refactor: modularize application domains
This commit is contained in:
+15
-141
@@ -1,142 +1,29 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
|
||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||
import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
import { buildModules } from "./app/modules";
|
||||
import {
|
||||
normalizeBrowserPath,
|
||||
readBrowserRoute,
|
||||
} from "./app/routing";
|
||||
|
||||
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
|
||||
const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
|
||||
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
|
||||
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
|
||||
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
|
||||
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
|
||||
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
|
||||
|
||||
const ASSETS_MODULE: ModuleConfig = {
|
||||
id: "assets",
|
||||
label: "资产管理",
|
||||
icon: Truck,
|
||||
component: AssetsModule,
|
||||
};
|
||||
|
||||
const MILEAGE_MODULE: ModuleConfig = {
|
||||
id: "mileage",
|
||||
label: "里程管理",
|
||||
icon: Route,
|
||||
component: MileageModule,
|
||||
};
|
||||
|
||||
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "vehicle-heatmap",
|
||||
label: "车辆",
|
||||
icon: MapPinned,
|
||||
component: VehicleHeatmapModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: "scheduling",
|
||||
label: "智能调度",
|
||||
icon: Activity,
|
||||
component: SchedulingModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_MODULE: ModuleConfig = {
|
||||
id: "hydrogen",
|
||||
label: "氢能",
|
||||
icon: Fuel,
|
||||
component: HydrogenModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "hydrogen-heatmap",
|
||||
label: "加氢",
|
||||
icon: MapPinned,
|
||||
component: HydrogenHeatmapModule,
|
||||
};
|
||||
|
||||
const ELECTRIC_MODULE: ModuleConfig = {
|
||||
id: "electric",
|
||||
label: "电能",
|
||||
icon: BatteryCharging,
|
||||
component: ElectricModule,
|
||||
};
|
||||
|
||||
const ETC_MODULE: ModuleConfig = {
|
||||
id: "etc",
|
||||
label: "ETC",
|
||||
icon: Receipt,
|
||||
component: EtcModule,
|
||||
};
|
||||
|
||||
/**
|
||||
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
|
||||
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
|
||||
*/
|
||||
function normalizePath() {
|
||||
if (typeof window === "undefined") return;
|
||||
const { pathname, hash, search } = window.location;
|
||||
|
||||
// 主路径 & 隐藏后台页保持不变
|
||||
if (pathname === "/asset" || pathname === "/energy") return;
|
||||
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
|
||||
|
||||
const legacyMap: Record<string, { path: string; hash?: string }> = {
|
||||
"/": { path: "/asset" },
|
||||
"/vehicle": { path: "/asset", hash: "assets" },
|
||||
"/assets": { path: "/asset", hash: "assets" },
|
||||
"/mileage": { path: "/asset", hash: "mileage" },
|
||||
"/scheduling": { path: "/asset", hash: "scheduling" },
|
||||
};
|
||||
|
||||
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
|
||||
const target = legacyMap[pathname] ?? { path: "/asset" };
|
||||
const finalHash = target.hash ? `#${target.hash}` : hash || "";
|
||||
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
|
||||
}
|
||||
|
||||
normalizePath();
|
||||
|
||||
type PathSet = "asset" | "energy";
|
||||
|
||||
function getPathSet(): PathSet {
|
||||
return window.location.pathname === "/energy" ? "energy" : "asset";
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setRouteKey(getRouteKey());
|
||||
setPathSet(getPathSet());
|
||||
setRoute(readBrowserRoute());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
@@ -150,23 +37,10 @@ function AuthGate() {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
|
||||
const modules = useMemo<ModuleConfig[]>(() => {
|
||||
if (pathSet === "energy") {
|
||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||
}
|
||||
const heatmapChildren = canAccessEnergy(user?.roles)
|
||||
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||
: [VEHICLE_HEATMAP_MODULE];
|
||||
const heatmapModule: ModuleConfig = {
|
||||
id: "heatmap",
|
||||
label: "热力图",
|
||||
icon: MapPinned,
|
||||
children: heatmapChildren,
|
||||
};
|
||||
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [pathSet, user?.roles]);
|
||||
const modules = useMemo(
|
||||
() => buildModules(pathSet, user?.roles),
|
||||
[pathSet, user?.roles],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildModules } from './modules.js';
|
||||
|
||||
function moduleIds(roles?: string[]): string[] {
|
||||
return buildModules('asset', roles).map(module => module.id);
|
||||
}
|
||||
|
||||
function heatmapChildIds(roles?: string[]): string[] {
|
||||
const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap');
|
||||
return heatmap?.children?.map(module => module.id) ?? [];
|
||||
}
|
||||
|
||||
test('keeps the base asset navigation and role-gated scheduling module', () => {
|
||||
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
|
||||
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
|
||||
'assets',
|
||||
'mileage',
|
||||
'heatmap',
|
||||
'scheduling',
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps energy heatmap visibility and the independent energy navigation', () => {
|
||||
assert.deepEqual(heatmapChildIds(), ['vehicle-heatmap']);
|
||||
assert.deepEqual(heatmapChildIds(['BI-LEADER-ENERGY']), [
|
||||
'hydrogen-heatmap',
|
||||
'vehicle-heatmap',
|
||||
]);
|
||||
assert.deepEqual(heatmapChildIds(['所有权限']), [
|
||||
'hydrogen-heatmap',
|
||||
'vehicle-heatmap',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
buildModules('energy', []).map(module => module.id),
|
||||
['hydrogen', 'electric', 'etc'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { lazy } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BatteryCharging,
|
||||
Fuel,
|
||||
MapPinned,
|
||||
Receipt,
|
||||
Route,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import type { ModuleConfig } from '../components/Shell';
|
||||
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
|
||||
import type { PathSet } from './routing';
|
||||
|
||||
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
|
||||
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
|
||||
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
|
||||
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
|
||||
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
|
||||
const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
|
||||
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
|
||||
const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/HydrogenHeatmapModule'));
|
||||
|
||||
const ASSET_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: 'vehicle-heatmap',
|
||||
label: '车辆',
|
||||
icon: MapPinned,
|
||||
component: VehicleHeatmapModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: 'hydrogen-heatmap',
|
||||
label: '加氢',
|
||||
icon: MapPinned,
|
||||
component: HydrogenHeatmapModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: 'scheduling',
|
||||
label: '智能调度',
|
||||
icon: Activity,
|
||||
component: SchedulingModule,
|
||||
};
|
||||
|
||||
const ENERGY_MODULES: ModuleConfig[] = [
|
||||
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
|
||||
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
|
||||
];
|
||||
|
||||
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
|
||||
export function buildModules(
|
||||
pathSet: PathSet,
|
||||
roles: readonly string[] | null | undefined,
|
||||
): ModuleConfig[] {
|
||||
if (pathSet === 'energy') return [...ENERGY_MODULES];
|
||||
|
||||
const heatmapChildren = canAccessEnergy(roles)
|
||||
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||
: [VEHICLE_HEATMAP_MODULE];
|
||||
const modules: ModuleConfig[] = [
|
||||
...ASSET_MODULES,
|
||||
{ id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren },
|
||||
];
|
||||
if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE);
|
||||
return modules;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js';
|
||||
|
||||
test('normalizes legacy asset paths without losing search parameters', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage');
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage');
|
||||
});
|
||||
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||
});
|
||||
|
||||
test('derives path groups and hidden routes from path or hash', () => {
|
||||
assert.equal(getPathSet('/energy'), 'energy');
|
||||
assert.equal(getPathSet('/asset'), 'asset');
|
||||
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export type PathSet = 'asset' | 'energy';
|
||||
|
||||
interface BrowserLocation {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
|
||||
|
||||
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||
'/': { path: '/asset' },
|
||||
'/vehicle': { path: '/asset', hash: 'assets' },
|
||||
'/assets': { path: '/asset', hash: 'assets' },
|
||||
'/mileage': { path: '/asset', hash: 'mileage' },
|
||||
'/scheduling': { path: '/asset', hash: 'scheduling' },
|
||||
};
|
||||
|
||||
/** 返回旧地址应替换成的规范地址;主路径返回 null。 */
|
||||
export function resolveCanonicalUrl(location: BrowserLocation): string | null {
|
||||
if (ROOT_PATHS.has(location.pathname)) return null;
|
||||
|
||||
const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' };
|
||||
const finalHash = target.hash ? `#${target.hash}` : location.hash;
|
||||
return `${target.path}${location.search}${finalHash}`;
|
||||
}
|
||||
|
||||
export function normalizeBrowserPath(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const target = resolveCanonicalUrl(window.location);
|
||||
if (target) window.history.replaceState(null, '', target);
|
||||
}
|
||||
|
||||
export function getPathSet(pathname: string): PathSet {
|
||||
return pathname === '/energy' ? 'energy' : 'asset';
|
||||
}
|
||||
|
||||
export function getRouteKey(pathname: string, hash: string): string {
|
||||
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||
return 'ele/import';
|
||||
}
|
||||
if (
|
||||
pathname === '/admin/feedback'
|
||||
|| hash === '#/admin/feedback'
|
||||
|| hash === '#admin/feedback'
|
||||
) {
|
||||
return 'admin/feedback';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } {
|
||||
return {
|
||||
routeKey: getRouteKey(window.location.pathname, window.location.hash),
|
||||
pathSet: getPathSet(window.location.pathname),
|
||||
};
|
||||
}
|
||||
+38
-352
@@ -1,24 +1,15 @@
|
||||
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';
|
||||
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
||||
import FeedbackModal from './feedback/FeedbackModal';
|
||||
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
||||
|
||||
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);
|
||||
@@ -42,38 +33,6 @@ function readAsDataUrl(file: File): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
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 [step, setStep] = useState<FeedbackStep>(1);
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
@@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
setTimeout(reset, 300);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -193,319 +150,48 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||||
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
|
||||
|
||||
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>
|
||||
<FeedbackLauncher
|
||||
isAdmin={isAdmin}
|
||||
menuOpen={menuOpen}
|
||||
onCloseMenu={() => setMenuOpen(false)}
|
||||
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<FeedbackModal
|
||||
canNext={canNext}
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={MAX_SCREENSHOTS}
|
||||
module={mod}
|
||||
open={open}
|
||||
progress={progress}
|
||||
screenshots={shots}
|
||||
step={step}
|
||||
submitting={submitting}
|
||||
textareaRef={taRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={addFiles}
|
||||
onBack={back}
|
||||
onChangeContent={setContent}
|
||||
onChangeModule={setMod}
|
||||
onClose={close}
|
||||
onNext={next}
|
||||
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { ImagePlus, Loader2, X } from 'lucide-react';
|
||||
import type { FeedbackType, UploadedImg } from './types';
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
'': '通用',
|
||||
};
|
||||
|
||||
function getPlaceholder(type: FeedbackType | null): string {
|
||||
if (type === 'dimension') return '比如:希望按客户/区域/日期范围 等等切片看里程数据…';
|
||||
if (type === 'bug') return '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)';
|
||||
if (type === 'ux') return '比如:能不能把外部 tab 默认收起,加载快一点…';
|
||||
return '随便聊聊你的想法';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
error: string | null;
|
||||
fileRef: RefObject<HTMLInputElement | null>;
|
||||
maxScreenshots: number;
|
||||
module: string;
|
||||
screenshots: UploadedImg[];
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
type: FeedbackType | null;
|
||||
uploading: boolean;
|
||||
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeModule: (module: string) => void;
|
||||
onRemoveScreenshot: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackFormStep({
|
||||
content,
|
||||
error,
|
||||
fileRef,
|
||||
maxScreenshots,
|
||||
module,
|
||||
screenshots,
|
||||
textareaRef,
|
||||
type,
|
||||
uploading,
|
||||
onAddFiles,
|
||||
onChangeContent,
|
||||
onChangeModule,
|
||||
onRemoveScreenshot,
|
||||
}: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
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={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => onChangeContent(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();
|
||||
onAddFiles(imgs);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder={getPlaceholder(type)}
|
||||
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">{screenshots.length}/{maxScreenshots},可粘贴</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) onAddFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{screenshots.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={() => onRemoveScreenshot(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>
|
||||
))}
|
||||
{screenshots.length < maxScreenshots && (
|
||||
<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(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onChangeModule(key)}
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${module === key ? '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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
Inbox,
|
||||
MessageCircleHeart,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
isAdmin: boolean;
|
||||
menuOpen: boolean;
|
||||
onCloseMenu: () => void;
|
||||
onOpenFeedback: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onToggleMenu: () => void;
|
||||
}
|
||||
|
||||
export default function FeedbackLauncher({
|
||||
isAdmin,
|
||||
menuOpen,
|
||||
onCloseMenu,
|
||||
onOpenFeedback,
|
||||
onOpenHistory,
|
||||
onToggleMenu,
|
||||
}: Props) {
|
||||
return (
|
||||
<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={onToggleMenu}
|
||||
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={onOpenFeedback}
|
||||
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={onOpenHistory}
|
||||
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={onCloseMenu}
|
||||
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={onCloseMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react';
|
||||
import FeedbackFormStep from './FeedbackFormStep';
|
||||
import FeedbackSuccessStep from './FeedbackSuccessStep';
|
||||
import FeedbackTypeStep from './FeedbackTypeStep';
|
||||
import type { FeedbackStep, FeedbackType, UploadedImg } from './types';
|
||||
|
||||
interface Props {
|
||||
canNext: boolean;
|
||||
content: string;
|
||||
error: string | null;
|
||||
fileRef: RefObject<HTMLInputElement | null>;
|
||||
maxScreenshots: number;
|
||||
module: string;
|
||||
open: boolean;
|
||||
progress: number;
|
||||
screenshots: UploadedImg[];
|
||||
step: FeedbackStep;
|
||||
submitting: boolean;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
type: FeedbackType | null;
|
||||
uploading: boolean;
|
||||
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||
onBack: () => void;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeModule: (module: string) => void;
|
||||
onClose: () => void;
|
||||
onNext: () => void;
|
||||
onRemoveScreenshot: (index: number) => void;
|
||||
onSelectType: (type: FeedbackType) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackModal({
|
||||
canNext,
|
||||
content,
|
||||
error,
|
||||
fileRef,
|
||||
maxScreenshots,
|
||||
module,
|
||||
open,
|
||||
progress,
|
||||
screenshots,
|
||||
step,
|
||||
submitting,
|
||||
textareaRef,
|
||||
type,
|
||||
uploading,
|
||||
onAddFiles,
|
||||
onBack,
|
||||
onChangeContent,
|
||||
onChangeModule,
|
||||
onClose,
|
||||
onNext,
|
||||
onRemoveScreenshot,
|
||||
onSelectType,
|
||||
}: Props) {
|
||||
return (
|
||||
<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()}
|
||||
>
|
||||
<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">
|
||||
<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={onClose} 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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<FeedbackTypeStep
|
||||
key="s1"
|
||||
selectedType={type}
|
||||
onSelect={onSelectType}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<FeedbackFormStep
|
||||
key="s2"
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={maxScreenshots}
|
||||
module={module}
|
||||
screenshots={screenshots}
|
||||
textareaRef={textareaRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={onAddFiles}
|
||||
onChangeContent={onChangeContent}
|
||||
onChangeModule={onChangeModule}
|
||||
onRemoveScreenshot={onRemoveScreenshot}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && <FeedbackSuccessStep key="s3" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{step < 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
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={onNext}
|
||||
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={onClose}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
export default function FeedbackSuccessStep() {
|
||||
return (
|
||||
<motion.div
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Bug,
|
||||
ChevronRight,
|
||||
Lightbulb,
|
||||
NotebookPen,
|
||||
Palette,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import RotatingFooterHint from '../RotatingFooterHint';
|
||||
import type { FeedbackType } from './types';
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
selectedType: FeedbackType | null;
|
||||
onSelect: (type: FeedbackType) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackTypeStep({ selectedType, onSelect }: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
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 = selectedType === 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={() => onSelect(opt.key)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type FeedbackStep = 1 | 2 | 3;
|
||||
|
||||
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
export interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
+157
-1695
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SubjectOption } from '../api';
|
||||
|
||||
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
|
||||
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
|
||||
|
||||
interface AssetsHeaderProps {
|
||||
activeTab: AssetsTab;
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
|
||||
theme: AssetsTheme;
|
||||
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
|
||||
lastUpdate: string;
|
||||
loading: boolean;
|
||||
selectedSubject: string | null;
|
||||
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
subjects: SubjectOption[];
|
||||
subjectDropdownOpen: boolean;
|
||||
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
subjectSearch: string;
|
||||
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
// --- Constants ---
|
||||
const TABS = [
|
||||
{ id: 'overview', label: '总览' },
|
||||
{ id: 'department', label: '按部门' },
|
||||
{ id: 'region', label: '按区域' },
|
||||
{ id: 'customer', label: '按客户' },
|
||||
];
|
||||
|
||||
function MarqueeBanner() {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
if (!trackRef.current || !innerRef.current) return;
|
||||
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
||||
};
|
||||
check();
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(trackRef.current!);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
|
||||
|
||||
return (
|
||||
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
|
||||
<div ref={trackRef} className="overflow-hidden">
|
||||
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
||||
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||
{text}
|
||||
</span>
|
||||
{overflow && (
|
||||
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsHeader({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
theme,
|
||||
setTheme,
|
||||
lastUpdate,
|
||||
loading,
|
||||
selectedSubject,
|
||||
setSelectedSubject,
|
||||
subjects,
|
||||
subjectDropdownOpen,
|
||||
setSubjectDropdownOpen,
|
||||
subjectSearch,
|
||||
setSubjectSearch,
|
||||
subjectDropdownRef,
|
||||
}: AssetsHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Compact Header Bar */}
|
||||
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
|
||||
{/* Title row */}
|
||||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||
{/* Right: status + theme */}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||
<span>{lastUpdate}</span>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<Loader2 className="animate-spin" size={10} />
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||||
{(['soft','minimal','vibrant'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTheme(t)}
|
||||
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 归属公司作用域筛选 (Scope Chip) */}
|
||||
<div className="flex items-center justify-center px-4 pt-1">
|
||||
<div className="relative" ref={subjectDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSubjectDropdownOpen((o) => !o);
|
||||
setSubjectSearch('');
|
||||
}}
|
||||
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
|
||||
selectedSubject
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
title={selectedSubject || '全部公司'}
|
||||
>
|
||||
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
|
||||
<span className="max-w-[180px] truncate">
|
||||
所属公司:{selectedSubject || '全部公司'}
|
||||
</span>
|
||||
{selectedSubject ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedSubject(null);
|
||||
}}
|
||||
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
|
||||
aria-label="清除归属公司筛选"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown size={11} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{subjectDropdownOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
autoFocus
|
||||
value={subjectSearch}
|
||||
onChange={(e) => setSubjectSearch(e.target.value)}
|
||||
placeholder="搜索公司名"
|
||||
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSubject(null);
|
||||
setSubjectDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||
全部公司
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{subjects.reduce((s, x) => s + x.total, 0)} 台
|
||||
</span>
|
||||
</button>
|
||||
<div className="my-1 mx-3 border-t border-gray-100" />
|
||||
{subjects
|
||||
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
|
||||
.map((s) => {
|
||||
const active = selectedSubject === s.name;
|
||||
return (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSubject(s.name);
|
||||
setSubjectDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
|
||||
}`}
|
||||
title={s.name}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||
{s.total} 台
|
||||
<span className="mx-1 text-gray-200">·</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab row */}
|
||||
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'text-blue-600 font-medium'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Status row */}
|
||||
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||
最后更新: {lastUpdate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OneOS 迁移提示滚动条 */}
|
||||
<MarqueeBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowDetailItem, FlowType } from '../api';
|
||||
|
||||
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||
};
|
||||
|
||||
interface FlowDetailModalProps {
|
||||
selectedFlow: { date: string; type: FlowType } | null;
|
||||
selectedFlowDetails: FlowDetailItem[];
|
||||
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
}
|
||||
export function FlowDetailModal({
|
||||
selectedFlow,
|
||||
selectedFlowDetails,
|
||||
setSelectedFlow,
|
||||
exportFlowDetails,
|
||||
}: FlowDetailModalProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Flow Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedFlow && (
|
||||
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<motion.div
|
||||
data-testid="asset-flow-detail-modal"
|
||||
initial={{ opacity: 0, y: 28, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
|
||||
>
|
||||
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">提交时间口径</div>
|
||||
<h3 className="mt-1 text-lg font-black">
|
||||
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细
|
||||
</h3>
|
||||
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||
共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFlow(null)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
disabled={selectedFlowDetails.length === 0}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} />
|
||||
导出当前明细
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
|
||||
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||
<tr>
|
||||
<th className="w-28 px-3 py-3">车牌</th>
|
||||
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}时间</th>
|
||||
<th className="w-40 px-3 py-3">提交时间</th>
|
||||
<th className="w-36 px-3 py-3">部门</th>
|
||||
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||
<th className="px-3 py-3">客户</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-blue-50/40">
|
||||
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
|
||||
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||
<td className="px-3 py-3">{item.manager || '-'}</td>
|
||||
<td className="px-3 py-3">{item.customerName || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 lg:hidden">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
|
||||
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
|
||||
{item.typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||
<div>提交</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">{item.typeLabel}时间</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">负责人</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">部门</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">客户</div>
|
||||
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedFlowDetails.length === 0 && (
|
||||
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||
import type { OverviewViewProps } from './overview/types';
|
||||
|
||||
export function OverviewView(props: OverviewViewProps) {
|
||||
return (
|
||||
<>
|
||||
<SummaryMetrics
|
||||
summary={props.summary}
|
||||
operatingRate={props.operatingRate}
|
||||
inventoryRate={props.inventoryRate}
|
||||
pendingRate={props.pendingRate}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<FlowStatisticsCard
|
||||
flowRange={props.flowRange}
|
||||
setFlowRange={props.setFlowRange}
|
||||
flowLoading={props.flowLoading}
|
||||
flowStats={props.flowStats}
|
||||
exportFlowDetails={props.exportFlowDetails}
|
||||
flowDailyExpanded={props.flowDailyExpanded}
|
||||
setFlowDailyExpanded={props.setFlowDailyExpanded}
|
||||
setSelectedFlow={props.setSelectedFlow}
|
||||
/>
|
||||
|
||||
<AssetSummarySection
|
||||
processedData={props.processedData}
|
||||
theme={props.theme}
|
||||
expandedAssetTypes={props.expandedAssetTypes}
|
||||
toggleAssetType={props.toggleAssetType}
|
||||
expandedModels={props.expandedModels}
|
||||
toggleModel={props.toggleModel}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<InventoryStatisticsSection
|
||||
inventoryFilters={props.inventoryFilters}
|
||||
setInventoryFilters={props.setInventoryFilters}
|
||||
draftInventoryFilters={props.draftInventoryFilters}
|
||||
setDraftInventoryFilters={props.setDraftInventoryFilters}
|
||||
isInventoryFilterOpen={props.isInventoryFilterOpen}
|
||||
setIsInventoryFilterOpen={props.setIsInventoryFilterOpen}
|
||||
uniqueInventoryRegions={props.uniqueInventoryRegions}
|
||||
uniqueInventoryCities={props.uniqueInventoryCities}
|
||||
uniqueInventoryBrands={props.uniqueInventoryBrands}
|
||||
uniqueInventoryTypes={props.uniqueInventoryTypes}
|
||||
uniqueInventoryModelsForType={props.uniqueInventoryModelsForType}
|
||||
filteredInventoryStats={props.filteredInventoryStats}
|
||||
inventoryTab={props.inventoryTab}
|
||||
setInventoryTab={props.setInventoryTab}
|
||||
inventoryByRegion={props.inventoryByRegion}
|
||||
expandedInventoryRegions={props.expandedInventoryRegions}
|
||||
toggleInventoryRegion={props.toggleInventoryRegion}
|
||||
inventoryByModel={props.inventoryByModel}
|
||||
expandedInventoryTypes={props.expandedInventoryTypes}
|
||||
toggleInventoryType={props.toggleInventoryType}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import { SearchSelect } from '../../../components/SearchSelect';
|
||||
import type { WeeklyDetailItem } from '../api';
|
||||
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
|
||||
import type { VehicleListItem } from '../types';
|
||||
|
||||
interface VehicleDetailModalProps {
|
||||
selection: VehicleModalSelection | null;
|
||||
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
|
||||
isFilterExpanded: boolean;
|
||||
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
filters: ModalVehicleFilters;
|
||||
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
|
||||
plates: string[];
|
||||
models: string[];
|
||||
brands: string[];
|
||||
locations: string[];
|
||||
loading: boolean;
|
||||
weeklyDetails: WeeklyDetailItem[];
|
||||
filteredWeeklyDetails: WeeklyDetailItem[];
|
||||
filteredVehicles: VehicleListItem[];
|
||||
}
|
||||
export function VehicleDetailModal({
|
||||
selection: showPlateNumbers,
|
||||
setSelection: setShowPlateNumbers,
|
||||
isFilterExpanded: isModalFilterExpanded,
|
||||
setIsFilterExpanded: setIsModalFilterExpanded,
|
||||
filters: modalFilters,
|
||||
setFilters: setModalFilters,
|
||||
plates: uniqueModalPlates,
|
||||
models: uniqueModalModels,
|
||||
brands: uniqueModalBrands,
|
||||
locations: uniqueModalLocations,
|
||||
loading: modalLoading,
|
||||
weeklyDetails: modalWeeklyDetail,
|
||||
filteredWeeklyDetails: filteredModalWeeklyDetail,
|
||||
filteredVehicles: filteredModalVehicles,
|
||||
}: VehicleDetailModalProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Vehicle Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{showPlateNumbers && (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
|
||||
<div>
|
||||
<h3 className="font-bold text-base flex items-center gap-2">
|
||||
<Truck size={18} className="text-blue-400" />
|
||||
{showPlateNumbers.title || (
|
||||
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
||||
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-[10px] opacity-60 mt-0.5">
|
||||
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
||||
{showPlateNumbers.category === 'Pending' ? '待交车' :
|
||||
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
|
||||
showPlateNumbers.category === 'Returned' ? '已还车' :
|
||||
showPlateNumbers.category === 'Replaced' ? '已替换' :
|
||||
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
|
||||
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/10 p-2 rounded-full transition-colors">
|
||||
<PlusCircle className="rotate-45" size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Filters */}
|
||||
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer py-1"
|
||||
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<Filter size={14} />
|
||||
<span className="text-xs font-bold">筛选条件</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Quick Search always visible when collapsed */}
|
||||
{!isModalFilterExpanded && (
|
||||
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown size={16} className="text-slate-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isModalFilterExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
|
||||
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车型</option>
|
||||
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
|
||||
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部品牌</option>
|
||||
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
|
||||
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部地区</option>
|
||||
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pb-2">
|
||||
<button
|
||||
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
|
||||
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
|
||||
{modalLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||
</div>
|
||||
) : modalWeeklyDetail.length > 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||
<th className="p-2 font-semibold text-center">日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalWeeklyDetail.map((v, i) => (
|
||||
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
|
||||
<table className="min-w-full w-max text-left border-collapse">
|
||||
<thead className="sticky top-0 z-20 shadow-sm">
|
||||
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务部门</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务负责人</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-16">品牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-16">车型</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-48">资产归属</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-48">客户名称</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">车牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center">状态</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">提车时间</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">合同到期时间</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">运营区域</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center">离到期</th>
|
||||
<th className="p-2 font-semibold w-48">签约公司</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||
)}
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">品牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">车型</th>
|
||||
<th className="p-2 font-semibold text-center">所在地</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalVehicles.map((v, idx) => (
|
||||
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
)}
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{filteredModalVehicles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
|
||||
暂无符合条件的车辆数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
|
||||
<div className="text-xs text-gray-500">
|
||||
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers(null)}
|
||||
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
|
||||
>
|
||||
确认并关闭
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummaryDesktopTable({
|
||||
processedData,
|
||||
theme,
|
||||
expandedAssetTypes,
|
||||
toggleAssetType,
|
||||
expandedModels,
|
||||
toggleModel,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{processedData.map((typeGroup) => (
|
||||
<React.Fragment key={typeGroup.type}>
|
||||
{/* Category Header Row */}
|
||||
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
|
||||
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
|
||||
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||
}
|
||||
<span>{typeGroup.type}</span>
|
||||
</div>
|
||||
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
|
||||
<React.Fragment key={model.model}>
|
||||
<motion.tr
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||
onClick={() => toggleModel(model.model)}
|
||||
>
|
||||
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
|
||||
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
|
||||
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
|
||||
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.total}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.inventory}
|
||||
</button>
|
||||
</td>
|
||||
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||
{model.inventoryRegions[reg] > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.inventoryRegions[reg]}
|
||||
</button>
|
||||
) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
{model.pending > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.pending}
|
||||
</button>
|
||||
) : model.pending}
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
|
||||
}}
|
||||
className="text-green-600 hover:underline font-bold"
|
||||
>
|
||||
{model.operating}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||
{model.weeklyDelivered > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyDelivered}
|
||||
</button>
|
||||
) : model.weeklyDelivered}
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||
{model.weeklyReturned > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyReturned}
|
||||
</button>
|
||||
) : model.weeklyReturned}
|
||||
</td>
|
||||
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||
{model.weeklyReplaced > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyReplaced}
|
||||
</button>
|
||||
) : model.weeklyReplaced}
|
||||
</td>
|
||||
</motion.tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummaryMobileCards({
|
||||
processedData,
|
||||
theme,
|
||||
expandedAssetTypes,
|
||||
toggleAssetType,
|
||||
expandedModels,
|
||||
toggleModel,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-4 space-y-4">
|
||||
{processedData.map((typeGroup) => (
|
||||
<div key={typeGroup.type} className="space-y-3">
|
||||
<div
|
||||
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
|
||||
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
|
||||
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
|
||||
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
|
||||
}
|
||||
<span className="text-xs font-bold">{typeGroup.type}</span>
|
||||
</div>
|
||||
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedAssetTypes.has(typeGroup.type) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="space-y-3 overflow-hidden"
|
||||
>
|
||||
{typeGroup.models.map((model) => (
|
||||
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
|
||||
onClick={() => toggleModel(model.model)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedModels.has(model.model) && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
|
||||
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
|
||||
<span className="text-[10px] text-gray-400">总库存</span>
|
||||
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
|
||||
<span className="text-[10px] text-gray-400">待交车</span>
|
||||
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
|
||||
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||
<div key={reg} className="text-center">
|
||||
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
|
||||
{model.inventoryRegions[reg] > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||
}}
|
||||
className="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
>
|
||||
{model.inventoryRegions[reg]}
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-[10px] font-bold text-gray-300">-</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
|
||||
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
|
||||
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
|
||||
</div>
|
||||
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
|
||||
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
|
||||
</div>
|
||||
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
|
||||
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
|
||||
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummarySection(props: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
|
||||
<Info size={10} />
|
||||
点击型号展开详情
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<AssetSummaryDesktopTable {...props} />
|
||||
|
||||
{/* Mobile View Cards for Asset Summary */}
|
||||
<AssetSummaryMobileCards {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowType } from '../../api';
|
||||
import { FLOW_META } from '../FlowDetailModal';
|
||||
import type { FlowStatisticsCardProps } from './types';
|
||||
|
||||
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||
|
||||
export function FlowStatisticsCard({
|
||||
flowRange,
|
||||
setFlowRange,
|
||||
flowLoading,
|
||||
flowStats,
|
||||
exportFlowDetails,
|
||||
flowDailyExpanded,
|
||||
setFlowDailyExpanded,
|
||||
setSelectedFlow,
|
||||
}: FlowStatisticsCardProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
|
||||
<CalendarDays size={14} className="text-blue-500" />
|
||||
<span>交还车统计</span>
|
||||
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails()}
|
||||
disabled={!flowStats?.details.length}
|
||||
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">开始</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.start}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400">至</div>
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">结束</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.end}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||
<div className="grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{FLOW_TYPES.map((type) => (
|
||||
<div key={type} className="border-l border-slate-200/70 px-2">
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||
{flowStats?.totals[type] ?? 0}
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="asset-flow-daily-toggle"
|
||||
onClick={() => setFlowDailyExpanded((prev) => !prev)}
|
||||
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
|
||||
>
|
||||
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
|
||||
<span className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||
{flowStats?.daily.length ?? 0} 天
|
||||
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{flowDailyExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
|
||||
{flowLoading && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">正在加载交还车统计...</div>
|
||||
)}
|
||||
{!flowLoading && flowStats?.daily.map((day) => (
|
||||
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
|
||||
<div className="text-[10px] font-bold italic text-slate-300">提交时间</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{FLOW_TYPES.map((type) => {
|
||||
const count = day[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
data-testid={`asset-flow-cell-${day.date}-${type}`}
|
||||
disabled={count === 0}
|
||||
onClick={() => setSelectedFlow({ date: day.date, type })}
|
||||
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!flowLoading && !flowStats?.daily.length && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无交换车数据</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
type InventoryDesktopTableProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryDesktopTable({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryDesktopTableProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
||||
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<React.Fragment key={region}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{region}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
||||
}}>
|
||||
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||
<React.Fragment key={city}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${city}-${stat.model}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<React.Fragment key={type}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{type}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
|
||||
}}>
|
||||
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
||||
<React.Fragment key={model}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
type InventoryMobileListProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryMobileList({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryMobileListProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-3 space-y-3">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{region}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryRegions.has(region) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(cities).map(([city, stats]) => (
|
||||
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.model}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{type}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryTypes.has(type) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(models).map(([model, stats]) => (
|
||||
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Filter } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
||||
import { InventoryMobileList } from './InventoryMobileList';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
export function InventoryStatisticsSection({
|
||||
inventoryFilters,
|
||||
setInventoryFilters,
|
||||
draftInventoryFilters,
|
||||
setDraftInventoryFilters,
|
||||
isInventoryFilterOpen,
|
||||
setIsInventoryFilterOpen,
|
||||
uniqueInventoryRegions,
|
||||
uniqueInventoryCities,
|
||||
uniqueInventoryBrands,
|
||||
uniqueInventoryTypes,
|
||||
uniqueInventoryModelsForType,
|
||||
filteredInventoryStats,
|
||||
inventoryTab,
|
||||
setInventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryStatisticsSectionProps) {
|
||||
const listProps = {
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span>筛选</span>
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isInventoryFilterOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
||||
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
||||
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部区域</option>
|
||||
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
|
||||
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部城市</option>
|
||||
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
|
||||
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部品牌</option>
|
||||
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
|
||||
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车型</option>
|
||||
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
|
||||
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部批次</option>
|
||||
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||
<button
|
||||
onClick={() => setInventoryTab('region')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInventoryTab('model')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按车型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Bar */}
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||
{inventoryFilters.region && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
区域: {inventoryFilters.region}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.city && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
城市: {inventoryFilters.city}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.brand && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
品牌: {inventoryFilters.brand}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.type && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
车型: {inventoryFilters.type}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.model && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
批次: {inventoryFilters.model}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<InventoryDesktopTable {...listProps} />
|
||||
|
||||
{/* Mobile View */}
|
||||
<InventoryMobileList {...listProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import type { SummaryMetricsProps } from './types';
|
||||
|
||||
export function SummaryMetrics({
|
||||
summary: SUMMARY,
|
||||
operatingRate,
|
||||
inventoryRate,
|
||||
pendingRate,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: SummaryMetricsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||
{/* Total Assets */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Truck size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
|
||||
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operating */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<Activity size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && ` 挂${SUMMARY.operating.hanging}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Warehouse size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<PlusCircle size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div>
|
||||
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||
import type {
|
||||
DateRange,
|
||||
InventoryFilters,
|
||||
VehicleModalSelection,
|
||||
} from '../../model';
|
||||
import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types';
|
||||
import type { AssetsTheme } from '../AssetsHeader';
|
||||
|
||||
export interface SummaryMetricsProps {
|
||||
summary: SummaryData;
|
||||
operatingRate: number;
|
||||
inventoryRate: number;
|
||||
pendingRate: number;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export interface FlowStatisticsCardProps {
|
||||
flowRange: DateRange;
|
||||
setFlowRange: Dispatch<SetStateAction<DateRange>>;
|
||||
flowLoading: boolean;
|
||||
flowStats: FlowStatsResponse | null;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
flowDailyExpanded: boolean;
|
||||
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
}
|
||||
|
||||
export interface AssetSummarySectionProps {
|
||||
processedData: TypeSummary[];
|
||||
theme: AssetsTheme;
|
||||
expandedAssetTypes: Set<string>;
|
||||
toggleAssetType: (type: string) => void;
|
||||
expandedModels: Set<string>;
|
||||
toggleModel: (model: string) => void;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export interface InventoryStatisticsSectionProps {
|
||||
inventoryFilters: InventoryFilters;
|
||||
setInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||
draftInventoryFilters: InventoryFilters;
|
||||
setDraftInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||
isInventoryFilterOpen: boolean;
|
||||
setIsInventoryFilterOpen: Dispatch<SetStateAction<boolean>>;
|
||||
uniqueInventoryRegions: string[];
|
||||
uniqueInventoryCities: string[];
|
||||
uniqueInventoryBrands: string[];
|
||||
uniqueInventoryTypes: string[];
|
||||
uniqueInventoryModelsForType: string[];
|
||||
filteredInventoryStats: RegionalInventoryStats[];
|
||||
inventoryTab: 'region' | 'model';
|
||||
setInventoryTab: Dispatch<SetStateAction<'region' | 'model'>>;
|
||||
inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||
expandedInventoryRegions: Set<string>;
|
||||
toggleInventoryRegion: (region: string) => void;
|
||||
inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||
expandedInventoryTypes: Set<string>;
|
||||
toggleInventoryType: (type: string) => void;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export type OverviewViewProps = SummaryMetricsProps
|
||||
& FlowStatisticsCardProps
|
||||
& AssetSummarySectionProps
|
||||
& InventoryStatisticsSectionProps
|
||||
& {
|
||||
allTypesExpanded: boolean;
|
||||
toggleAllAssetTypes: () => void;
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { FlowStatsResponse, WeeklyDetailItem } from './api';
|
||||
import type {
|
||||
CustomerStats,
|
||||
DeptGroup,
|
||||
ManagerStats,
|
||||
RegionalInventoryStats,
|
||||
VehicleListItem,
|
||||
} from './types';
|
||||
import {
|
||||
buildCustomerPieData,
|
||||
buildVehicleModalRequest,
|
||||
deriveCustomerView,
|
||||
deriveDepartmentView,
|
||||
deriveInventoryView,
|
||||
deriveModalVehicleView,
|
||||
formatLocalDate,
|
||||
formatLocalDateTime,
|
||||
getWeeklyFlowRange,
|
||||
groupInventoryByModel,
|
||||
selectFlowDetails,
|
||||
} from './model';
|
||||
|
||||
const emptyInventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
|
||||
const emptyCustomerFilters = { customer: [], brand: '', department: '', manager: '', region: '' };
|
||||
const emptyModalFilters = { plateNumber: '', model: '', brand: '', location: '' };
|
||||
|
||||
function inventory(overrides: Partial<RegionalInventoryStats>): RegionalInventoryStats {
|
||||
return {
|
||||
region: '广东',
|
||||
city: '广州',
|
||||
brand: '现代',
|
||||
type: '18T',
|
||||
model: 'M1',
|
||||
batch: 'B1',
|
||||
quantity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function manager(overrides: Partial<ManagerStats>): ManagerStats {
|
||||
return {
|
||||
manager: '负责人甲',
|
||||
department: '业务一部',
|
||||
t4_5: 0,
|
||||
t4_5c: 0,
|
||||
t18: 0,
|
||||
t49: 0,
|
||||
trailer: 0,
|
||||
other: 0,
|
||||
total: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function department(name: string, managers: ManagerStats[]): DeptGroup {
|
||||
return {
|
||||
department: name,
|
||||
totalAssets: managers.reduce((sum, item) => sum + item.total, 0),
|
||||
operatingCount: 0,
|
||||
idleCount: 0,
|
||||
attendanceRate: 0,
|
||||
avgMileage: 0,
|
||||
managers,
|
||||
};
|
||||
}
|
||||
|
||||
function customer(overrides: Partial<CustomerStats>): CustomerStats {
|
||||
return {
|
||||
customer: '客户甲',
|
||||
manager: '负责人甲',
|
||||
brand: '现代',
|
||||
department: '业务一部',
|
||||
region: '广东',
|
||||
city: '广州',
|
||||
t4_5: 0,
|
||||
t4_5c: 0,
|
||||
t18: 0,
|
||||
t49: 0,
|
||||
trailer: 0,
|
||||
other: 0,
|
||||
total: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vehicle(overrides: Partial<VehicleListItem>): VehicleListItem {
|
||||
return {
|
||||
id: 1,
|
||||
plateNumber: '粤A00001',
|
||||
vin: 'VIN-1',
|
||||
type: '18T',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
province: '广东',
|
||||
city: '广州',
|
||||
status: '运营',
|
||||
ownership: '自营',
|
||||
contractNo: null,
|
||||
customerName: null,
|
||||
subjectOrg: null,
|
||||
departmentName: null,
|
||||
customerManager: null,
|
||||
brandLabel: '现代',
|
||||
orgName: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('本地日期格式不引入 UTC 偏移', () => {
|
||||
const date = new Date(2026, 7, 3, 9, 7, 5);
|
||||
assert.equal(formatLocalDate(date), '2026-08-03');
|
||||
assert.equal(formatLocalDateTime(date), '2026-08-03 09:07:05');
|
||||
});
|
||||
|
||||
test('周流转区间按周六至周五计算', () => {
|
||||
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 12)), {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
});
|
||||
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 15)), {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
});
|
||||
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 16)), {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
});
|
||||
});
|
||||
|
||||
test('交还替明细走周接口且原样保留周筛选参数', () => {
|
||||
assert.deepEqual(
|
||||
buildVehicleModalRequest({
|
||||
batch: 'All',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
category: 'Delivered',
|
||||
source: 'asset',
|
||||
}, '主体甲'),
|
||||
{
|
||||
kind: 'weekly',
|
||||
type: 'delivered',
|
||||
filters: { model: 'M1', batch: 'All', location: '广州', source: 'asset' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('待交付与车型映射继续走车辆列表参数', () => {
|
||||
assert.deepEqual(
|
||||
buildVehicleModalRequest({
|
||||
batch: 'B1',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
category: 'Pending',
|
||||
type: '4.5T',
|
||||
isColdChain: true,
|
||||
isTrailer: false,
|
||||
manager: '负责人甲',
|
||||
}, '主体甲'),
|
||||
{
|
||||
kind: 'vehicles',
|
||||
params: {
|
||||
batch: 'B1',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
category: 'Pending',
|
||||
manager: '负责人甲',
|
||||
vehicleType: '4.5T冷链',
|
||||
subject: '主体甲',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildVehicleModalRequest({
|
||||
batch: 'All',
|
||||
model: 'All',
|
||||
location: 'All',
|
||||
isColdChain: false,
|
||||
isTrailer: true,
|
||||
}, null),
|
||||
{
|
||||
kind: 'vehicles',
|
||||
params: { isColdChain: 'false', isTrailer: 'true', subject: null },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('库存筛选与区域、车型分组保持既有顺序', () => {
|
||||
const rows = [
|
||||
inventory({ region: '广东', city: '广州', type: '18T', model: 'M1', quantity: 2 }),
|
||||
inventory({ region: '浙江', city: '嘉兴', type: '4.5T普货', model: 'M2', quantity: 3 }),
|
||||
inventory({ region: '广东', city: '佛山', type: '18T', model: 'M3', quantity: 4 }),
|
||||
inventory({ region: '广东', city: '广州', type: '新增车型', model: 'M4', quantity: 5 }),
|
||||
];
|
||||
const view = deriveInventoryView(
|
||||
rows,
|
||||
{ ...emptyInventoryFilters, region: '广东', type: '18T' },
|
||||
'18T',
|
||||
);
|
||||
|
||||
assert.deepEqual(view.filtered.map((item) => item.model), ['M1', 'M3']);
|
||||
assert.deepEqual(view.modelsForType, ['M1', 'M3']);
|
||||
assert.deepEqual(Object.keys(view.byRegion), ['广东']);
|
||||
assert.deepEqual(Object.keys(view.byRegion['广东']), ['广州', '佛山']);
|
||||
assert.deepEqual(Object.keys(view.byModel), ['18T']);
|
||||
assert.deepEqual(
|
||||
Object.keys(groupInventoryByModel(rows)),
|
||||
['4.5T普货', '18T', '新增车型'],
|
||||
);
|
||||
});
|
||||
|
||||
test('部门负责人去重、分组并按车辆数倒序', () => {
|
||||
const departments = [
|
||||
department('业务二部', [
|
||||
manager({ manager: '负责人乙', department: '业务二部', total: 3 }),
|
||||
manager({ manager: '负责人甲', department: '业务二部', total: 8 }),
|
||||
]),
|
||||
department('业务一部', [manager({ manager: '负责人甲', total: 5 })]),
|
||||
];
|
||||
const view = deriveDepartmentView(departments, 'All');
|
||||
|
||||
assert.deepEqual(view.managers, ['负责人乙', '负责人甲']);
|
||||
assert.deepEqual(view.groupedManagers, [
|
||||
{ department: '业务二部', managers: ['负责人乙', '负责人甲'] },
|
||||
{ department: '业务一部', managers: ['负责人甲'] },
|
||||
]);
|
||||
assert.deepEqual(view.managerStats.map((item) => item.total), [8, 5, 3]);
|
||||
});
|
||||
|
||||
test('客户筛选与负责人分组合并部门和客户来源', () => {
|
||||
const departments = [
|
||||
department('业务二部', [manager({ manager: '负责人乙', department: '业务二部' })]),
|
||||
department('业务一部', [manager({ manager: '负责人甲' })]),
|
||||
];
|
||||
const customers = [
|
||||
customer({ customer: '客户甲', department: '业务一部', manager: '负责人甲', total: 2 }),
|
||||
customer({ customer: '客户乙', department: '业务二部', manager: '负责人丙', region: '浙江', city: '嘉兴', total: 3 }),
|
||||
customer({ customer: '公务车', department: '公务车', manager: '负责人丁', total: 1 }),
|
||||
];
|
||||
const view = deriveCustomerView(
|
||||
customers,
|
||||
departments,
|
||||
{ ...emptyCustomerFilters, customer: ['客户乙'], region: '浙江' },
|
||||
);
|
||||
|
||||
assert.deepEqual(view.filtered.map((item) => item.customer), ['客户乙']);
|
||||
assert.deepEqual(view.departments, ['业务一部', '业务二部', '公务车']);
|
||||
assert.deepEqual(view.managersByDepartment, [
|
||||
{ department: '业务一部', managers: ['负责人甲'] },
|
||||
{ department: '业务二部', managers: ['负责人乙', '负责人丙'] },
|
||||
{ department: '公务车', managers: ['负责人丁'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('弹窗筛选沿用车牌为空时回退 VIN 的规则', () => {
|
||||
const vehicles = [
|
||||
vehicle({ id: 1 }),
|
||||
vehicle({ id: 2, plateNumber: '', vin: 'VIN-2', model: 'M2', brandLabel: '福田', location: '嘉兴' }),
|
||||
];
|
||||
const weeklyDetails: WeeklyDetailItem[] = [
|
||||
{ truck_id: 1, plate_number: '粤A00001', handover_date: null, contract_type: null, customer_name: null },
|
||||
{ truck_id: 2, plate_number: 'VIN-2', handover_date: null, contract_type: null, customer_name: null },
|
||||
];
|
||||
const view = deriveModalVehicleView(
|
||||
vehicles,
|
||||
weeklyDetails,
|
||||
{ ...emptyModalFilters, plateNumber: 'VIN-2' },
|
||||
);
|
||||
|
||||
assert.deepEqual(view.plates, ['粤A00001', 'VIN-2']);
|
||||
assert.deepEqual(view.filteredVehicles.map((item) => item.id), [2]);
|
||||
assert.deepEqual(view.filteredWeeklyDetails.map((item) => item.truck_id), [2]);
|
||||
});
|
||||
|
||||
test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', () => {
|
||||
const flowStats: FlowStatsResponse = {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
daily: [],
|
||||
totals: { delivered: 1, returned: 1, replaced: 0, total: 2 },
|
||||
details: [
|
||||
{
|
||||
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
|
||||
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||
},
|
||||
{
|
||||
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
|
||||
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map((item) => item.id),
|
||||
['1'],
|
||||
);
|
||||
|
||||
const customers = [
|
||||
customer({ region: '广东', total: 2 }),
|
||||
customer({ customer: '客户乙', region: '浙江', total: 5 }),
|
||||
customer({ customer: '客户丙', region: '广东', total: 3 }),
|
||||
];
|
||||
assert.deepEqual(buildCustomerPieData(customers, 'region', []), [
|
||||
{ name: '广东', value: 5 },
|
||||
{ name: '浙江', value: 5 },
|
||||
]);
|
||||
const provinceData = [{ name: '广东省', value: 10 }];
|
||||
assert.equal(buildCustomerPieData(customers, 'province', provinceData), provinceData);
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import type { FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||
import type {
|
||||
CustomerStats,
|
||||
DeptGroup,
|
||||
RegionalInventoryStats,
|
||||
VehicleListItem,
|
||||
} from './types';
|
||||
|
||||
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
|
||||
const CHINESE_NUMBER_ORDER: Record<string, number> = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
十: 10,
|
||||
};
|
||||
|
||||
export interface DateRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface InventoryFilters {
|
||||
region: string;
|
||||
city: string;
|
||||
brand: string;
|
||||
type: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
customer: string[];
|
||||
brand: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface ModalVehicleFilters {
|
||||
plateNumber: string;
|
||||
model: string;
|
||||
brand: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export type VehicleModalCategory =
|
||||
| 'Inventory'
|
||||
| 'Pending'
|
||||
| 'Delivered'
|
||||
| 'Returned'
|
||||
| 'Replaced'
|
||||
| 'Operating';
|
||||
|
||||
export interface VehicleModalSelection {
|
||||
batch: string;
|
||||
model: string;
|
||||
location: string;
|
||||
category?: VehicleModalCategory;
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
department?: string;
|
||||
attendance?: 'active' | 'idle';
|
||||
isColdChain?: boolean;
|
||||
isTrailer?: boolean;
|
||||
type?: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface VehicleListRequestParams {
|
||||
batch?: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
category?: 'Inventory' | 'Operating' | 'Pending';
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
isColdChain?: string;
|
||||
isTrailer?: string;
|
||||
department?: string;
|
||||
attendance?: string;
|
||||
subject?: string | null;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type VehicleModalRequest =
|
||||
| {
|
||||
kind: 'weekly';
|
||||
type: FlowType;
|
||||
filters: { model: string; batch: string; location: string; source?: string };
|
||||
}
|
||||
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
||||
|
||||
export interface FlowSelection {
|
||||
date: string;
|
||||
type: FlowType;
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function formatLocalDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
// 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。
|
||||
export function getWeeklyFlowRange(referenceDate = new Date()): DateRange {
|
||||
const day = referenceDate.getDay();
|
||||
const end = day === 6
|
||||
? addDays(referenceDate, -1)
|
||||
: day === 0
|
||||
? addDays(referenceDate, -2)
|
||||
: addDays(referenceDate, 5 - day);
|
||||
return {
|
||||
start: formatLocalDate(addDays(end, -6)),
|
||||
end: formatLocalDate(end),
|
||||
};
|
||||
}
|
||||
|
||||
const WEEKLY_FLOW_TYPE_BY_CATEGORY: Partial<Record<VehicleModalCategory, FlowType>> = {
|
||||
Delivered: 'delivered',
|
||||
Returned: 'returned',
|
||||
Replaced: 'replaced',
|
||||
};
|
||||
|
||||
// Pending 不是周流转事件,必须继续走车辆列表接口以保留型号、批次和区域筛选。
|
||||
export function buildVehicleModalRequest(
|
||||
selection: VehicleModalSelection,
|
||||
subject: string | null,
|
||||
): VehicleModalRequest {
|
||||
const weeklyType = selection.category
|
||||
? WEEKLY_FLOW_TYPE_BY_CATEGORY[selection.category]
|
||||
: undefined;
|
||||
if (weeklyType) {
|
||||
return {
|
||||
kind: 'weekly',
|
||||
type: weeklyType,
|
||||
filters: {
|
||||
model: selection.model,
|
||||
batch: selection.batch,
|
||||
location: selection.location,
|
||||
source: selection.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const params: VehicleListRequestParams = {};
|
||||
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
|
||||
if (selection.batch !== 'All') params.batch = selection.batch;
|
||||
if (selection.model !== 'All') params.model = selection.model;
|
||||
if (selection.location !== 'All') params.location = selection.location;
|
||||
if (selection.source) params.source = selection.source;
|
||||
if (selection.category === 'Inventory') params.category = 'Inventory';
|
||||
if (selection.category === 'Operating') params.category = 'Operating';
|
||||
if (selection.category === 'Pending') params.category = 'Pending';
|
||||
if (selection.manager) params.manager = selection.manager;
|
||||
if (selection.customer) params.customer = selection.customer;
|
||||
if (selection.department) params.department = selection.department;
|
||||
if (selection.attendance) params.attendance = selection.attendance;
|
||||
|
||||
if (!selection.type) {
|
||||
if (selection.isColdChain !== undefined) params.isColdChain = String(selection.isColdChain);
|
||||
if (selection.isTrailer !== undefined) params.isTrailer = String(selection.isTrailer);
|
||||
}
|
||||
|
||||
// 页面车型分组与列表接口的 vehicleType 取值并不完全一致,这里集中保留既有映射。
|
||||
if (selection.type === '4.5T') {
|
||||
if (selection.isColdChain === true) params.vehicleType = '4.5T冷链';
|
||||
if (selection.isColdChain === false) params.vehicleType = '4.5T普货';
|
||||
} else if (
|
||||
selection.type === '4.5T普货'
|
||||
|| selection.type === '4.5T冷链'
|
||||
|| selection.type === '18T'
|
||||
|| selection.type === '49T'
|
||||
|| selection.type === '挂车'
|
||||
|| selection.type === '其他'
|
||||
) {
|
||||
params.vehicleType = selection.type;
|
||||
} else if (selection.type === '其他车型') {
|
||||
if (selection.isTrailer === true) params.isTrailer = 'true';
|
||||
if (selection.isTrailer === false) params.vehicleType = '其他';
|
||||
}
|
||||
|
||||
return { kind: 'vehicles', params: { ...params, subject } };
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
function getDepartmentOrder(name: string): number {
|
||||
const match = name.match(/[一二三四五六七八九十]/);
|
||||
return match ? (CHINESE_NUMBER_ORDER[match[0]] || 99) : 99;
|
||||
}
|
||||
|
||||
export function filterInventoryStats(
|
||||
inventory: RegionalInventoryStats[],
|
||||
filters: InventoryFilters,
|
||||
): RegionalInventoryStats[] {
|
||||
return inventory.filter((item) => (
|
||||
(!filters.region || item.region === filters.region)
|
||||
&& (!filters.city || item.city === filters.city)
|
||||
&& (!filters.brand || item.brand === filters.brand)
|
||||
&& (!filters.type || item.type === filters.type)
|
||||
&& (!filters.model || item.model === filters.model)
|
||||
));
|
||||
}
|
||||
|
||||
export function groupInventoryByRegion(
|
||||
inventory: RegionalInventoryStats[],
|
||||
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const item of inventory) {
|
||||
if (!result[item.region]) result[item.region] = {};
|
||||
if (!result[item.region][item.city]) result[item.region][item.city] = [];
|
||||
result[item.region][item.city].push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function groupInventoryByModel(
|
||||
inventory: RegionalInventoryStats[],
|
||||
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||
const raw: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const item of inventory) {
|
||||
if (!raw[item.type]) raw[item.type] = {};
|
||||
if (!raw[item.type][item.model]) raw[item.type][item.model] = [];
|
||||
raw[item.type][item.model].push(item);
|
||||
}
|
||||
|
||||
// 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。
|
||||
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const type of INVENTORY_TYPE_ORDER) {
|
||||
if (raw[type]) result[type] = raw[type];
|
||||
}
|
||||
for (const type of Object.keys(raw)) {
|
||||
if (!result[type]) result[type] = raw[type];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function deriveInventoryView(
|
||||
inventory: RegionalInventoryStats[],
|
||||
filters: InventoryFilters,
|
||||
modelTypeFilter: string,
|
||||
) {
|
||||
const filtered = filterInventoryStats(inventory, filters);
|
||||
const modelSource = modelTypeFilter
|
||||
? inventory.filter((item) => item.type === modelTypeFilter)
|
||||
: inventory;
|
||||
const types = uniqueNonEmpty(inventory.map((item) => item.type));
|
||||
|
||||
return {
|
||||
filtered,
|
||||
brands: uniqueNonEmpty(inventory.map((item) => item.brand)),
|
||||
regions: Array.from(new Set(inventory.map((item) => item.region))),
|
||||
cities: uniqueNonEmpty(inventory.map((item) => item.city)),
|
||||
types: types.sort(
|
||||
(left, right) => INVENTORY_TYPE_ORDER.indexOf(left) - INVENTORY_TYPE_ORDER.indexOf(right),
|
||||
),
|
||||
modelsForType: uniqueNonEmpty(modelSource.map((item) => item.model)),
|
||||
byRegion: groupInventoryByRegion(filtered),
|
||||
byModel: groupInventoryByModel(filtered),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveDepartmentView(departments: DeptGroup[], selectedManager: string) {
|
||||
const managers = departments
|
||||
.flatMap((department) => department.managers.map((manager) => manager.manager))
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.sort();
|
||||
const groupedManagers = departments.map((department) => ({
|
||||
department: department.department,
|
||||
managers: department.managers.map((manager) => manager.manager),
|
||||
}));
|
||||
const managerStats = departments
|
||||
.flatMap((department) => department.managers)
|
||||
.filter((manager) => selectedManager === 'All' || manager.manager === selectedManager)
|
||||
.sort((left, right) => right.total - left.total);
|
||||
|
||||
return { managers, groupedManagers, managerStats };
|
||||
}
|
||||
|
||||
export function filterCustomerStats(
|
||||
customers: CustomerStats[],
|
||||
filters: CustomerFilters,
|
||||
): CustomerStats[] {
|
||||
return customers.filter((customer) => (
|
||||
(filters.customer.length === 0 || filters.customer.includes(customer.customer))
|
||||
&& (!filters.brand || customer.brand === filters.brand)
|
||||
&& (!filters.department || customer.department === filters.department)
|
||||
&& (!filters.manager || customer.manager === filters.manager)
|
||||
&& (!filters.region || customer.region === filters.region)
|
||||
));
|
||||
}
|
||||
|
||||
function groupCustomerManagers(
|
||||
customers: CustomerStats[],
|
||||
departments: DeptGroup[],
|
||||
): Array<{ department: string; managers: string[] }> {
|
||||
const departmentManagers = new Map<string, Set<string>>();
|
||||
for (const department of departments) {
|
||||
if (!departmentManagers.has(department.department)) {
|
||||
departmentManagers.set(department.department, new Set());
|
||||
}
|
||||
for (const manager of department.managers) {
|
||||
departmentManagers.get(department.department)!.add(manager.manager);
|
||||
}
|
||||
}
|
||||
for (const customer of customers) {
|
||||
if (!customer.manager || !customer.department) continue;
|
||||
if (!departmentManagers.has(customer.department)) {
|
||||
departmentManagers.set(customer.department, new Set());
|
||||
}
|
||||
departmentManagers.get(customer.department)!.add(customer.manager);
|
||||
}
|
||||
|
||||
return Array.from(departmentManagers.entries())
|
||||
.sort((left, right) => {
|
||||
const leftOrder = left[0] === '公务车' ? 100 : getDepartmentOrder(left[0]);
|
||||
const rightOrder = right[0] === '公务车' ? 100 : getDepartmentOrder(right[0]);
|
||||
return leftOrder - rightOrder;
|
||||
})
|
||||
.map(([department, managers]) => ({ department, managers: Array.from(managers) }));
|
||||
}
|
||||
|
||||
export function deriveCustomerView(
|
||||
customers: CustomerStats[],
|
||||
departments: DeptGroup[],
|
||||
filters: CustomerFilters,
|
||||
) {
|
||||
return {
|
||||
filtered: filterCustomerStats(customers, filters),
|
||||
brands: uniqueNonEmpty(customers.map((customer) => customer.brand)),
|
||||
departments: uniqueNonEmpty(customers.map((customer) => customer.department))
|
||||
.sort((left, right) => getDepartmentOrder(left) - getDepartmentOrder(right)),
|
||||
regions: Array.from(new Set(customers.map((customer) => customer.region))),
|
||||
cities: uniqueNonEmpty(customers.map((customer) => customer.city)),
|
||||
customerNames: uniqueNonEmpty(customers.map((customer) => customer.customer)),
|
||||
managersByDepartment: groupCustomerManagers(customers, departments),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveModalVehicleView(
|
||||
vehicles: VehicleListItem[],
|
||||
weeklyDetails: WeeklyDetailItem[],
|
||||
filters: ModalVehicleFilters,
|
||||
) {
|
||||
return {
|
||||
plates: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.plateNumber || vehicle.vin)),
|
||||
models: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.model)),
|
||||
brands: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.brandLabel)),
|
||||
locations: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.location)),
|
||||
filteredVehicles: vehicles.filter((vehicle) => (
|
||||
(!filters.plateNumber || (vehicle.plateNumber || vehicle.vin) === filters.plateNumber)
|
||||
&& (!filters.model || vehicle.model === filters.model)
|
||||
&& (!filters.brand || vehicle.brandLabel === filters.brand)
|
||||
&& (!filters.location || vehicle.location === filters.location)
|
||||
)),
|
||||
filteredWeeklyDetails: weeklyDetails.filter((detail) => (
|
||||
!filters.plateNumber || detail.plate_number === filters.plateNumber
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectFlowDetails(
|
||||
flowStats: FlowStatsResponse | null,
|
||||
selection: FlowSelection | null,
|
||||
) {
|
||||
if (!flowStats || !selection) return [];
|
||||
return flowStats.details.filter((detail) => (
|
||||
detail.date === selection.date && detail.type === selection.type
|
||||
));
|
||||
}
|
||||
|
||||
export function buildCustomerPieData(
|
||||
customers: CustomerStats[],
|
||||
view: 'region' | 'province',
|
||||
provinceData: Array<{ name: string; value: number }>,
|
||||
): Array<{ name: string; value: number }> {
|
||||
if (view === 'province') return provinceData;
|
||||
|
||||
const totals: Record<string, number> = {};
|
||||
for (const customer of customers) {
|
||||
totals[customer.region] = (totals[customer.region] || 0) + customer.total;
|
||||
}
|
||||
return Object.entries(totals)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
}
|
||||
@@ -1,64 +1,28 @@
|
||||
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 { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import FeedbackFab from '../../components/FeedbackFab';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import { BatchTable } from './import-page/BatchTable';
|
||||
import { RecordFilters } from './import-page/RecordFilters';
|
||||
import { RecordTable } from './import-page/RecordTable';
|
||||
import { SummaryKpis } from './import-page/SummaryKpis';
|
||||
import { UploadDropzone } from './import-page/UploadDropzone';
|
||||
import { UploadFeedback } from './import-page/UploadFeedback';
|
||||
import type {
|
||||
BatchRow,
|
||||
ListItem,
|
||||
OverallRow,
|
||||
UploadResult,
|
||||
VehicleKindFilter,
|
||||
} from './import-page/types';
|
||||
|
||||
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);
|
||||
@@ -83,7 +47,7 @@ export default function EleImportPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [filter, setFilter] = useState<VehicleKindFilter>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -110,13 +74,13 @@ export default function EleImportPage() {
|
||||
reload().catch(e => console.error(e));
|
||||
}, [reload]);
|
||||
|
||||
const handleUpload = async (f: File) => {
|
||||
const handleUpload = async (selectedFile: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await uploadFile(f);
|
||||
setResult(r);
|
||||
const nextResult = await uploadFile(selectedFile);
|
||||
setResult(nextResult);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -125,16 +89,11 @@ export default function EleImportPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPick = (f: File | null) => {
|
||||
setFile(f);
|
||||
if (f) handleUpload(f);
|
||||
const onPick = (selectedFile: File | null) => {
|
||||
setFile(selectedFile);
|
||||
if (selectedFile) handleUpload(selectedFile);
|
||||
};
|
||||
|
||||
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 (
|
||||
<PageFrame
|
||||
title="充电记录导入"
|
||||
@@ -144,16 +103,16 @@ export default function EleImportPage() {
|
||||
meta={user?.userName || '导入工作台'}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
|
||||
@@ -164,233 +123,53 @@ export default function EleImportPage() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<UploadDropzone
|
||||
inputRef={inputRef}
|
||||
file={file}
|
||||
uploading={uploading}
|
||||
dragOver={dragOver}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) onPick(droppedFile);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onInputChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{/* 上传区 */}
|
||||
<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>
|
||||
<UploadFeedback
|
||||
result={result}
|
||||
error={error}
|
||||
onCloseResult={() => setResult(null)}
|
||||
onCloseError={() => setError(null)}
|
||||
/>
|
||||
|
||||
{/* 上传结果提示 */}
|
||||
<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>
|
||||
<SummaryKpis overall={overall} />
|
||||
|
||||
{/* 聚合卡 */}
|
||||
<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>
|
||||
<BatchTable
|
||||
batches={batches}
|
||||
batchFilter={batchFilter}
|
||||
onClearFilter={() => setBatchFilter('')}
|
||||
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
|
||||
/>
|
||||
|
||||
{/* 批次 */}
|
||||
{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">
|
||||
<RecordFilters
|
||||
total={total}
|
||||
searchInput={searchInput}
|
||||
filter={filter}
|
||||
onSearchInputChange={(e) => setSearchInput(e.target.value)}
|
||||
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
<RecordTable items={items} />
|
||||
</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" />
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
<FeedbackFab module="ele" />
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { formatCount, formatDateTime, formatDecimal } from './model';
|
||||
import type { BatchRow } from './types';
|
||||
|
||||
interface BatchTableProps {
|
||||
batches: BatchRow[];
|
||||
batchFilter: string;
|
||||
onClearFilter: () => void;
|
||||
onSelectBatch: (batchId: string) => void;
|
||||
}
|
||||
|
||||
export function BatchTable({ batches, batchFilter, onClearFilter, onSelectBatch }: BatchTableProps) {
|
||||
if (batches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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={onClearFilter} 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(batch => (
|
||||
<tr
|
||||
key={batch.batch_id}
|
||||
onClick={() => onSelectBatch(batch.batch_id)}
|
||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === batch.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{formatDateTime(batch.imported_at, 19)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{formatCount(batch.records)}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{formatCount(batch.internal_count)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{formatCount(batch.external_count)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{formatDecimal(batch.total_kwh, 1)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{formatDecimal(batch.total_fee, 2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{batch.batch_id.slice(0, 12)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import type { VehicleKindFilter } from './types';
|
||||
|
||||
interface RecordFiltersProps {
|
||||
total: number;
|
||||
searchInput: string;
|
||||
filter: VehicleKindFilter;
|
||||
onSearchInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
onFilterChange: (filter: VehicleKindFilter) => void;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: ReadonlyArray<readonly [VehicleKindFilter, string]> = [
|
||||
['', '全部'],
|
||||
['internal', '内部'],
|
||||
['external', '外部'],
|
||||
];
|
||||
|
||||
export function RecordFilters({
|
||||
total,
|
||||
searchInput,
|
||||
filter,
|
||||
onSearchInputChange,
|
||||
onSearchKeyDown,
|
||||
onFilterChange,
|
||||
}: RecordFiltersProps) {
|
||||
return (
|
||||
<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={onSearchInputChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
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">
|
||||
{FILTER_OPTIONS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onFilterChange(key)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === key ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FileSpreadsheet } from 'lucide-react';
|
||||
import { formatDateTime, formatFixed, KIND_LABEL, KIND_STYLE } from './model';
|
||||
import type { ListItem } from './types';
|
||||
|
||||
interface RecordTableProps {
|
||||
items: ListItem[];
|
||||
}
|
||||
|
||||
export function RecordTable({ items }: RecordTableProps) {
|
||||
return (
|
||||
<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(item => (
|
||||
<tr key={item.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">{formatDateTime(item.start_time, 16)}</td>
|
||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{item.plate || item.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[item.vehicle_kind]}`}>
|
||||
{KIND_LABEL[item.vehicle_kind]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{item.station_name || '—'}{item.terminal_name ? ` · ${item.terminal_name}` : ''}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.kwh, 2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.fee, 2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{item.duration_min ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{item.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ExternalLink, Layers, Truck, Zap } from 'lucide-react';
|
||||
import { summarizeOverall } from './model';
|
||||
import type { OverallRow } from './types';
|
||||
|
||||
interface SummaryKpisProps {
|
||||
overall: OverallRow[];
|
||||
}
|
||||
|
||||
export function SummaryKpis({ overall }: SummaryKpisProps) {
|
||||
const summary = summarizeOverall(overall);
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={summary.totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={summary.internalRecords.toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={summary.externalRecords.toLocaleString()} accent="amber" />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${summary.totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${summary.internalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${summary.externalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
ChangeEventHandler,
|
||||
DragEventHandler,
|
||||
MouseEventHandler,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import { RotateCcw, Upload } from 'lucide-react';
|
||||
|
||||
interface UploadDropzoneProps {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
file: File | null;
|
||||
uploading: boolean;
|
||||
dragOver: boolean;
|
||||
onClick: MouseEventHandler<HTMLElement>;
|
||||
onDragOver: DragEventHandler<HTMLElement>;
|
||||
onDragLeave: DragEventHandler<HTMLElement>;
|
||||
onDrop: DragEventHandler<HTMLElement>;
|
||||
onInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function UploadDropzone({
|
||||
inputRef,
|
||||
file,
|
||||
uploading,
|
||||
dragOver,
|
||||
onClick,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onInputChange,
|
||||
}: UploadDropzoneProps) {
|
||||
return (
|
||||
<section
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onClick}
|
||||
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={onInputChange}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import type { UploadResult } from './types';
|
||||
|
||||
interface UploadFeedbackProps {
|
||||
result: UploadResult | null;
|
||||
error: string | null;
|
||||
onCloseResult: () => void;
|
||||
onCloseError: () => void;
|
||||
}
|
||||
|
||||
export function UploadFeedback({ result, error, onCloseResult, onCloseError }: UploadFeedbackProps) {
|
||||
return (
|
||||
<>
|
||||
<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={onCloseResult} 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={onCloseError} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
formatCount,
|
||||
formatDateTime,
|
||||
formatDecimal,
|
||||
formatFixed,
|
||||
summarizeOverall,
|
||||
} from './model';
|
||||
|
||||
test('summarizeOverall preserves aggregate totals and per-kind values', () => {
|
||||
assert.deepEqual(summarizeOverall([
|
||||
{ vehicle_kind: 'internal', records: 3, total_kwh: 12.34, total_fee: 20.5 },
|
||||
{ vehicle_kind: 'external', records: 2, total_kwh: 5.66, total_fee: 9.5 },
|
||||
]), {
|
||||
totalRecords: 5,
|
||||
totalKwh: 18,
|
||||
totalFee: 30,
|
||||
internalRecords: 3,
|
||||
externalRecords: 2,
|
||||
internalKwh: 12.34,
|
||||
externalKwh: 5.66,
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeOverall preserves empty fallbacks', () => {
|
||||
assert.deepEqual(summarizeOverall([]), {
|
||||
totalRecords: 0,
|
||||
totalKwh: 0,
|
||||
totalFee: 0,
|
||||
internalRecords: 0,
|
||||
externalRecords: 0,
|
||||
internalKwh: 0,
|
||||
externalKwh: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('formatters preserve the import page display rules', () => {
|
||||
assert.equal(formatDateTime('2026-08-13T09:08:07.000Z', 19), '2026-08-13 09:08:07');
|
||||
assert.equal(formatDateTime(null, 16), '');
|
||||
assert.equal(formatCount(1234), (1234).toLocaleString());
|
||||
assert.equal(formatDecimal(1234.56, 1), (1234.56).toLocaleString('zh-CN', { maximumFractionDigits: 1 }));
|
||||
assert.equal(formatFixed(null, 2), '0.00');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { OverallRow } from './types';
|
||||
|
||||
export const KIND_LABEL: Record<string, string> = {
|
||||
internal: '内部',
|
||||
external: '外部',
|
||||
};
|
||||
|
||||
export 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',
|
||||
};
|
||||
|
||||
export function summarizeOverall(rows: OverallRow[]) {
|
||||
const byKind = new Map(rows.map(row => [row.vehicle_kind, row]));
|
||||
|
||||
return {
|
||||
totalRecords: rows.reduce((sum, row) => sum + Number(row.records || 0), 0),
|
||||
totalKwh: rows.reduce((sum, row) => sum + Number(row.total_kwh || 0), 0),
|
||||
totalFee: rows.reduce((sum, row) => sum + Number(row.total_fee || 0), 0),
|
||||
internalRecords: byKind.get('internal')?.records ?? 0,
|
||||
externalRecords: byKind.get('external')?.records ?? 0,
|
||||
internalKwh: byKind.get('internal')?.total_kwh ?? 0,
|
||||
externalKwh: byKind.get('external')?.total_kwh ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined, length: number) {
|
||||
return (value || '').replace('T', ' ').slice(0, length);
|
||||
}
|
||||
|
||||
export function formatCount(value: number) {
|
||||
return Number(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDecimal(value: number | null | undefined, maximumFractionDigits: number) {
|
||||
return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits });
|
||||
}
|
||||
|
||||
export function formatFixed(value: number | null | undefined, fractionDigits: number) {
|
||||
return Number(value ?? 0).toFixed(fractionDigits);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface UploadResult {
|
||||
ok: boolean;
|
||||
filename: string;
|
||||
batchId: string;
|
||||
parsed: number;
|
||||
fileDuplicates: number;
|
||||
inserted: number;
|
||||
dbDuplicates: number;
|
||||
breakdown: { internal: number; external: number };
|
||||
}
|
||||
|
||||
export type VehicleKind = 'internal' | 'external' | 'unknown';
|
||||
export type VehicleKindFilter = '' | 'internal' | 'external';
|
||||
|
||||
export 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: VehicleKind;
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
}
|
||||
|
||||
export interface OverallRow {
|
||||
vehicle_kind: 'internal' | 'external';
|
||||
records: number;
|
||||
total_kwh: number;
|
||||
total_fee: number;
|
||||
}
|
||||
|
||||
export interface BatchRow {
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
records: number;
|
||||
internal_count: number;
|
||||
external_count: number;
|
||||
total_kwh: number;
|
||||
total_fee: number;
|
||||
}
|
||||
@@ -1,46 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
type RangeMode = DateQuickPick | 'custom';
|
||||
|
||||
function fmtYmd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const next = new Date(d);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||||
}
|
||||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||||
}
|
||||
|
||||
function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
}
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
type RangeMode,
|
||||
} from './daily-range/model';
|
||||
import { summarizeElectricMonths } from './electric-daily/model';
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
@@ -75,17 +48,17 @@ export default function ElectricDaily() {
|
||||
return next;
|
||||
});
|
||||
|
||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||||
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
|
||||
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
|
||||
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
|
||||
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
|
||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
||||
const scopeLabel = pick === 'custom'
|
||||
? '自定义区间'
|
||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
||||
const {
|
||||
totalKwh,
|
||||
totalFee,
|
||||
activeDays,
|
||||
abnormalDays,
|
||||
avgKwh,
|
||||
avgPrice,
|
||||
hasFeeDetail,
|
||||
} = useMemo(() => summarizeElectricMonths(months), [months]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const hasFeeDetail = totalFee > 0;
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
@@ -101,71 +74,15 @@ export default function ElectricDaily() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPick('custom')}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={e => updateDateRange('start', e.target.value)}
|
||||
onInput={e => updateDateRange('start', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={e => updateDateRange('end', e.target.value)}
|
||||
onInput={e => updateDateRange('end', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||
|
||||
@@ -5,44 +5,17 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
type RangeMode,
|
||||
} from './hydrogen-daily/model';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
type RangeMode = DateQuickPick | 'custom';
|
||||
|
||||
function fmtYmd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const next = new Date(d);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||||
}
|
||||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||||
}
|
||||
|
||||
function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
}
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
@@ -65,25 +38,18 @@ export default function HydrogenDaily() {
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
||||
const stationCount = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
|
||||
return names.size;
|
||||
}, [rows]);
|
||||
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
||||
const scopeLabel = pick === 'custom'
|
||||
? '自定义区间'
|
||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
||||
const {
|
||||
trendData,
|
||||
totalKg,
|
||||
activeDays,
|
||||
stationCount,
|
||||
avgKg,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays,
|
||||
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
|
||||
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
|
||||
|
||||
const toggle = (date: string) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -104,71 +70,15 @@ export default function HydrogenDaily() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPick('custom')}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={e => updateDateRange('start', e.target.value)}
|
||||
onInput={e => updateDateRange('start', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={e => updateDateRange('end', e.target.value)}
|
||||
onInput={e => updateDateRange('end', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
|
||||
@@ -1,89 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} from 'recharts';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
|
||||
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
|
||||
import { InsightCards } from './hydrogen-overview/components/InsightCards';
|
||||
import { KpiSection } from './hydrogen-overview/components/KpiSection';
|
||||
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
|
||||
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
|
||||
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
|
||||
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
|
||||
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 数字格式化 ----------
|
||||
function fmtKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||||
const abs = Math.abs(yuan);
|
||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
if (abs >= 10_000) {
|
||||
const w = yuan / 10_000;
|
||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
||||
}
|
||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
||||
}
|
||||
|
||||
// ---------- KPI 卡 ----------
|
||||
interface KpiCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export default function HydrogenOverview() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -124,591 +51,56 @@ export default function HydrogenOverview() {
|
||||
if (!data) {
|
||||
return <HydrogenOverviewSkeleton />;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const top5 = data.top5;
|
||||
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 monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
|
||||
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
|
||||
const latestMonth = monthly[monthly.length - 1];
|
||||
const prevMonth = monthly[monthly.length - 2];
|
||||
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
|
||||
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
|
||||
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
|
||||
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
|
||||
|
||||
// 月度收支组合数据(推算"年内每月"图)
|
||||
const monthlyDual = monthly.map(m => ({
|
||||
...m,
|
||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||||
}));
|
||||
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
|
||||
const {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
stationAvgKg,
|
||||
monthlyDual,
|
||||
} = deriveOverviewMetrics(data);
|
||||
const yearProfitFmt = fmtYuan(kpi.yearProfit);
|
||||
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load(year, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewHeader
|
||||
activeYear={activeYear}
|
||||
availableYears={availableYears}
|
||||
lastRefreshAt={lastRefreshAt}
|
||||
refreshing={refreshing}
|
||||
onSelectYear={setYear}
|
||||
onRefresh={() => void load(year, true)}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<KpiSection kpi={kpi} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stations.length} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
时享获利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InsightCards
|
||||
monthAvgKg={monthAvgKg}
|
||||
bestMonth={bestMonth}
|
||||
latestMonth={latestMonth}
|
||||
monthMomentum={monthMomentum}
|
||||
top5Share={top5Share}
|
||||
profitYield={profitYield}
|
||||
stationAvgKg={stationAvgKg}
|
||||
stationCount={stations.length}
|
||||
yearProfitValue={yearProfitFmt.value}
|
||||
yearProfitUnit={yearProfitFmt.unit}
|
||||
yearRevenueValue={yearRevenueFmt.value}
|
||||
yearRevenueUnit={yearRevenueFmt.unit}
|
||||
/>
|
||||
|
||||
{/* 月度趋势:年内每月加氢量 */}
|
||||
{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">
|
||||
{/* Top5 加氢站 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 区域占比 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加氢站加氢汇总(全量) */}
|
||||
{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>
|
||||
)}
|
||||
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
|
||||
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
|
||||
<StationSummaryTable stations={stations} />
|
||||
<CustomerSummaryTable customers={customers} />
|
||||
|
||||
<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 formatRefreshTime(ts: number): string {
|
||||
const exactTime = new Date(ts).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
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>
|
||||
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Truck } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../../components/ui/surface';
|
||||
import type { CustomerType, DateQuickPick } from '../types';
|
||||
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
|
||||
|
||||
interface DailyRangeControlsProps {
|
||||
pick: RangeMode;
|
||||
dateRange: { start: string; end: string };
|
||||
customer: CustomerType;
|
||||
onQuickPick: (pick: DateQuickPick) => void;
|
||||
onCustomPick: () => void;
|
||||
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
|
||||
onCustomerChange: (customer: CustomerType) => void;
|
||||
}
|
||||
|
||||
export default function DailyRangeControls({
|
||||
pick,
|
||||
dateRange,
|
||||
customer,
|
||||
onQuickPick,
|
||||
onCustomPick,
|
||||
onDateRangeChange,
|
||||
onCustomerChange,
|
||||
}: DailyRangeControlsProps) {
|
||||
return (
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => onQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onCustomPick}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={event => onDateRangeChange('start', event.target.value)}
|
||||
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={event => onDateRangeChange('end', event.target.value)}
|
||||
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(option => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => onCustomerChange(option)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DateQuickPick } from '../types';
|
||||
|
||||
export const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
export type RangeMode = DateQuickPick | 'custom';
|
||||
|
||||
export function formatYmd(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Energy quick ranges follow the browser's local calendar day, not UTC. */
|
||||
export function getQuickRange(
|
||||
pick: DateQuickPick,
|
||||
now: Date = new Date(),
|
||||
): { start: string; end: string } {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: formatYmd(addDays(today, -(day - 1))), end: formatYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return {
|
||||
start: formatYmd(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||
end: formatYmd(today),
|
||||
};
|
||||
}
|
||||
return { start: formatYmd(addDays(today, -14)), end: formatYmd(today) };
|
||||
}
|
||||
|
||||
export function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
}
|
||||
|
||||
export function getRangeModeLabel(mode: RangeMode): string {
|
||||
if (mode === 'custom') return '自定义区间';
|
||||
return QUICK_PICK_OPTIONS.find(item => item.id === mode)?.label ?? '当前时段';
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ElectricMonthGroup } from '../types';
|
||||
import { summarizeElectricMonths } from './model';
|
||||
|
||||
test('electric daily summary preserves totals, active-day and 30 percent boundaries', () => {
|
||||
const months: ElectricMonthGroup[] = [
|
||||
{
|
||||
month: '2026-08',
|
||||
kwh: 150,
|
||||
fee: 75,
|
||||
rows: [
|
||||
{ date: '2026-08-01', kwh: 100, fee: 50, chainPct: 0.3 },
|
||||
{ date: '2026-08-02', kwh: 50, fee: 25, chainPct: -0.29 },
|
||||
{ date: '2026-08-03', kwh: 0, fee: 0, chainPct: -1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(summarizeElectricMonths(months), {
|
||||
totalKwh: 150,
|
||||
totalFee: 75,
|
||||
activeDays: 2,
|
||||
abnormalDays: 2,
|
||||
avgKwh: 75,
|
||||
avgPrice: 0.5,
|
||||
hasFeeDetail: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('electric daily summary preserves empty and missing-fee fallbacks', () => {
|
||||
assert.deepEqual(summarizeElectricMonths(null), {
|
||||
totalKwh: 0,
|
||||
totalFee: 0,
|
||||
activeDays: 0,
|
||||
abnormalDays: 0,
|
||||
avgKwh: 0,
|
||||
avgPrice: 0,
|
||||
hasFeeDetail: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ElectricMonthGroup } from '../types';
|
||||
|
||||
export function summarizeElectricMonths(months: ElectricMonthGroup[] | null) {
|
||||
const source = months ?? [];
|
||||
const totalKwh = source.reduce((sum, month) => sum + (month.kwh || 0), 0);
|
||||
const totalFee = source.reduce((sum, month) => sum + (month.fee || 0), 0);
|
||||
const activeDays = source.reduce(
|
||||
(sum, month) => sum + month.rows.filter(row => row.kwh > 0).length,
|
||||
0,
|
||||
);
|
||||
const abnormalDays = source.reduce(
|
||||
(sum, month) => sum + month.rows.filter(row => Math.abs(row.chainPct) >= 0.3).length,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalKwh,
|
||||
totalFee,
|
||||
activeDays,
|
||||
abnormalDays,
|
||||
avgKwh: activeDays > 0 ? totalKwh / activeDays : 0,
|
||||
avgPrice: totalKwh > 0 ? totalFee / totalKwh : 0,
|
||||
hasFeeDetail: totalFee > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenDailyRow } from '../types.js';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
} from './model.js';
|
||||
|
||||
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
|
||||
const now = new Date(2026, 7, 12, 23, 30);
|
||||
assert.deepEqual(getQuickRange('thisWeek', now), {
|
||||
start: '2026-08-10',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('thisMonth', now), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('last15', now), {
|
||||
start: '2026-07-29',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
});
|
||||
|
||||
test('自定义日期倒置时仅交换查询边界', () => {
|
||||
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.equal(getRangeModeLabel('custom'), '自定义区间');
|
||||
assert.equal(getRangeModeLabel('last15'), '近 15 天');
|
||||
});
|
||||
|
||||
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
|
||||
const rows: HydrogenDailyRow[] = [
|
||||
{
|
||||
date: '2026-08-12',
|
||||
totalKg: 0,
|
||||
chainPct: -1,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-10',
|
||||
totalKg: 100,
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-11',
|
||||
totalKg: 300,
|
||||
chainPct: 2,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const summary = summarizeHydrogenRows(rows);
|
||||
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
|
||||
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
|
||||
assert.equal(summary.totalKg, 400);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.avgKg, 200);
|
||||
assert.equal(summary.stationCount, 2);
|
||||
assert.equal(summary.peakDay?.date, '2026-08-11');
|
||||
assert.equal(summary.lowDay?.date, '2026-08-10');
|
||||
assert.equal(summary.zeroDays, 1);
|
||||
});
|
||||
|
||||
test('空数据保持零值且没有峰谷日', () => {
|
||||
assert.deepEqual(summarizeHydrogenRows(null), {
|
||||
trendData: [],
|
||||
totalKg: 0,
|
||||
activeDays: 0,
|
||||
stationCount: 0,
|
||||
avgKg: 0,
|
||||
peakDay: null,
|
||||
lowDay: null,
|
||||
zeroDays: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { HydrogenDailyRow } from '../types';
|
||||
|
||||
export {
|
||||
formatYmd,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
QUICK_PICK_OPTIONS,
|
||||
type RangeMode,
|
||||
} from '../daily-range/model';
|
||||
|
||||
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
const source = rows ?? [];
|
||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
|
||||
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
|
||||
const activeDays = source.filter(row => row.totalKg > 0).length;
|
||||
const stationNames = new Set<string>();
|
||||
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
|
||||
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
|
||||
null,
|
||||
);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>(
|
||||
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
trendData,
|
||||
totalKg,
|
||||
activeDays,
|
||||
stationCount: stationNames.size,
|
||||
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays: source.filter(row => row.totalKg === 0).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface DistributionChartsProps {
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
yearKg: number;
|
||||
}
|
||||
|
||||
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
{/* 5 卡占位 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 月度柱图占位 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-[120px]">
|
||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
正在加载氢能总览…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatKg as fmtKg } from '../model';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
bestMonth: HydrogenMonthlyPoint | null;
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
profitYield: number;
|
||||
stationAvgKg: number;
|
||||
stationCount: number;
|
||||
yearProfitValue: string;
|
||||
yearProfitUnit: string;
|
||||
yearRevenueValue: string;
|
||||
yearRevenueUnit: string;
|
||||
}
|
||||
|
||||
export function InsightCards({
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
stationAvgKg,
|
||||
stationCount,
|
||||
yearProfitValue,
|
||||
yearProfitUnit,
|
||||
yearRevenueValue,
|
||||
yearRevenueUnit,
|
||||
}: InsightCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
|
||||
import type { HydrogenKpi } from '../../types';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
|
||||
interface KpiCardProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||
const monthKgFmt = fmtKg(k.monthKg);
|
||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||
const todayKgFmt = fmtKg(k.todayKg);
|
||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-cyan-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢量"
|
||||
hero={yearKgFmt}
|
||||
rows={[
|
||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-blue-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢费"
|
||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||
rows={[
|
||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-emerald-50"
|
||||
accentClass={profitColor}
|
||||
label="时享加氢获利"
|
||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||
rows={[
|
||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-amber-50"
|
||||
accentClass="text-amber-600"
|
||||
label="本月加氢"
|
||||
hero={monthKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-violet-50"
|
||||
accentClass="text-violet-600"
|
||||
label="本日加氢"
|
||||
hero={todayKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatYuan as fmtYuan } from '../model';
|
||||
|
||||
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||
|
||||
interface MonthlyChartsProps {
|
||||
activeYear: number;
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
monthlyDual: MonthlyChartPoint[];
|
||||
}
|
||||
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
|
||||
return (
|
||||
<>
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||
{monthlyDual.map((_, i) => (
|
||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { formatRefreshTime } from '../model';
|
||||
|
||||
interface OverviewHeaderProps {
|
||||
activeYear: number;
|
||||
availableYears: number[];
|
||||
lastRefreshAt: number;
|
||||
refreshing: boolean;
|
||||
onSelectYear: (year: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function OverviewHeader({
|
||||
activeYear,
|
||||
availableYears,
|
||||
lastRefreshAt,
|
||||
refreshing,
|
||||
onSelectYear,
|
||||
onRefresh,
|
||||
}: OverviewHeaderProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => onSelectYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
interface RefreshOverlayProps {
|
||||
refreshing: boolean;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{refreshing && hasData && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
|
||||
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
|
||||
return (
|
||||
<>
|
||||
{stations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">加氢站</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
|
||||
return (
|
||||
<>
|
||||
{customers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">客户</th>
|
||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c2, i) => {
|
||||
const kgFmt = fmtKg(c2.kg);
|
||||
const costFmt = fmtYuan(c2.cost);
|
||||
const revFmt = fmtYuan(c2.revenue);
|
||||
return (
|
||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenOverviewResponse } from '../api.js';
|
||||
import {
|
||||
deriveOverviewMetrics,
|
||||
formatKg,
|
||||
formatRelative,
|
||||
formatYuan,
|
||||
} from './model.js';
|
||||
|
||||
test('重量和金额单位保持现有阈值及负数格式', () => {
|
||||
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
|
||||
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
|
||||
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
|
||||
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
|
||||
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
|
||||
});
|
||||
|
||||
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
|
||||
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
|
||||
assert.equal(formatRelative(now + 60_000, now), '刚刚');
|
||||
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
|
||||
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
|
||||
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
|
||||
});
|
||||
|
||||
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
|
||||
const data = {
|
||||
kpi: {
|
||||
yearKg: 1000,
|
||||
yearFee: 0,
|
||||
yearProfit: -50,
|
||||
ourYearKg: 0,
|
||||
customerYearKg: 0,
|
||||
ourYearFee: 0,
|
||||
yearRevenue: 500,
|
||||
monthKg: 0,
|
||||
monthFee: 0,
|
||||
monthRevenue: 0,
|
||||
monthProfit: 0,
|
||||
todayKg: 0,
|
||||
todayFee: 0,
|
||||
todayRevenue: 0,
|
||||
todayProfit: 0,
|
||||
lingniuBornKg: 0,
|
||||
lingniuBornFee: 0,
|
||||
},
|
||||
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
|
||||
regions: [],
|
||||
monthly: [
|
||||
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
|
||||
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
|
||||
],
|
||||
customers: [],
|
||||
stations: [
|
||||
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
|
||||
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
|
||||
],
|
||||
availableYears: [2026],
|
||||
year: 2026,
|
||||
} satisfies HydrogenOverviewResponse;
|
||||
|
||||
const metrics = deriveOverviewMetrics(data);
|
||||
assert.equal(metrics.monthAvgKg, 125);
|
||||
assert.equal(metrics.bestMonth?.month, '2026-02');
|
||||
assert.equal(metrics.latestMonth?.month, '2026-02');
|
||||
assert.equal(metrics.monthMomentum, 50);
|
||||
assert.equal(metrics.top5Share, 60);
|
||||
assert.equal(metrics.profitYield, -10);
|
||||
assert.equal(metrics.stationAvgKg, 500);
|
||||
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { HydrogenOverviewResponse } from '../api';
|
||||
|
||||
export function formatKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
|
||||
export function formatYuan(yuan: number): { value: string; unit: string } {
|
||||
const absolute = Math.abs(yuan);
|
||||
if (absolute >= 100_000_000) {
|
||||
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
}
|
||||
if (absolute >= 10_000) {
|
||||
return {
|
||||
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
|
||||
unit: '万元',
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||
unit: '元',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRelative(timestamp: number, now = Date.now()): string {
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 5) return '刚刚';
|
||||
if (seconds < 60) return `${seconds} 秒前`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
|
||||
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
|
||||
const { kpi, monthly, stations, top5 } = data;
|
||||
const monthAvgKg = monthly.length > 0
|
||||
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
|
||||
: 0;
|
||||
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
|
||||
(best, item) => (!best || item.kg > best.kg ? item : best),
|
||||
null,
|
||||
);
|
||||
const latestMonth = monthly[monthly.length - 1];
|
||||
const previousMonth = monthly[monthly.length - 2];
|
||||
|
||||
return {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
|
||||
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
|
||||
: null,
|
||||
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
|
||||
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
|
||||
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
|
||||
monthlyDual: monthly.map(month => ({
|
||||
...month,
|
||||
monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+217
-1333
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, LineChart, Line, AreaChart, Area,
|
||||
Cell, LabelList,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Truck, ChevronDown, Maximize2, Minimize2,
|
||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||
} from 'lucide-react';
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
function getDefaultDate(): string {
|
||||
const now = new Date();
|
||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getCurrentDateLabel(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
||||
}
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
|
||||
if (target.yearlyAssessments.length === 0) return null;
|
||||
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
||||
}
|
||||
|
||||
function fmtDateLabel(date: string | null): string {
|
||||
if (!date) return '';
|
||||
const [year, month, day] = date.split('-');
|
||||
return `${year}.${Number(month)}.${Number(day)}`;
|
||||
}
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
// Extract the number and a short description
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
const count = match[1];
|
||||
let desc = match[2];
|
||||
// Simplify common patterns
|
||||
desc = desc.replace('4.5T普货', '普货');
|
||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||||
desc = desc.replace('18T', '18T');
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
import AllVehiclesPanel from './statistics/AllVehiclesPanel';
|
||||
import FullscreenTargetTable from './statistics/FullscreenTargetTable';
|
||||
import StatisticsOverview from './statistics/StatisticsOverview';
|
||||
import TargetDetailPanel from './statistics/TargetDetailPanel';
|
||||
import TrendPanel from './statistics/TrendPanel';
|
||||
import {
|
||||
deriveSelectedTargetMetrics,
|
||||
getCurrentDateLabel,
|
||||
getDefaultDate,
|
||||
initialAssessmentYears,
|
||||
orderTargets,
|
||||
} from './statistics/model';
|
||||
|
||||
export default function StatisticsView() {
|
||||
const currentDateLabel = getCurrentDateLabel();
|
||||
@@ -64,44 +20,33 @@ export default function StatisticsView() {
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
|
||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||
|
||||
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
||||
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
|
||||
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
|
||||
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
|
||||
const latestTrend = trendData[trendData.length - 1];
|
||||
const previousTrend = trendData[trendData.length - 2];
|
||||
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
||||
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
|
||||
const selectedTarget = targets.find(target => target.id === selectedTargetId);
|
||||
const selectedMetrics = deriveSelectedTargetMetrics(
|
||||
selectedTarget,
|
||||
selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined,
|
||||
trendData,
|
||||
);
|
||||
|
||||
// Load targets on mount
|
||||
useEffect(() => {
|
||||
fetchTargets().then(data => {
|
||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||
const ordered = focused
|
||||
? [focused, ...data.filter(item => item.id !== focused.id)]
|
||||
: data;
|
||||
const ordered = orderTargets(data);
|
||||
setTargets(ordered);
|
||||
if (ordered.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(focused.id);
|
||||
setExpandedTargetId(focused.id);
|
||||
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
||||
setAssessmentYearMap(initialAssessmentYears(ordered));
|
||||
fetchTargetVehicles(focused.id).then(vehicles => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
|
||||
setTargetVehiclesMap(previous => ({ ...previous, [focused.id]: vehicles }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -113,7 +58,7 @@ export default function StatisticsView() {
|
||||
setExpandedTargetId(selectedTargetId);
|
||||
if (!targetVehiclesMap[selectedTargetId]) {
|
||||
fetchTargetVehicles(selectedTargetId).then(vehicles => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
|
||||
setTargetVehiclesMap(previous => ({ ...previous, [selectedTargetId]: vehicles }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||
@@ -124,640 +69,84 @@ export default function StatisticsView() {
|
||||
if (viewAllTargetId === null) return;
|
||||
setViewAllLoading(true);
|
||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
||||
setTargetVehiclesMap(previous => ({ ...previous, [viewAllTargetId]: data }));
|
||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
||||
}, [viewAllTargetId, viewAllDate]);
|
||||
|
||||
function selectTarget(targetId: number) {
|
||||
setSelectedTargetId(targetId);
|
||||
setExpandedTargetId(targetId);
|
||||
}
|
||||
|
||||
function expandTarget(targetId: number) {
|
||||
setExpandedTargetId(targetId);
|
||||
if (!targetVehiclesMap[targetId]) {
|
||||
fetchTargetVehicles(targetId).then(data => {
|
||||
setTargetVehiclesMap(previous => ({ ...previous, [targetId]: data }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function openAllVehicles(target: TargetSummary) {
|
||||
setViewAllTargetId(target.id);
|
||||
setViewAllTargetName(target.targetName);
|
||||
setViewAllDate(getDefaultDate());
|
||||
}
|
||||
|
||||
function refreshTargets() {
|
||||
fetchTargets().then(data => {
|
||||
setTargets(data);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||
{/* Project Selector */}
|
||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
||||
{targets.map(target => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => {
|
||||
setSelectedTargetId(target.id);
|
||||
setExpandedTargetId(target.id);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.targetName)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTarget && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="grid grid-cols-2 gap-2 md:grid-cols-4"
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
|
||||
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
|
||||
{pressureLevel}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(selectedCompletion)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {selectedDaysLeft} 天</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
|
||||
<div className="mt-1 text-lg font-black text-blue-600">
|
||||
{selectedDaysLeft > 0 ? (
|
||||
<>
|
||||
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
|
||||
</>
|
||||
) : '已到期'}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
||||
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<StatisticsOverview
|
||||
targets={targets}
|
||||
selectedTargetId={selectedTargetId}
|
||||
selectedTarget={selectedTarget}
|
||||
metrics={selectedMetrics}
|
||||
onSelectTarget={selectTarget}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||
{/* KPI Cards in Landscape — linked to selected target */}
|
||||
{(() => {
|
||||
const sel = selectedTarget;
|
||||
return (
|
||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{fmtKm(sel?.todayTotal ?? 0)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{fmtKm(sel?.cumulativeTotal ?? 0)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{sel?.vehicleCount ?? 0}
|
||||
<span className="text-blue-500 text-[10px] ml-1">台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{selectedCompletion.toFixed(1)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
|
||||
</div>
|
||||
<div className="flex bg-slate-50 p-1 rounded-lg">
|
||||
{(['bar', 'line', 'area'] as const).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setChartType(type)}
|
||||
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
|
||||
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[280px] w-full min-w-0">
|
||||
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
||||
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
|
||||
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
||||
{trendData.map((_entry: TrendPoint, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
) : chartType === 'line' ? (
|
||||
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
||||
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
|
||||
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
||||
</Line>
|
||||
</LineChart>
|
||||
) : (
|
||||
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
||||
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
|
||||
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
||||
</Area>
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Summary Section */}
|
||||
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 flex-shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
|
||||
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsTableFullscreen(true)}
|
||||
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||
{(selectedTarget ? [selectedTarget] : []).map((target, idx) => (
|
||||
(() => {
|
||||
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
||||
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
|
||||
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
||||
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
setExpandedTargetId(target.id);
|
||||
if (!targetVehiclesMap[target.id]) {
|
||||
fetchTargetVehicles(target.id).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
|
||||
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
|
||||
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
|
||||
</div>
|
||||
<div className="text-[8px] font-bold text-slate-300">
|
||||
累计: {fmtKm(target.cumulativeTotal)} KM
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: 180 }}
|
||||
className="text-slate-300"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{expandedTargetId === target.id && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||
<select
|
||||
value={assessment?.yearNumber || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
|
||||
}}
|
||||
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
|
||||
>
|
||||
{target.yearlyAssessments.map(item => (
|
||||
<option key={item.yearNumber} value={item.yearNumber}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||
{target.periods.map((p, i) => (
|
||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||
</div>
|
||||
</div>
|
||||
{assessment ? (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
||||
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
|
||||
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
|
||||
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
|
||||
{assessment.vehicleCount < target.vehicleCount && (
|
||||
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
||||
<p className="text-[8px] font-bold text-slate-300">
|
||||
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
|
||||
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
||||
<p className="text-[10px] font-black text-blue-500">
|
||||
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
||||
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
||||
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||
</div>
|
||||
|
||||
{/* Vehicle List Detail */}
|
||||
<div className="col-span-2 space-y-2 mt-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setViewAllTargetId(target.id);
|
||||
setViewAllTargetName(target.targetName);
|
||||
setViewAllDate(getDefaultDate());
|
||||
}}
|
||||
className="text-[8px] text-blue-500 font-bold hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
|
||||
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
|
||||
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
|
||||
<span className="text-[8px] text-slate-400 ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TrendPanel
|
||||
selectedTarget={selectedTarget}
|
||||
selectedCompletion={selectedMetrics.completion}
|
||||
trendData={trendData}
|
||||
/>
|
||||
<TargetDetailPanel
|
||||
target={selectedTarget}
|
||||
expandedTargetId={expandedTargetId}
|
||||
selectedYear={selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined}
|
||||
vehicles={selectedTarget ? targetVehiclesMap[selectedTarget.id] || [] : []}
|
||||
currentDateLabel={currentDateLabel}
|
||||
onOpenFullscreen={() => setIsTableFullscreen(true)}
|
||||
onExpandTarget={expandTarget}
|
||||
onAssessmentYearChange={(targetId, year) => {
|
||||
setAssessmentYearMap(previous => ({ ...previous, [targetId]: year }));
|
||||
}}
|
||||
onViewAll={openAllVehicles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Table Overlay */}
|
||||
<AnimatePresence>
|
||||
{isTableFullscreen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Top bar: compact inline KPI */}
|
||||
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
|
||||
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
|
||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsTableFullscreen(false)}
|
||||
className="p-1.5 text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FullscreenTargetTable
|
||||
isOpen={isTableFullscreen}
|
||||
targets={targets}
|
||||
assessmentYearMap={assessmentYearMap}
|
||||
onRefresh={refreshTargets}
|
||||
onClose={() => setIsTableFullscreen(false)}
|
||||
/>
|
||||
|
||||
{/* Table Area */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<tr className="border-b border-slate-800/60">
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{targets.map((target, idx) => {
|
||||
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
||||
const completion = assessment?.completionRate ?? target.avgCompletion;
|
||||
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
||||
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
|
||||
const goal = assessment?.target ?? target.currentYearTarget;
|
||||
const completed = assessment?.completed ?? target.currentYearCompleted;
|
||||
const remainingMileage = assessment?.remaining ?? target.remaining;
|
||||
const days = assessment?.daysLeft ?? target.daysLeft;
|
||||
const daily = assessment?.dailyTarget ?? target.dailyTarget;
|
||||
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
||||
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
||||
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
||||
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
|
||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(completion, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||
<span>{fmtKm(completed)}</span>
|
||||
<span>/ {fmtKm(goal)} km</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* View All Vehicles Side Panel */}
|
||||
<AnimatePresence>
|
||||
{viewAllTargetId !== null && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setViewAllTargetId(null)}
|
||||
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewAllTargetId(null);
|
||||
setViewAllSearch('');
|
||||
}}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索车牌号..."
|
||||
value={viewAllSearch}
|
||||
onChange={(e) => setViewAllSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
|
||||
<input
|
||||
type="date"
|
||||
value={viewAllDate}
|
||||
onChange={(e) => setViewAllDate(e.target.value)}
|
||||
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
{viewAllLoading ? '加载中...' : (() => {
|
||||
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
|
||||
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
|
||||
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
|
||||
>
|
||||
<ArrowUpDown size={12} />
|
||||
{viewAllSort === 'desc' ? '降序' : '升序'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
|
||||
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
||||
).sort((a, b) => {
|
||||
const valA = a.todayMileage || 0;
|
||||
const valB = b.todayMileage || 0;
|
||||
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
||||
}).map(tv => (
|
||||
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{tv.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2">
|
||||
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
||||
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||
<button
|
||||
onClick={() => setViewAllTargetId(null)}
|
||||
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
|
||||
>
|
||||
关闭明细
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AllVehiclesPanel
|
||||
targetId={viewAllTargetId}
|
||||
targetName={viewAllTargetName}
|
||||
vehicles={viewAllTargetId !== null ? targetVehiclesMap[viewAllTargetId] || [] : []}
|
||||
date={viewAllDate}
|
||||
loading={viewAllLoading}
|
||||
onDateChange={setViewAllDate}
|
||||
onClose={() => setViewAllTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { fmtDate } from './utils';
|
||||
|
||||
export default function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.16, ease: 'easeOut' }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/20 p-5 backdrop-blur-[2px]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
className="w-full max-w-[260px] overflow-hidden rounded-xl border border-slate-100 bg-white p-5 text-center shadow-2xl"
|
||||
>
|
||||
<div className="relative mx-auto flex h-12 w-12 items-center justify-center">
|
||||
<motion.span
|
||||
className="absolute inset-0 rounded-full border-2 border-blue-100"
|
||||
animate={{ scale: [0.86, 1.15], opacity: [0.9, 0] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeOut' }}
|
||||
/>
|
||||
<span className="relative flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 text-blue-600">
|
||||
<RefreshCw size={19} className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-black text-slate-900">正在加载日报</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">正在汇总 {fmtDate(reportDate)} 的里程数据</div>
|
||||
<div className="mt-4 h-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<motion.div
|
||||
className="h-full w-1/2 rounded-full bg-blue-500"
|
||||
animate={{ x: ['-100%', '220%'] }}
|
||||
transition={{ duration: 1.15, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
ChevronDown,
|
||||
CircleGauge,
|
||||
MapPin,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import type { MileageReportGroup } from '../api';
|
||||
import VehicleTable from './VehicleTable';
|
||||
import {
|
||||
currentAssessmentYearLabel,
|
||||
dailyMileageStatus,
|
||||
fmtKm,
|
||||
} from './utils';
|
||||
|
||||
interface GroupRowProps {
|
||||
group: MileageReportGroup;
|
||||
reportDate: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function BreakdownList({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
showAverage = true,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
items: MileageReportGroup['departments'];
|
||||
showAverage?: boolean;
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map(item => item.vehicleCount));
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
|
||||
<span className="text-slate-400">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无数据</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{items.map(item => (
|
||||
<div key={item.name}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-[11px] font-bold">
|
||||
<span className="truncate text-slate-600">{item.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-slate-500">
|
||||
{item.vehicleCount} 台{showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.max(4, (item.vehicleCount / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupRow({
|
||||
group,
|
||||
reportDate,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: GroupRowProps) {
|
||||
const highRate = group.operatingCount > 0 ? (group.highMileageCount / group.operatingCount) * 100 : 0;
|
||||
const qualifiedRate = group.vehicleCount > 0 ? (group.qualifiedCount / group.vehicleCount) * 100 : 0;
|
||||
const assessmentYearLabel = currentAssessmentYearLabel(group.assessmentPeriods, reportDate);
|
||||
const dailyStatus = dailyMileageStatus(group);
|
||||
const dailyStatusClass = dailyStatus.tone === 'positive'
|
||||
? 'border-emerald-100 bg-emerald-50/70 text-emerald-700'
|
||||
: dailyStatus.tone === 'warning'
|
||||
? 'border-amber-100 bg-amber-50/70 text-amber-700'
|
||||
: 'border-slate-100 bg-slate-50 text-slate-600';
|
||||
|
||||
return (
|
||||
<div className="border-b border-slate-100 last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="grid w-full grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50 max-lg:grid-cols-[minmax(150px,1fr)_82px_96px_24px]"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-xs font-black text-slate-900">{group.displayName}</span>
|
||||
{assessmentYearLabel ? (
|
||||
<span
|
||||
className="shrink-0 rounded-md bg-blue-50 px-1.5 py-0.5 text-[9px] font-black text-blue-700 ring-1 ring-blue-100"
|
||||
title={`当前考核年度:${assessmentYearLabel}`}
|
||||
>
|
||||
{assessmentYearLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-1 text-[10px] font-bold text-slate-400">
|
||||
<span>{group.vehicleCount} 台 · 运营 {group.operatingCount} · 库存 {group.inventoryCount}</span>
|
||||
<span className="text-emerald-600">· 达标 {group.qualifiedCount} 台</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-right">
|
||||
<span className="block text-xs font-black tabular-nums text-blue-600">{group.dailyMileage}</span>
|
||||
<span className="text-[9px] font-bold text-slate-400">km</span>
|
||||
</span>
|
||||
<span className="text-right max-lg:hidden">
|
||||
<span className={`block text-xs font-black tabular-nums ${group.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{group.dayOverDayDelta >= 0 ? '+' : ''}{fmtKm(group.dayOverDayDelta)}
|
||||
</span>
|
||||
<span className="text-[9px] font-bold text-slate-400">环比变化</span>
|
||||
</span>
|
||||
<span className="text-right max-lg:hidden">
|
||||
<span className="block text-xs font-black tabular-nums text-slate-700">{group.lowMileageCount}/{group.highMileageCount}</span>
|
||||
<span className="text-[9px] font-bold text-slate-400">低/高里程</span>
|
||||
</span>
|
||||
<span className="text-right">
|
||||
<span className="block text-xs font-black tabular-nums text-slate-700">{highRate.toFixed(1)}%</span>
|
||||
<span className="text-[9px] font-bold text-slate-400">高里程占比</span>
|
||||
</span>
|
||||
<span className="text-right max-lg:hidden">
|
||||
<span className="block text-xs font-black tabular-nums text-slate-700">{group.qualifiedCount}</span>
|
||||
<span className="text-[9px] font-bold text-slate-400">年度达标</span>
|
||||
</span>
|
||||
<ChevronDown size={16} className={`text-slate-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-slate-50/70 px-4 py-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_1fr_1.1fr]">
|
||||
<BreakdownList title="业务部门" icon={<Truck size={15} />} items={group.departments} />
|
||||
<BreakdownList title="库存地区" icon={<MapPin size={15} />} items={group.inventoryRegions} showAverage={false} />
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
|
||||
<CircleGauge size={15} className="text-slate-400" />考核进度
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||
<div className="text-[10px] font-bold text-slate-400">累计完成率</div>
|
||||
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.completionRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||
<div className="text-[10px] font-bold text-slate-400">达标50%</div>
|
||||
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.halfQualifiedCount} 台</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||
<div className="text-[10px] font-bold text-slate-400">剩余任务</div>
|
||||
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{fmtKm(group.remainingMileage)} km</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||
<div className="text-[10px] font-bold text-slate-400">每日应完成</div>
|
||||
<div className="mt-1 text-base font-black tabular-nums text-blue-600">{fmtKm(group.dailyRequiredMileage)} km</div>
|
||||
</div>
|
||||
<div className={`col-span-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-lg border p-3 ${dailyStatusClass}`}>
|
||||
<div>
|
||||
<div className="text-[10px] font-black">当日目标执行 · {dailyStatus.label}</div>
|
||||
<div className="mt-1 text-[10px] font-bold opacity-80">当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km</div>
|
||||
</div>
|
||||
<div className="max-w-[260px] text-[10px] font-bold leading-relaxed opacity-80">{dailyStatus.detail}</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-end justify-between gap-3 rounded-lg border border-blue-100 bg-blue-50/50 p-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-500">累计达标率</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-500">已达标 {group.qualifiedCount} / {group.vehicleCount} 台</div>
|
||||
</div>
|
||||
<div className="text-lg font-black tabular-nums text-blue-700">{qualifiedRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] font-bold leading-relaxed text-slate-400">{group.assessmentPeriods.join(';')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<VehicleTable group={group} />
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export default function MileageBandDefinition() {
|
||||
return (
|
||||
<section className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 sm:flex-row sm:items-center sm:gap-5">
|
||||
<div className="flex shrink-0 items-center gap-2 text-[11px] font-black text-slate-700">
|
||||
<Info size={14} className="text-blue-600" />里程分档口径
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
|
||||
<span><strong className="text-slate-800">低里程</strong>:当日里程 ≤100km</span>
|
||||
<span><strong className="text-blue-600">高里程</strong>:当日里程 >100km</span>
|
||||
<span>仅统计运营车辆,库存车辆单独统计</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { SurfaceCard } from '../../../components/ui/surface';
|
||||
import type { DailyMileageReport } from '../api';
|
||||
import { fmtDate, fmtKm } from './utils';
|
||||
|
||||
interface ReportChartsProps {
|
||||
report: DailyMileageReport;
|
||||
trendTargetId: number | null;
|
||||
onTrendTargetChange: (targetId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function ReportCharts({
|
||||
report,
|
||||
trendTargetId,
|
||||
onTrendTargetChange,
|
||||
}: ReportChartsProps) {
|
||||
const comparisonData = report.groups.map(group => ({
|
||||
name: group.displayName,
|
||||
运营里程: group.operatingMileage,
|
||||
库存里程: group.inventoryMileage,
|
||||
}));
|
||||
const trendGroup = trendTargetId == null
|
||||
? null
|
||||
: report.groups.find(group => group.targetId === trendTargetId) || null;
|
||||
const trendData = trendGroup?.trend || report.trend;
|
||||
const trendName = trendGroup?.displayName || '总计';
|
||||
const trendSubtitle = '选择总计或车型查看近 7 日每日里程,包含库存车辆里程';
|
||||
const trendColor = trendGroup ? '#0f766e' : '#2563eb';
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
|
||||
<SurfaceCard
|
||||
title="近 7 日里程趋势"
|
||||
subtitle={trendSubtitle}
|
||||
actions={(
|
||||
<div className="flex max-w-full gap-1 overflow-x-auto rounded-lg bg-slate-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTrendTargetChange(null)}
|
||||
className={`h-7 shrink-0 rounded-md px-2.5 text-[10px] font-black transition-colors ${trendGroup == null ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
总计
|
||||
</button>
|
||||
{report.groups.map(group => (
|
||||
<button
|
||||
key={group.targetId}
|
||||
type="button"
|
||||
onClick={() => onTrendTargetChange(group.targetId)}
|
||||
className={`h-7 shrink-0 rounded-md px-2.5 text-[10px] font-black transition-colors ${trendTargetId === group.targetId ? 'bg-white text-emerald-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
{group.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="h-[270px] px-2 py-3">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={{ width: 640, height: 246 }}
|
||||
>
|
||||
<LineChart data={trendData} margin={{ top: 14, right: 18, left: -4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtDate} />
|
||||
<YAxis axisLine={false} tickLine={false} width={58} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
|
||||
<Tooltip
|
||||
labelFormatter={label => fmtDate(String(label))}
|
||||
formatter={value => [`${fmtKm(Number(value))} km`, trendName]}
|
||||
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="totalMileage" stroke={trendColor} strokeWidth={2.5} dot={{ r: 3, fill: trendColor }} activeDot={{ r: 5 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard
|
||||
title={(
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span>车型里程构成</span>
|
||||
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-black text-blue-600 ring-1 ring-blue-100">统计日期 {fmtDate(report.reportDate)}</span>
|
||||
</span>
|
||||
)}
|
||||
subtitle="运营与库存里程分开展示"
|
||||
>
|
||||
<div className="h-[270px] px-2 py-3">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={{ width: 480, height: 246 }}
|
||||
>
|
||||
<BarChart data={comparisonData} layout="vertical" margin={{ top: 6, right: 14, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid horizontal={false} stroke="#e2e8f0" strokeDasharray="3 3" />
|
||||
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
|
||||
<YAxis type="category" dataKey="name" width={92} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b', fontWeight: 700 }} />
|
||||
<Tooltip formatter={(value, name) => [`${fmtKm(Number(value))} km`, String(name)]} contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 10, fontWeight: 700 }} />
|
||||
<Bar dataKey="运营里程" stackId="mileage" fill="#10b981" radius={[3, 0, 0, 3]} />
|
||||
<Bar dataKey="库存里程" stackId="mileage" fill="#f59e0b" radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { monthShift, reportDateLabel } from './utils';
|
||||
|
||||
interface ReportDatePickerProps {
|
||||
value: string;
|
||||
maxDate: string;
|
||||
onChange: (date: string) => void;
|
||||
}
|
||||
|
||||
export default function ReportDatePicker({
|
||||
value,
|
||||
maxDate,
|
||||
onChange,
|
||||
}: ReportDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [month, setMonth] = useState(value.slice(0, 7));
|
||||
const monthStart = new Date(`${month}-01T00:00:00Z`);
|
||||
const firstGridDate = new Date(monthStart);
|
||||
firstGridDate.setUTCDate(1 - monthStart.getUTCDay());
|
||||
const days = Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(firstGridDate);
|
||||
date.setUTCDate(firstGridDate.getUTCDate() + index);
|
||||
return date;
|
||||
});
|
||||
const weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const monthLabel = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
}).format(monthStart);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1 sm:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMonth(value.slice(0, 7));
|
||||
setOpen(current => !current);
|
||||
}}
|
||||
className="flex h-9 w-full min-w-[190px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-left text-xs font-black text-slate-700 transition-colors hover:border-blue-200 hover:bg-blue-50/50 sm:w-[208px]"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="选择日报日期"
|
||||
>
|
||||
<CalendarDays size={15} className="shrink-0 text-blue-500" />
|
||||
<span className="min-w-0 flex-1 truncate">{reportDateLabel(value)}</span>
|
||||
<ChevronDown size={14} className={`shrink-0 text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div role="dialog" aria-label="日报日期日历" className="absolute right-0 top-full z-40 mt-2 w-[308px] rounded-xl border border-slate-200 bg-white p-3 shadow-xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth(current => monthShift(current, -1))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100"
|
||||
title="上个月"
|
||||
aria-label="上个月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="text-sm font-black text-slate-900">{monthLabel}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth(current => monthShift(current, 1))}
|
||||
disabled={month >= maxDate.slice(0, 7)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title="下个月"
|
||||
aria-label="下个月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{weekdayLabels.map(day => <span key={day} className="py-1 text-[10px] font-black text-slate-400">{day}</span>)}
|
||||
{days.map(date => {
|
||||
const dateValue = date.toISOString().slice(0, 10);
|
||||
const inMonth = dateValue.slice(0, 7) === month;
|
||||
const selected = dateValue === value;
|
||||
const disabled = dateValue > maxDate;
|
||||
return (
|
||||
<button
|
||||
key={dateValue}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange(dateValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex h-9 items-center justify-center rounded-md text-xs font-black transition-colors ${
|
||||
selected
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: inMonth
|
||||
? 'text-slate-700 hover:bg-blue-50 hover:text-blue-600'
|
||||
: 'text-slate-300 hover:bg-slate-50'
|
||||
} disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent`}
|
||||
>
|
||||
{date.getUTCDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Archive, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import type { DailyMileageReport } from '../api';
|
||||
import { dateShift, fmtDate, fmtDateTime } from './utils';
|
||||
import ReportDatePicker from './ReportDatePicker';
|
||||
|
||||
interface ReportHeaderProps {
|
||||
report: DailyMileageReport;
|
||||
selectedDate: string;
|
||||
maxDate: string;
|
||||
onDateChange: (date: string) => void;
|
||||
}
|
||||
|
||||
export default function ReportHeader({
|
||||
report,
|
||||
selectedDate,
|
||||
maxDate,
|
||||
onDateChange,
|
||||
}: ReportHeaderProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate text-base font-black text-slate-950">{report.title}</h2>
|
||||
<span className={`inline-flex h-6 items-center gap-1 rounded-md px-2 text-[10px] font-black ${
|
||||
report.status === 'ARCHIVED' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
|
||||
}`}>
|
||||
{report.status === 'ARCHIVED' ? <Archive size={12} /> : <RefreshCw size={12} />}
|
||||
{report.status === 'ARCHIVED' ? '已归档' : '预览'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
||||
{fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT'
|
||||
? '人工台账归档'
|
||||
: report.status === 'ARCHIVED'
|
||||
? `归档于 ${fmtDateTime(report.generatedAt)}`
|
||||
: `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-bold text-slate-500">
|
||||
统计周期:自然日 · 汇总频率:每日 06:30 汇总前一日里程
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDateChange(dateShift(selectedDate, -1))}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
|
||||
title="前一天"
|
||||
aria-label="前一天"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<ReportDatePicker value={selectedDate} maxDate={maxDate} onChange={onDateChange} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDateChange(dateShift(selectedDate, 1))}
|
||||
disabled={selectedDate >= maxDate}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title="后一天"
|
||||
aria-label="后一天"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AlertTriangle, CircleGauge, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import type { DailyMileageReport } from '../api';
|
||||
|
||||
function InsightIcon({ tone }: { tone: string }) {
|
||||
if (tone === 'positive') return <TrendingUp size={16} />;
|
||||
if (tone === 'critical') return <AlertTriangle size={16} />;
|
||||
if (tone === 'warning') return <TrendingDown size={16} />;
|
||||
return <CircleGauge size={16} />;
|
||||
}
|
||||
|
||||
export default function ReportInsights({
|
||||
insights,
|
||||
}: {
|
||||
insights: DailyMileageReport['insights'];
|
||||
}) {
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 xl:grid-cols-4">
|
||||
{insights.map(insight => {
|
||||
const toneClass = insight.tone === 'positive'
|
||||
? 'text-emerald-600 bg-emerald-50'
|
||||
: insight.tone === 'critical'
|
||||
? 'text-rose-600 bg-rose-50'
|
||||
: insight.tone === 'warning'
|
||||
? 'text-amber-600 bg-amber-50'
|
||||
: 'text-blue-600 bg-blue-50';
|
||||
return (
|
||||
<div key={`${insight.title}-${insight.detail}`} className="flex min-h-[96px] gap-2.5 bg-white p-3 xl:min-h-[88px]">
|
||||
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${toneClass}`}><InsightIcon tone={insight.tone} /></span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-black text-slate-800">{insight.title}</div>
|
||||
<div className="mt-1 text-[10px] font-bold leading-relaxed text-slate-500">{insight.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { CircleGauge, Route, TrendingUp, Truck, Warehouse } from 'lucide-react';
|
||||
import { MetricTile } from '../../../components/ui/surface';
|
||||
import type { DailyMileageReport } from '../api';
|
||||
import { deltaLabel, fmtKm } from './utils';
|
||||
|
||||
interface ReportOverviewProps {
|
||||
report: DailyMileageReport;
|
||||
highMileageRate: number;
|
||||
}
|
||||
|
||||
function MobileDailyOverview({ report, highMileageRate }: ReportOverviewProps) {
|
||||
const operatingRate = (report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100;
|
||||
const stats = [
|
||||
{
|
||||
label: '运营车辆',
|
||||
value: report.totals.operatingCount,
|
||||
unit: '台',
|
||||
detail: `${report.totals.vehicleCount} 台考核车辆 · ${operatingRate.toFixed(1)}%`,
|
||||
icon: Truck,
|
||||
tone: 'text-emerald-600 bg-emerald-50',
|
||||
},
|
||||
{
|
||||
label: '库存车辆',
|
||||
value: report.totals.inventoryCount,
|
||||
unit: '台',
|
||||
detail: `产生 ${fmtKm(report.totals.inventoryMileage)} km`,
|
||||
icon: Warehouse,
|
||||
tone: 'text-rose-600 bg-rose-50',
|
||||
},
|
||||
{
|
||||
label: '运营单车均值',
|
||||
value: fmtKm(report.totals.averageOperatingMileage),
|
||||
unit: 'km/台',
|
||||
detail: `运营里程 ${fmtKm(report.totals.operatingMileage)} km`,
|
||||
icon: CircleGauge,
|
||||
tone: 'text-slate-600 bg-slate-100',
|
||||
},
|
||||
{
|
||||
label: '高里程车辆',
|
||||
value: report.totals.highMileageCount,
|
||||
unit: '台',
|
||||
detail: `${highMileageRate.toFixed(1)}% · 低里程 ${report.totals.lowMileageCount} 台`,
|
||||
icon: TrendingUp,
|
||||
tone: 'text-blue-600 bg-blue-50',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm md:hidden">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3.5">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">当日总里程</div>
|
||||
<div className="mt-1 flex items-end gap-1">
|
||||
<span className="whitespace-nowrap text-[clamp(2rem,10vw,2.45rem)] font-black leading-none tabular-nums text-slate-950">{fmtKm(report.totals.dailyMileage)}</span>
|
||||
<span className="mb-0.5 text-xs font-black text-slate-400">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-1 rounded-lg px-2.5 py-1.5 text-right ${report.totals.dayOverDayDelta >= 0 ? 'bg-emerald-50' : 'bg-rose-50'}`}>
|
||||
<div className="text-[9px] font-black text-slate-400">较前一日</div>
|
||||
<div className={`mt-0.5 text-[11px] font-black tabular-nums ${report.totals.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-t border-slate-100">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.label} className={`min-w-0 px-3 py-2.5 ${index < 2 ? 'border-b border-slate-100' : ''} ${index % 2 === 0 ? 'border-r border-slate-100' : ''}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[10px] font-black text-slate-400">{stat.label}</span>
|
||||
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${stat.tone}`}><Icon size={13} /></span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-end gap-1">
|
||||
<span className="min-w-0 truncate text-xl font-black leading-none tabular-nums text-slate-900">{stat.value}</span>
|
||||
<span className="mb-px shrink-0 text-[10px] font-black text-slate-400">{stat.unit}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[9px] font-bold text-slate-500">{stat.detail}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportOverview({ report, highMileageRate }: ReportOverviewProps) {
|
||||
return (
|
||||
<>
|
||||
<MobileDailyOverview report={report} highMileageRate={highMileageRate} />
|
||||
|
||||
<div className="hidden grid-cols-2 gap-2 md:grid xl:grid-cols-5">
|
||||
<MetricTile
|
||||
label="当日总里程"
|
||||
value={fmtKm(report.totals.dailyMileage)}
|
||||
unit="km"
|
||||
helper={deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
|
||||
icon={Route}
|
||||
tone="blue"
|
||||
className="col-span-2 xl:col-span-1"
|
||||
/>
|
||||
<MetricTile
|
||||
label="运营车辆"
|
||||
value={report.totals.operatingCount}
|
||||
unit="台"
|
||||
helper={`${report.totals.vehicleCount} 台考核车辆 · 运营率 ${((report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100).toFixed(1)}%`}
|
||||
icon={Truck}
|
||||
tone="emerald"
|
||||
compact
|
||||
/>
|
||||
<MetricTile
|
||||
label="库存车辆"
|
||||
value={report.totals.inventoryCount}
|
||||
unit="台"
|
||||
helper={`库存产生 ${fmtKm(report.totals.inventoryMileage)} km`}
|
||||
icon={Warehouse}
|
||||
tone={report.totals.inventoryMileage > 0 ? 'rose' : 'amber'}
|
||||
compact
|
||||
/>
|
||||
<MetricTile
|
||||
label="运营单车均值"
|
||||
value={fmtKm(report.totals.averageOperatingMileage)}
|
||||
unit="km/台"
|
||||
helper={`运营里程 ${fmtKm(report.totals.operatingMileage)} km`}
|
||||
icon={CircleGauge}
|
||||
tone="slate"
|
||||
compact
|
||||
/>
|
||||
<MetricTile
|
||||
label="高里程车辆"
|
||||
value={report.totals.highMileageCount}
|
||||
unit="台"
|
||||
helper={`占运营车辆 ${highMileageRate.toFixed(1)}% · ≤100km ${report.totals.lowMileageCount} 台`}
|
||||
icon={TrendingUp}
|
||||
tone="blue"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowUp,
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { MileageReportGroup } from '../api';
|
||||
import {
|
||||
filterAndSortVehicles,
|
||||
fmtDate,
|
||||
fmtKm,
|
||||
roundMileageForDisplay,
|
||||
sevenDayMileageOf,
|
||||
type SortDirection,
|
||||
type VehicleFilter,
|
||||
type VehicleSortKey,
|
||||
} from './utils';
|
||||
|
||||
const VEHICLE_PAGE_SIZE = 20;
|
||||
|
||||
const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [
|
||||
{ id: 'ALL', label: '全部' },
|
||||
{ id: 'LOW', label: '≤100km' },
|
||||
{ id: 'HIGH', label: '>100km' },
|
||||
{ id: 'INVENTORY', label: '库存' },
|
||||
];
|
||||
|
||||
function SortableVehicleHeader({
|
||||
label,
|
||||
sortKey,
|
||||
activeSortKey,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: VehicleSortKey;
|
||||
activeSortKey: VehicleSortKey;
|
||||
sortDirection: SortDirection;
|
||||
onSort: (sortKey: VehicleSortKey) => void;
|
||||
}) {
|
||||
const active = activeSortKey === sortKey;
|
||||
const Icon = !active ? ArrowDownUp : sortDirection === 'asc' ? ArrowUp : ArrowDown;
|
||||
const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(sortKey)}
|
||||
className={`ml-auto flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors ${active ? 'text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'}`}
|
||||
title={`${label}:${order},点击切换`}
|
||||
aria-label={`${label}:${order},点击切换`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Icon size={12} strokeWidth={active ? 2.5 : 2} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VehicleTable({ group }: { group: MileageReportGroup }) {
|
||||
const [filter, setFilter] = useState<VehicleFilter>('ALL');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<VehicleSortKey>('dailyMileage');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [trendPlate, setTrendPlate] = useState<string | null>(null);
|
||||
const trendPanelRef = useRef<HTMLDivElement>(null);
|
||||
const vehicles = useMemo(
|
||||
() => filterAndSortVehicles(group.vehicles, filter, search, sortKey, sortDirection),
|
||||
[filter, group.vehicles, search, sortDirection, sortKey],
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(vehicles.length / VEHICLE_PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const displayedVehicles = vehicles.slice(
|
||||
(currentPage - 1) * VEHICLE_PAGE_SIZE,
|
||||
currentPage * VEHICLE_PAGE_SIZE,
|
||||
);
|
||||
const firstVehicleIndex = vehicles.length === 0 ? 0 : (currentPage - 1) * VEHICLE_PAGE_SIZE + 1;
|
||||
const lastVehicleIndex = Math.min(currentPage * VEHICLE_PAGE_SIZE, vehicles.length);
|
||||
const toggleSort = (nextSortKey: VehicleSortKey) => {
|
||||
setPage(1);
|
||||
if (nextSortKey === sortKey) {
|
||||
setSortDirection(current => current === 'asc' ? 'desc' : 'asc');
|
||||
return;
|
||||
}
|
||||
setSortKey(nextSortKey);
|
||||
setSortDirection('desc');
|
||||
};
|
||||
const trendVehicle = trendPlate ? group.vehicles.find(vehicle => vehicle.plate === trendPlate) || null : null;
|
||||
// 旧日报快照没有逐车趋势时,只在报告日回填当日里程,保持原有兼容展示。
|
||||
const trendData = trendVehicle?.trend?.length
|
||||
? trendVehicle.trend
|
||||
: group.trend.map((point, index) => ({
|
||||
date: point.date,
|
||||
totalMileage: index === group.trend.length - 1 ? trendVehicle?.dailyMileage || 0 : 0,
|
||||
}));
|
||||
const sevenDayTotal = roundMileageForDisplay(trendData.reduce((total, point) => total + point.totalMileage, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!trendPlate) return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
trendPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [trendPlate]);
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-slate-100 pt-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 gap-1 overflow-x-auto rounded-lg bg-slate-100 p-1">
|
||||
{VEHICLE_FILTERS.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilter(item.id);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`h-8 shrink-0 rounded-md px-3 text-[11px] font-black transition-colors ${
|
||||
filter === item.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex h-9 min-w-0 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 sm:w-64">
|
||||
<Search size={14} className="shrink-0 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={event => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="车牌、客户或部门"
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-xs font-bold text-slate-700 outline-none placeholder:text-slate-300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{trendVehicle ? (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div ref={trendPanelRef} className="mt-3 scroll-mt-20 rounded-lg border border-blue-100 bg-white p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs font-black text-slate-900">
|
||||
<BarChart3 size={15} className="text-blue-600" />
|
||||
<Blur>{trendVehicle.plate}</Blur>
|
||||
<span className="font-bold text-slate-400">近7日里程</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
|
||||
<span>区间累计 <strong className="text-slate-900">{fmtKm(sevenDayTotal)} km</strong></span>
|
||||
<span>日均 <strong className="text-slate-900">{fmtKm(roundMileageForDisplay(sevenDayTotal / Math.max(1, trendData.length)))} km</strong></span>
|
||||
<span>当日 <strong className="text-blue-600">{fmtKm(trendVehicle.dailyMileage)} km</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendPlate(null)}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
title="关闭趋势图"
|
||||
aria-label="关闭趋势图"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 h-[190px] min-w-0">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={{ width: 760, height: 190 }}
|
||||
>
|
||||
<BarChart data={trendData} margin={{ top: 10, right: 12, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} tickFormatter={fmtDate} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} />
|
||||
<Tooltip
|
||||
labelFormatter={label => fmtDate(String(label))}
|
||||
formatter={value => [`${fmtKm(Number(value))} km`, '当日里程']}
|
||||
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
|
||||
/>
|
||||
<ReferenceLine y={100} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: '100km', fill: '#d97706', fontSize: 9, position: 'insideTopRight' }} />
|
||||
<Bar dataKey="totalMileage" radius={[4, 4, 0, 0]} maxBarSize={42}>
|
||||
{trendData.map(point => (
|
||||
<Cell key={point.date} fill={point.totalMileage > 100 ? '#2563eb' : '#94a3b8'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-3 max-h-[420px] overflow-auto rounded-lg border border-slate-100">
|
||||
<div className="sticky top-0 z-10 grid min-w-[720px] grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] gap-3 bg-slate-50 px-3 py-2 text-[10px] font-black text-slate-400 md:min-w-[930px] md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px]">
|
||||
<span className="sticky left-3 z-20 -my-2 flex items-center !bg-slate-50 py-2 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)]"><span className="md:hidden">车牌/状态</span><span className="hidden md:inline">车牌</span></span>
|
||||
<span className="sticky left-[110px] z-20 -my-2 hidden items-center bg-slate-50 py-2 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex">状态</span>
|
||||
<span>部门/地区</span><span>客户</span>
|
||||
<SortableVehicleHeader label="当日里程" sortKey="dailyMileage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
|
||||
<SortableVehicleHeader label="近7日累计" sortKey="sevenDayMileage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
|
||||
<SortableVehicleHeader label="考核进度" sortKey="completionRate" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
|
||||
<span />
|
||||
</div>
|
||||
<div className="min-w-[720px] divide-y divide-slate-100 bg-white md:min-w-[930px]">
|
||||
{displayedVehicles.map(vehicle => {
|
||||
const sevenDayMileage = sevenDayMileageOf(vehicle);
|
||||
const selected = trendPlate === vehicle.plate;
|
||||
const frozenCellClass = selected ? '!bg-blue-50' : '!bg-white';
|
||||
return (
|
||||
<div
|
||||
key={vehicle.plate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
aria-label={`选择${vehicle.plate}查看近7日每日里程`}
|
||||
onClick={() => setTrendPlate(vehicle.plate)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setTrendPlate(vehicle.plate);
|
||||
}
|
||||
}}
|
||||
className={`grid cursor-pointer grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] items-center gap-3 px-3 py-2.5 text-[11px] font-bold outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px] ${selected ? 'bg-blue-50/60' : ''}`}
|
||||
>
|
||||
<span className={`sticky left-3 z-10 -my-2.5 flex self-stretch flex-col justify-center font-mono font-black text-slate-800 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex-row md:items-center md:shadow-none ${frozenCellClass}`}>
|
||||
<Blur>{vehicle.plate}</Blur>
|
||||
<span className={`mt-0.5 font-sans text-[9px] md:hidden ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
|
||||
</span>
|
||||
<span className={`sticky left-[110px] z-10 -my-2.5 hidden self-stretch items-center shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex ${frozenCellClass} ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
|
||||
<span className="truncate text-slate-500">{vehicle.department || vehicle.inventoryRegion || '未标注'}</span>
|
||||
<span className="truncate text-slate-500"><Blur>{vehicle.customer || '未绑定客户'}</Blur></span>
|
||||
<span className={`text-right tabular-nums ${vehicle.mileageBand === 'HIGH' ? 'text-blue-600' : vehicle.mileageBand === 'INVENTORY' && vehicle.dailyMileage > 0 ? 'text-rose-600' : 'text-slate-700'}`}>
|
||||
{fmtKm(vehicle.dailyMileage)} km
|
||||
</span>
|
||||
<span className="text-right tabular-nums font-black text-slate-700">{fmtKm(sevenDayMileage)} km</span>
|
||||
<span className="text-right tabular-nums text-slate-600">{vehicle.completionRate.toFixed(1)}%</span>
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-md ${selected ? 'bg-blue-600 text-white' : 'text-slate-400'}`} aria-hidden="true">
|
||||
<BarChart3 size={15} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{vehicles.length === 0 ? <div className="px-4 py-8 text-center text-xs font-bold text-slate-400">没有符合条件的车辆</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-400">
|
||||
<span>显示 {firstVehicleIndex}-{lastVehicleIndex} / {vehicles.length} 台</span>
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(current => Math.max(1, current - 1))}
|
||||
disabled={currentPage === 1}
|
||||
title="上一页"
|
||||
aria-label="上一页"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="min-w-8 text-center tabular-nums text-slate-500">{currentPage}/{totalPages}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(current => Math.min(totalPages, current + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
title="下一页"
|
||||
aria-label="下一页"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { MileageReportVehicle } from '../api';
|
||||
import {
|
||||
currentAssessmentYearLabel,
|
||||
dailyMileageStatus,
|
||||
dateShift,
|
||||
deltaLabel,
|
||||
filterAndSortVehicles,
|
||||
fmtDate,
|
||||
fmtKm,
|
||||
initialReportDate,
|
||||
isVisibleQualityNote,
|
||||
monthShift,
|
||||
roundMileageForDisplay,
|
||||
sevenDayMileageOf,
|
||||
shanghaiYmd,
|
||||
} from './utils';
|
||||
|
||||
function vehicle(overrides: Partial<MileageReportVehicle>): MileageReportVehicle {
|
||||
return {
|
||||
plate: '粤A00000',
|
||||
status: '运营',
|
||||
customer: null,
|
||||
department: null,
|
||||
inventoryRegion: null,
|
||||
dailyMileage: 0,
|
||||
cumulativeMileage: 0,
|
||||
completionRate: 0,
|
||||
mileageBand: 'LOW',
|
||||
trend: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('日报日期始终按上海自然日计算,并支持跨月偏移', () => {
|
||||
const now = new Date('2026-08-12T16:30:00.000Z');
|
||||
assert.equal(shanghaiYmd(0, now), '2026-08-13');
|
||||
assert.equal(shanghaiYmd(-1, now), '2026-08-12');
|
||||
assert.equal(dateShift('2026-03-01', -1), '2026-02-28');
|
||||
assert.equal(monthShift('2026-01', -1), '2025-12');
|
||||
});
|
||||
|
||||
test('URL 中合法日报日期优先,非法值回退到前一日', () => {
|
||||
assert.equal(initialReportDate('?mileageReportDate=2026-03-31', '2026-08-12'), '2026-03-31');
|
||||
assert.equal(initialReportDate('?mileageReportDate=bad-date', '2026-08-12'), '2026-08-12');
|
||||
assert.equal(initialReportDate('', '2026-08-12'), '2026-08-12');
|
||||
});
|
||||
|
||||
test('里程和日期文案保持现有显示精度', () => {
|
||||
assert.equal(fmtKm(88123.46), '8.81万');
|
||||
assert.equal(fmtKm(5182.04), '5,182');
|
||||
assert.equal(fmtDate('2026-08-11'), '8月11日');
|
||||
assert.equal(deltaLabel(16197.7, 22.54), '+1.62万 km · +22.5%');
|
||||
assert.equal(roundMileageForDisplay(12.345), 12.3);
|
||||
});
|
||||
|
||||
test('分批车辆可同时展示多个当前考核年度', () => {
|
||||
const periods = [
|
||||
'2024/6/13-2029/6/12',
|
||||
'2025/12/24-2027/12/23',
|
||||
];
|
||||
assert.equal(currentAssessmentYearLabel(periods, '2026-03-31'), '考核第1/2年');
|
||||
assert.equal(currentAssessmentYearLabel(periods, '2030-01-01'), null);
|
||||
});
|
||||
|
||||
test('每日目标状态区分已完成、缺截止日、达标和未达标', () => {
|
||||
assert.deepEqual(
|
||||
dailyMileageStatus({ dailyRequiredMileage: 0, remainingMileage: 0, dailyMileage: 0 }),
|
||||
{ label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
dailyMileageStatus({ dailyRequiredMileage: 0, remainingMileage: 100, dailyMileage: 0 }),
|
||||
{ label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
dailyMileageStatus({ dailyRequiredMileage: 100, remainingMileage: 1000, dailyMileage: 120 }),
|
||||
{ label: '今日已达标', detail: '超出应完成 20 km,完成 120.0%。', tone: 'positive' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
dailyMileageStatus({ dailyRequiredMileage: 100, remainingMileage: 1000, dailyMileage: 80 }),
|
||||
{ label: '今日未达标', detail: '距应完成差 20 km,完成 80.0%。', tone: 'warning' },
|
||||
);
|
||||
});
|
||||
|
||||
test('车辆列表保持筛选、搜索、排序和车牌兜底顺序', () => {
|
||||
const vehicles = [
|
||||
vehicle({ plate: '粤A00002', dailyMileage: 80, completionRate: 20, customer: '甲客户', trend: [{ date: '2026-08-11', totalMileage: 20 }] }),
|
||||
vehicle({ plate: '粤A00001', dailyMileage: 180, completionRate: 10, department: '业务一部', mileageBand: 'HIGH', trend: [{ date: '2026-08-11', totalMileage: 30 }] }),
|
||||
vehicle({ plate: '粤A00003', dailyMileage: 0, completionRate: 30, mileageBand: 'INVENTORY' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterAndSortVehicles(vehicles, 'ALL', '', 'dailyMileage', 'desc').map(item => item.plate),
|
||||
['粤A00001', '粤A00002', '粤A00003'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterAndSortVehicles(vehicles, 'HIGH', '业务一部', 'completionRate', 'asc').map(item => item.plate),
|
||||
['粤A00001'],
|
||||
);
|
||||
assert.equal(sevenDayMileageOf(vehicles[1]), 30);
|
||||
});
|
||||
|
||||
test('人工汇报口径旧提示继续隐藏,其他质量提示保留', () => {
|
||||
assert.equal(isVisibleQualityNote('日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。'), false);
|
||||
assert.equal(isVisibleQualityNote('部分车辆缺少仪表数据'), true);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
MileageReportGroup,
|
||||
MileageReportVehicle,
|
||||
} from '../api';
|
||||
|
||||
export type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY';
|
||||
export type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const REPORT_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const HIDDEN_QUALITY_NOTE = '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。';
|
||||
|
||||
export function shanghaiYmd(offsetDays = 0, now = new Date()): string {
|
||||
// 日报按上海自然日归档,不能依赖浏览器所在时区计算“昨天”。
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
const date = new Date(`${parts}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + offsetDays);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function dateShift(date: string, days: number): string {
|
||||
const value = new Date(`${date}T00:00:00Z`);
|
||||
value.setUTCDate(value.getUTCDate() + days);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function monthShift(month: string, months: number): string {
|
||||
const value = new Date(`${month}-01T00:00:00Z`);
|
||||
value.setUTCMonth(value.getUTCMonth() + months);
|
||||
return value.toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
export function reportDateLabel(value: string): string {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).format(date).replace(/日(?=周)/, '日 ');
|
||||
}
|
||||
|
||||
export function initialReportDate(search: string, fallbackDate = shanghaiYmd(-1)): string {
|
||||
const value = new URLSearchParams(search).get('mileageReportDate');
|
||||
return REPORT_DATE_PATTERN.test(value || '') ? value! : fallbackDate;
|
||||
}
|
||||
|
||||
export function fmtKm(value: number): string {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`;
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
export function fmtDate(value: string): string {
|
||||
const [, month, day] = value.split('-');
|
||||
return `${Number(month)}月${Number(day)}日`;
|
||||
}
|
||||
|
||||
export function fmtDateTime(value: string | null): string {
|
||||
if (!value) return '数据时间未返回';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function deltaLabel(value: number, rate: number | null): string {
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`;
|
||||
}
|
||||
|
||||
export function currentAssessmentYearLabel(periods: string[], reportDate: string): string | null {
|
||||
const report = new Date(`${reportDate}T00:00:00Z`);
|
||||
if (Number.isNaN(report.getTime())) return null;
|
||||
const years = new Set<number>();
|
||||
|
||||
// 同一车型可能分批交付,因此一个日报日期可能同时落在多个考核年度。
|
||||
for (const period of periods) {
|
||||
const dates = Array.from(period.matchAll(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/g));
|
||||
const start = dates[0];
|
||||
const end = dates[1];
|
||||
if (!start || !end) continue;
|
||||
const startDate = new Date(Date.UTC(Number(start[1]), Number(start[2]) - 1, Number(start[3])));
|
||||
const endDate = new Date(Date.UTC(Number(end[1]), Number(end[2]) - 1, Number(end[3])));
|
||||
if (report < startDate || report > endDate) continue;
|
||||
|
||||
let yearNumber = report.getUTCFullYear() - startDate.getUTCFullYear() + 1;
|
||||
if (
|
||||
report.getUTCMonth() < startDate.getUTCMonth()
|
||||
|| (report.getUTCMonth() === startDate.getUTCMonth() && report.getUTCDate() < startDate.getUTCDate())
|
||||
) yearNumber -= 1;
|
||||
if (yearNumber > 0) years.add(yearNumber);
|
||||
}
|
||||
|
||||
if (years.size === 0) return null;
|
||||
const labels = Array.from(years).sort((a, b) => a - b).map(year => String(year));
|
||||
return `考核第${labels.join('/')}年`;
|
||||
}
|
||||
|
||||
export function dailyMileageStatus(group: Pick<
|
||||
MileageReportGroup,
|
||||
'dailyRequiredMileage' | 'remainingMileage' | 'dailyMileage'
|
||||
>): {
|
||||
label: string;
|
||||
detail: string;
|
||||
tone: 'positive' | 'warning' | 'neutral';
|
||||
} {
|
||||
if (group.dailyRequiredMileage <= 0) {
|
||||
if (group.remainingMileage <= 0) {
|
||||
return { label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' };
|
||||
}
|
||||
return { label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' };
|
||||
}
|
||||
|
||||
const gap = group.dailyMileage - group.dailyRequiredMileage;
|
||||
const rate = (group.dailyMileage / group.dailyRequiredMileage) * 100;
|
||||
if (gap >= 0) {
|
||||
return { label: '今日已达标', detail: `超出应完成 ${fmtKm(gap)} km,完成 ${rate.toFixed(1)}%。`, tone: 'positive' };
|
||||
}
|
||||
return { label: '今日未达标', detail: `距应完成差 ${fmtKm(Math.abs(gap))} km,完成 ${rate.toFixed(1)}%。`, tone: 'warning' };
|
||||
}
|
||||
|
||||
export function sevenDayMileageOf(vehicle: MileageReportVehicle): number {
|
||||
return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0);
|
||||
}
|
||||
|
||||
function vehicleSortValue(vehicle: MileageReportVehicle, sortKey: VehicleSortKey): number {
|
||||
return sortKey === 'sevenDayMileage' ? sevenDayMileageOf(vehicle) : vehicle[sortKey];
|
||||
}
|
||||
|
||||
export function filterAndSortVehicles(
|
||||
vehicles: MileageReportVehicle[],
|
||||
filter: VehicleFilter,
|
||||
search: string,
|
||||
sortKey: VehicleSortKey,
|
||||
sortDirection: SortDirection,
|
||||
): MileageReportVehicle[] {
|
||||
const query = search.trim().toLowerCase();
|
||||
return vehicles
|
||||
.filter(vehicle => {
|
||||
if (filter !== 'ALL' && vehicle.mileageBand !== filter) return false;
|
||||
if (!query) return true;
|
||||
return vehicle.plate.toLowerCase().includes(query)
|
||||
|| (vehicle.customer || '').toLowerCase().includes(query)
|
||||
|| (vehicle.department || '').toLowerCase().includes(query);
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const difference = vehicleSortValue(left, sortKey) - vehicleSortValue(right, sortKey);
|
||||
if (difference !== 0) return sortDirection === 'asc' ? difference : -difference;
|
||||
return left.plate.localeCompare(right.plate, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
export function roundMileageForDisplay(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function isVisibleQualityNote(message: string): boolean {
|
||||
return message !== HIDDEN_QUALITY_NOTE;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { MonitoringFilters } from '../../types';
|
||||
import { defaultMileageDate, normalizeRangeLabel } from '../date-range';
|
||||
|
||||
type MileageRange = { min: string; max: string };
|
||||
|
||||
export default function ActiveFilterTags({
|
||||
filterTargetNames,
|
||||
filterOptions,
|
||||
onFilterTargetNamesChange,
|
||||
filterRegion,
|
||||
onFilterRegionChange,
|
||||
filterPlates,
|
||||
onFilterPlatesChange,
|
||||
filterRentStatus,
|
||||
onFilterRentStatusChange,
|
||||
filterDept,
|
||||
onFilterDeptChange,
|
||||
filterCustomer,
|
||||
onFilterCustomerChange,
|
||||
filterBrands,
|
||||
onFilterBrandsChange,
|
||||
filterProject,
|
||||
onFilterProjectChange,
|
||||
filterEntity,
|
||||
onFilterEntityChange,
|
||||
searchTerm,
|
||||
onSearchTermChange,
|
||||
onFilterMileageRangeChange,
|
||||
appliedMileageRange,
|
||||
onAppliedMileageRangeChange,
|
||||
filterPlatePrefix,
|
||||
onFilterPlatePrefixChange,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
onRangeStartChange,
|
||||
onRangeEndChange,
|
||||
}: {
|
||||
filterTargetNames: string[];
|
||||
filterOptions: MonitoringFilters;
|
||||
onFilterTargetNamesChange: (value: string[]) => void;
|
||||
filterRegion: string;
|
||||
onFilterRegionChange: (value: string) => void;
|
||||
filterPlates: string[];
|
||||
onFilterPlatesChange: (value: string[]) => void;
|
||||
filterRentStatus: string;
|
||||
onFilterRentStatusChange: (value: string) => void;
|
||||
filterDept: string;
|
||||
onFilterDeptChange: (value: string) => void;
|
||||
filterCustomer: string;
|
||||
onFilterCustomerChange: (value: string) => void;
|
||||
filterBrands: string[];
|
||||
onFilterBrandsChange: (value: string[]) => void;
|
||||
filterProject: string;
|
||||
onFilterProjectChange: (value: string) => void;
|
||||
filterEntity: string;
|
||||
onFilterEntityChange: (value: string) => void;
|
||||
searchTerm: string;
|
||||
onSearchTermChange: (value: string) => void;
|
||||
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
||||
appliedMileageRange: MileageRange;
|
||||
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
||||
filterPlatePrefix: string;
|
||||
onFilterPlatePrefixChange: (value: string) => void;
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
onRangeStartChange: (value: string) => void;
|
||||
onRangeEndChange: (value: string) => void;
|
||||
}) {
|
||||
const tags: { label: string; onClear: () => void }[] = [];
|
||||
if (filterTargetNames.length > 0) tags.push({
|
||||
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]} 等${filterTargetNames.length}`}`,
|
||||
onClear: () => onFilterTargetNamesChange([])
|
||||
});
|
||||
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => onFilterRegionChange('All') });
|
||||
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => onFilterPlatesChange([]) });
|
||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => onFilterRentStatusChange('All') });
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => onFilterDeptChange('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => onFilterCustomerChange('All') });
|
||||
if (filterBrands.length > 0) tags.push({
|
||||
label: `品牌: ${filterBrands.length === 1 ? filterBrands[0] : `${filterBrands[0]} 等${filterBrands.length}`}`,
|
||||
onClear: () => onFilterBrandsChange([])
|
||||
});
|
||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
|
||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
|
||||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') });
|
||||
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, min: '' })); } });
|
||||
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } });
|
||||
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
|
||||
if (rangeStart || rangeEnd) tags.push({
|
||||
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
|
||||
onClear: () => {
|
||||
const today = defaultMileageDate();
|
||||
onRangeStartChange(today);
|
||||
onRangeEndChange(today);
|
||||
}
|
||||
});
|
||||
if (tags.length === 0) return null;
|
||||
const clearAll = () => {
|
||||
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
|
||||
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
|
||||
onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' });
|
||||
onFilterBrandsChange([]);
|
||||
const today = defaultMileageDate();
|
||||
onRangeStartChange(today); onRangeEndChange(today);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
|
||||
<span className="mr-1 text-[10px] font-black text-slate-500">已选条件</span>
|
||||
{tags.map((tag, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-[10px] font-bold text-blue-600">
|
||||
{tag.label}
|
||||
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
export default function BatchMultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
options: string[],
|
||||
selected: string[],
|
||||
onChange: (val: string[]) => void,
|
||||
placeholder: string
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return options;
|
||||
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
|
||||
}, [options, search]);
|
||||
const label = selected.length === 0
|
||||
? placeholder
|
||||
: selected.length === options.length
|
||||
? '全部批次'
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `已选 ${selected.length} 个批次`;
|
||||
|
||||
const toggle = (opt: string) => {
|
||||
if (selectedSet.has(opt)) {
|
||||
onChange(selected.filter(item => item !== opt));
|
||||
} else {
|
||||
onChange([...selected, opt]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
|
||||
onClick={() => setIsOpen(open => !open)}
|
||||
>
|
||||
<span className="block truncate">{label}</span>
|
||||
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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 overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-slate-50">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
|
||||
placeholder="搜索批次"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
onClick={() => onChange(options)}
|
||||
>
|
||||
全选批次
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
不限
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{filtered.map((opt: string) => {
|
||||
const checked = selectedSet.has(opt);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt}
|
||||
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
<span className="truncate">{opt}</span>
|
||||
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
|
||||
<Check size={10} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
|
||||
无匹配项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { MonitoringFilters } from '../../types';
|
||||
import { defaultMileageDate, type RangePreset } from '../date-range';
|
||||
import BatchMultiSelect from './BatchMultiSelect';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
|
||||
type MileageRange = { min: string; max: string };
|
||||
|
||||
export default function FilterPanel({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
onRangeStartChange,
|
||||
onRangeEndChange,
|
||||
onApplyRangePreset,
|
||||
departments,
|
||||
filterDept,
|
||||
onFilterDeptChange,
|
||||
filterOptions,
|
||||
filterCustomer,
|
||||
onFilterCustomerChange,
|
||||
filterBrands,
|
||||
onFilterBrandsChange,
|
||||
filterProject,
|
||||
onFilterProjectChange,
|
||||
filterRentStatus,
|
||||
onFilterRentStatusChange,
|
||||
filterEntity,
|
||||
onFilterEntityChange,
|
||||
filterPlatePrefix,
|
||||
onFilterPlatePrefixChange,
|
||||
filterMileageRange,
|
||||
onFilterMileageRangeChange,
|
||||
onSearchTermChange,
|
||||
onFilterPlatesChange,
|
||||
onFilterTargetNamesChange,
|
||||
onFilterRegionChange,
|
||||
onAppliedMileageRangeChange,
|
||||
onFilterOpenChange,
|
||||
}: {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
onRangeStartChange: (value: string) => void;
|
||||
onRangeEndChange: (value: string) => void;
|
||||
onApplyRangePreset: (preset: RangePreset) => void;
|
||||
departments: string[];
|
||||
filterDept: string;
|
||||
onFilterDeptChange: (value: string) => void;
|
||||
filterOptions: MonitoringFilters;
|
||||
filterCustomer: string;
|
||||
onFilterCustomerChange: (value: string) => void;
|
||||
filterBrands: string[];
|
||||
onFilterBrandsChange: (value: string[]) => void;
|
||||
filterProject: string;
|
||||
onFilterProjectChange: (value: string) => void;
|
||||
filterRentStatus: string;
|
||||
onFilterRentStatusChange: (value: string) => void;
|
||||
filterEntity: string;
|
||||
onFilterEntityChange: (value: string) => void;
|
||||
filterPlatePrefix: string;
|
||||
onFilterPlatePrefixChange: (value: string) => void;
|
||||
filterMileageRange: MileageRange;
|
||||
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
||||
onSearchTermChange: (value: string) => void;
|
||||
onFilterPlatesChange: (value: string[]) => void;
|
||||
onFilterTargetNamesChange: (value: string[]) => void;
|
||||
onFilterRegionChange: (value: string) => void;
|
||||
onAppliedMileageRangeChange: (value: MileageRange) => void;
|
||||
onFilterOpenChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
|
||||
{/* Date range */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询时间区间</label>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={rangeStart}
|
||||
onChange={(e) => onRangeStartChange(e.target.value)}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-300">至</span>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={rangeEnd}
|
||||
onChange={(e) => onRangeEndChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last7', '近7天'],
|
||||
['last15', '近15天'],
|
||||
] as Array<[RangePreset, string]>).map(([preset, label]) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => onApplyRangePreset(preset)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Department */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按部门</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterDept}
|
||||
onChange={(e) => onFilterDeptChange(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Customer */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按客户</label>
|
||||
<SearchableSelect
|
||||
options={filterOptions.customers}
|
||||
value={filterCustomer}
|
||||
onChange={onFilterCustomerChange}
|
||||
placeholder="搜索客户"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand multi-select */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按品牌</label>
|
||||
<BatchMultiSelect
|
||||
options={filterOptions.brands}
|
||||
selected={filterBrands}
|
||||
onChange={onFilterBrandsChange}
|
||||
placeholder="选择品牌"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Project */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">项目</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterProject}
|
||||
onChange={(e) => onFilterProjectChange(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rent Status */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">租赁状态</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterRentStatus}
|
||||
onChange={(e) => onFilterRentStatusChange(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Entity */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">主体查询</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterEntity}
|
||||
onChange={(e) => onFilterEntityChange(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.entities.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plate Prefix */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">车牌区域</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterPlatePrefix}
|
||||
onChange={(e) => onFilterPlatePrefixChange(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.platePrefixes.map(p => <option key={p.prefix} value={p.prefix}>{p.prefix}({p.count})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mileage Range */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">里程 ≥ (KM)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="不限"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterMileageRange.min}
|
||||
onChange={(e) => onFilterMileageRangeChange(prev => ({ ...prev, min: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">里程 ≤ (KM)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="不限"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterMileageRange.max}
|
||||
onChange={(e) => onFilterMileageRangeChange(prev => ({ ...prev, max: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
onSearchTermChange('');
|
||||
onFilterDeptChange('All');
|
||||
onFilterPlatesChange([]);
|
||||
onFilterCustomerChange('All');
|
||||
onFilterProjectChange('All');
|
||||
onFilterEntityChange('All');
|
||||
onFilterPlatePrefixChange('All');
|
||||
onFilterTargetNamesChange([]);
|
||||
onFilterRegionChange('All');
|
||||
onFilterMileageRangeChange({ min: '', max: '' });
|
||||
onAppliedMileageRangeChange({ min: '', max: '' });
|
||||
onFilterBrandsChange([]);
|
||||
const today = defaultMileageDate();
|
||||
onRangeStartChange(today);
|
||||
onRangeEndChange(today);
|
||||
}}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
重置所有
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAppliedMileageRangeChange({ ...filterMileageRange });
|
||||
onFilterOpenChange(false);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
|
||||
>
|
||||
完成筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../../components/Blur';
|
||||
import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types';
|
||||
import { vehicleStatisticTime } from '../oneos-time';
|
||||
import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display';
|
||||
|
||||
type SortBy = 'today' | 'total' | 'statisticTime';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export default function FullscreenMonitoring({
|
||||
forceLandscape,
|
||||
fullscreenStats,
|
||||
sortBy,
|
||||
sourcePriority,
|
||||
onRefresh,
|
||||
fullscreenLoading,
|
||||
onToggleFullscreen,
|
||||
filterTargetNames,
|
||||
onFilterTargetNamesChange,
|
||||
filterOptions,
|
||||
filterPlates,
|
||||
filterCustomer,
|
||||
onFilterCustomerChange,
|
||||
filterBrands,
|
||||
onFilterBrandsChange,
|
||||
filterRentStatus,
|
||||
onFilterRentStatusChange,
|
||||
filterDept,
|
||||
onFilterDeptChange,
|
||||
departments,
|
||||
sortOrder,
|
||||
onSortByChange,
|
||||
onSortOrderChange,
|
||||
fullscreenVehicles,
|
||||
isHighMileageAlert,
|
||||
relativeNow,
|
||||
}: {
|
||||
forceLandscape: boolean;
|
||||
fullscreenStats: MonitoringStats;
|
||||
sortBy: SortBy;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
onRefresh: () => void;
|
||||
fullscreenLoading: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
filterTargetNames: string[];
|
||||
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
|
||||
filterOptions: MonitoringFilters;
|
||||
filterPlates: string[];
|
||||
filterCustomer: string;
|
||||
onFilterCustomerChange: (value: string) => void;
|
||||
filterBrands: string[];
|
||||
onFilterBrandsChange: (value: string[]) => void;
|
||||
filterRentStatus: string;
|
||||
onFilterRentStatusChange: (value: string) => void;
|
||||
filterDept: string;
|
||||
onFilterDeptChange: (value: string) => void;
|
||||
departments: string[];
|
||||
sortOrder: SortOrder;
|
||||
onSortByChange: Dispatch<SetStateAction<SortBy>>;
|
||||
onSortOrderChange: Dispatch<SetStateAction<SortOrder>>;
|
||||
fullscreenVehicles: MonitoringVehicle[];
|
||||
isHighMileageAlert: (vehicle: MonitoringVehicle) => boolean;
|
||||
relativeNow: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||
style={
|
||||
forceLandscape
|
||||
? {
|
||||
// 小程序 webview 无法真横屏,强制 CSS 旋转 90 度模拟横屏
|
||||
top: 0,
|
||||
left: '100vw',
|
||||
width: '100vh',
|
||||
height: '100vw',
|
||||
transform: 'rotate(90deg)',
|
||||
transformOrigin: 'top left',
|
||||
}
|
||||
: { top: 0, left: 0, right: 0, bottom: 0 }
|
||||
}
|
||||
>
|
||||
{/* Top bar: compact inline KPI */}
|
||||
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
|
||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">
|
||||
来源 <span className="text-white font-black">
|
||||
{sourcePriority.map(group => MILEAGE_SOURCE_META[group].label).join(' > ')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button onClick={onToggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch selector + legend */}
|
||||
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => onFilterTargetNamesChange([])}
|
||||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
>全部</button>
|
||||
{filterOptions.targetNames.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => onFilterTargetNamesChange(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])}
|
||||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[8px] text-slate-500 flex-shrink-0 ml-2">
|
||||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>在线</span>
|
||||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-slate-500 inline-block"></span>离线</span>
|
||||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"></span>未对接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{fullscreenLoading && (
|
||||
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
|
||||
<RotateCcw size={14} className="animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<tr className="border-b border-slate-800/60">
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center">状态</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>车牌号</span>
|
||||
<span className="text-[9px] text-slate-500 font-normal">
|
||||
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>客户</span>
|
||||
<input
|
||||
type="text"
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-20"
|
||||
placeholder={filterCustomer === 'All' ? '全部' : filterCustomer}
|
||||
defaultValue=""
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
onFilterCustomerChange(val || 'All');
|
||||
e.target.value = '';
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
onFilterCustomerChange(val || 'All');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>品牌</span>
|
||||
<input
|
||||
type="text"
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-16"
|
||||
placeholder={filterBrands.length === 0 ? '全部' : `已选${filterBrands.length}`}
|
||||
defaultValue=""
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val) {
|
||||
onFilterBrandsChange(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) {
|
||||
onFilterBrandsChange(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
|
||||
}
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>租赁状态</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterRentStatus}
|
||||
onChange={(e) => onFilterRentStatusChange(e.target.value)}
|
||||
>
|
||||
<option value="All">全部状态</option>
|
||||
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>部门</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterDept}
|
||||
onChange={(e) => onFilterDeptChange(e.target.value)}
|
||||
>
|
||||
<option value="All">全部部门</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={() => {
|
||||
if (sortBy === 'today') {
|
||||
onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc');
|
||||
} else {
|
||||
onSortByChange('today');
|
||||
onSortOrderChange('desc');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>区间里程</span>
|
||||
{sortBy === 'today' && (
|
||||
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={() => {
|
||||
if (sortBy === 'total') {
|
||||
onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc');
|
||||
} else {
|
||||
onSortByChange('total');
|
||||
onSortOrderChange('desc');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>累计里程</span>
|
||||
{sortBy === 'total' && (
|
||||
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{fullscreenVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
||||
<span
|
||||
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||
title={sourceDisplay.title}
|
||||
>
|
||||
{sourceDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
{v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
import { Bar, BarChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
|
||||
import type { MonitoringStats } from '../../types';
|
||||
|
||||
export default function MonitoringSummary({
|
||||
pageLoading,
|
||||
sortBy,
|
||||
isRangeMode,
|
||||
stats,
|
||||
rangeLabel,
|
||||
rangeDailyTotals,
|
||||
total,
|
||||
}: {
|
||||
pageLoading: boolean;
|
||||
sortBy: 'today' | 'total' | 'statisticTime';
|
||||
isRangeMode: boolean;
|
||||
stats: MonitoringStats;
|
||||
rangeLabel: string;
|
||||
rangeDailyTotals: { date: string; totalKm: number }[];
|
||||
total: number;
|
||||
}) {
|
||||
const averageDailyKm = rangeDailyTotals.length > 0
|
||||
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
||||
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
|
||||
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
|
||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div>
|
||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||
</div>
|
||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
|
||||
<div className="text-[7px] text-slate-400">km/台</div>
|
||||
</div>
|
||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
|
||||
<div className="text-[7px] text-slate-400">台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
|
||||
{pageLoading ? (
|
||||
<div className="h-[74px] bg-slate-50 animate-pulse" />
|
||||
) : isRangeMode ? (
|
||||
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
|
||||
<CalendarDays size={12} className="text-blue-500" />
|
||||
区间走势
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
|
||||
</div>
|
||||
<div className="h-[58px] min-w-0">
|
||||
{rangeDailyTotals.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">暂无趋势</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={58} minWidth={0}>
|
||||
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
||||
<XAxis dataKey="date" hide />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
|
||||
labelFormatter={(label) => `日期 ${String(label)}`}
|
||||
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
|
||||
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
|
||||
/>
|
||||
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
|
||||
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[9px] font-black text-slate-400">日均</div>
|
||||
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div>
|
||||
<div className="text-[9px] font-bold text-slate-400">km</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-[64px] grid-cols-[minmax(0,1fr)_86px_86px] items-stretch gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_110px_110px]">
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
|
||||
<CalendarDays size={12} className="text-blue-500" />
|
||||
单日概览
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · 单位 km</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</span>
|
||||
<span className="text-[9px] font-bold text-slate-300">{total} 条</span>
|
||||
</div>
|
||||
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
|
||||
<span>车牌 / 数据状态</span>
|
||||
<span>客户 / 归属</span>
|
||||
<span className="text-right">{isRangeMode ? '区间里程' : '当日里程'}</span>
|
||||
<span className="text-right">累计里程</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ArrowDown, ArrowUp, Download, Filter, Maximize2, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import PlateMultiSelect from '../../PlateMultiSelect';
|
||||
import type { MileageSourceGroup, MonitoringFilters } from '../../types';
|
||||
import { getRangePreset, type RangePreset } from '../date-range';
|
||||
import BatchMultiSelect from './BatchMultiSelect';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import SourcePriorityControl from './SourcePriorityControl';
|
||||
|
||||
type SortBy = 'today' | 'total' | 'statisticTime';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export default function MonitoringToolbar({
|
||||
onToggleFullscreen,
|
||||
onDownload,
|
||||
exporting,
|
||||
lastRefreshedAt,
|
||||
onManualRefresh,
|
||||
manualRefreshing,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
sourcePriority,
|
||||
onSourcePriorityChange,
|
||||
filterOptions,
|
||||
filterTargetNames,
|
||||
onFilterTargetNamesChange,
|
||||
filterRegion,
|
||||
onFilterRegionChange,
|
||||
plateNumbers,
|
||||
filterPlates,
|
||||
onFilterPlatesChange,
|
||||
isFilterOpen,
|
||||
onFilterOpenChange,
|
||||
searchTerm,
|
||||
filterDept,
|
||||
filterCustomer,
|
||||
filterRentStatus,
|
||||
filterProject,
|
||||
filterBrands,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
onApplyRangePreset,
|
||||
rangeLabel,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
onDownload: () => void;
|
||||
exporting: boolean;
|
||||
lastRefreshedAt: Date | null;
|
||||
onManualRefresh: () => void;
|
||||
manualRefreshing: boolean;
|
||||
sortBy: SortBy;
|
||||
onSortByChange: Dispatch<SetStateAction<SortBy>>;
|
||||
sortOrder: SortOrder;
|
||||
onSortOrderChange: Dispatch<SetStateAction<SortOrder>>;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
onSourcePriorityChange: (value: MileageSourceGroup[]) => void;
|
||||
filterOptions: MonitoringFilters;
|
||||
filterTargetNames: string[];
|
||||
onFilterTargetNamesChange: (value: string[]) => void;
|
||||
filterRegion: string;
|
||||
onFilterRegionChange: (value: string) => void;
|
||||
plateNumbers: string[];
|
||||
filterPlates: string[];
|
||||
onFilterPlatesChange: (value: string[]) => void;
|
||||
isFilterOpen: boolean;
|
||||
onFilterOpenChange: (value: boolean) => void;
|
||||
searchTerm: string;
|
||||
filterDept: string;
|
||||
filterCustomer: string;
|
||||
filterRentStatus: string;
|
||||
filterProject: string;
|
||||
filterBrands: string[];
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
onApplyRangePreset: (preset: RangePreset) => void;
|
||||
rangeLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="h-8 w-1 flex-shrink-0 rounded-full bg-blue-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
|
||||
<button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
|
||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-tight text-slate-400">数据监控 • OneOS 实时查询</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManualRefresh}
|
||||
disabled={manualRefreshing}
|
||||
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
|
||||
title="局部刷新当前筛选数据"
|
||||
>
|
||||
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
||||
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
||||
<button onClick={() => onSortByChange('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>区间</button>
|
||||
<button onClick={() => onSortByChange('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>累计</button>
|
||||
<button onClick={() => onSortByChange('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>统计时间</button>
|
||||
<button
|
||||
onClick={() => onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
|
||||
title={sortOrder === 'desc' ? '当前倒序,点击切换正序' : '当前正序,点击切换倒序'}
|
||||
>
|
||||
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SourcePriorityControl priority={sourcePriority} onChange={onSourcePriorityChange} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid flex-1 grid-cols-3 gap-1.5">
|
||||
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={onFilterTargetNamesChange} placeholder="批次型号" />
|
||||
<SearchableSelect options={filterOptions.regions} value={filterRegion} onChange={onFilterRegionChange} placeholder="运营区域" />
|
||||
<PlateMultiSelect allPlates={plateNumbers} selected={filterPlates} onChange={onFilterPlatesChange} placeholder="按车牌" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFilterOpenChange(!isFilterOpen)}
|
||||
className={`flex-shrink-0 rounded-lg border p-1.5 transition-all ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-transparent bg-slate-50 text-slate-400'}`}
|
||||
title="高级筛选"
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last15', '近15天'],
|
||||
] as Array<[RangePreset, string]>).map(([preset, label]) => {
|
||||
const range = getRangePreset(preset);
|
||||
const active = rangeStart === range.start && rangeEnd === range.end;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => onApplyRangePreset(preset)}
|
||||
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${active ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export default function SearchableSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder
|
||||
}: {
|
||||
options: string[],
|
||||
value: string,
|
||||
onChange: (val: string) => void,
|
||||
placeholder: string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return options;
|
||||
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
|
||||
}, [options, search]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
|
||||
placeholder={value === 'All' ? placeholder : value === '__EMPTY__' ? '无值' : value}
|
||||
value={search}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Delay to allow clicking an option
|
||||
setTimeout(() => setIsOpen(false), 200);
|
||||
}}
|
||||
/>
|
||||
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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 max-h-40 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-[10px] font-bold text-blue-600 hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => {
|
||||
onChange('All');
|
||||
setSearch('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
无限制
|
||||
</div>
|
||||
{filtered.map((opt: string) => (
|
||||
<div
|
||||
key={opt}
|
||||
className="px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50"
|
||||
onClick={() => {
|
||||
onChange(opt);
|
||||
setSearch('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt === '__EMPTY__' ? '无值' : opt}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
|
||||
无匹配项
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ArrowLeftRight, Power } from 'lucide-react';
|
||||
import type { MileageSourceGroup } from '../../types';
|
||||
import {
|
||||
ALL_MILEAGE_SOURCE_GROUPS,
|
||||
MILEAGE_SOURCE_META,
|
||||
} from '../source-display';
|
||||
|
||||
export default function SourcePriorityControl({
|
||||
priority,
|
||||
onChange,
|
||||
}: {
|
||||
priority: MileageSourceGroup[];
|
||||
onChange: (next: MileageSourceGroup[]) => void;
|
||||
}) {
|
||||
const ordered = [
|
||||
...priority,
|
||||
...ALL_MILEAGE_SOURCE_GROUPS.filter(group => !priority.includes(group)),
|
||||
];
|
||||
const toggle = (group: MileageSourceGroup) => {
|
||||
if (priority.includes(group)) {
|
||||
if (priority.length === 1) return;
|
||||
onChange(priority.filter(item => item !== group));
|
||||
return;
|
||||
}
|
||||
onChange([...priority, group]);
|
||||
};
|
||||
const swap = () => {
|
||||
if (priority.length === 2) onChange([priority[1], priority[0]]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||
<span className="shrink-0 text-[9px] font-black text-slate-400">数据来源</span>
|
||||
{ordered.map(group => {
|
||||
const enabledIndex = priority.indexOf(group);
|
||||
const enabled = enabledIndex >= 0;
|
||||
const meta = MILEAGE_SOURCE_META[group];
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
onClick={() => toggle(group)}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-1 transition-all ${
|
||||
enabled
|
||||
? 'border-blue-100 bg-blue-50 text-blue-700'
|
||||
: 'border-slate-100 bg-slate-50 text-slate-400'
|
||||
}`}
|
||||
title={enabled && priority.length === 1 ? '至少保留一种数据来源' : enabled ? '点击停用' : '点击启用'}
|
||||
>
|
||||
<span className={`flex h-4 w-4 items-center justify-center rounded text-[8px] font-black ${
|
||||
enabled ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{enabled ? enabledIndex + 1 : <Power size={9} />}
|
||||
</span>
|
||||
<span className="text-left leading-tight">
|
||||
<span className="block text-[9px] font-black">{meta.label}</span>
|
||||
<span className="block min-h-[9px] text-[7px] font-bold opacity-60">
|
||||
{enabled ? meta.protocol : '已停用'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={swap}
|
||||
disabled={priority.length !== 2}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-white text-slate-400 transition-all hover:border-blue-100 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title="切换数据来源优先级"
|
||||
>
|
||||
<ArrowLeftRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../../components/Blur';
|
||||
import type { MonitoringVehicle } from '../../types';
|
||||
import { vehicleStatisticTime } from '../oneos-time';
|
||||
import { vehicleSourceDisplay } from '../source-display';
|
||||
|
||||
export default function VehicleList({
|
||||
pageLoading,
|
||||
vehicles,
|
||||
isHighMileageAlert,
|
||||
relativeNow,
|
||||
isRangeMode,
|
||||
onVehicleClick,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
total,
|
||||
sentinelRef,
|
||||
}: {
|
||||
pageLoading: boolean;
|
||||
vehicles: MonitoringVehicle[];
|
||||
isHighMileageAlert: (vehicle: MonitoringVehicle) => boolean;
|
||||
relativeNow: number;
|
||||
isRangeMode: boolean;
|
||||
onVehicleClick: (vehicle: MonitoringVehicle) => void;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
sentinelRef: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{pageLoading && (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-100"></div>
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="h-3 bg-slate-100 rounded w-24"></div>
|
||||
<div className="h-2 bg-slate-50 rounded w-36"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-right">
|
||||
<div className="h-4 bg-slate-100 rounded w-16 ml-auto"></div>
|
||||
<div className="h-2 bg-slate-50 rounded w-20 ml-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{vehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key={v.plate}
|
||||
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
|
||||
onClick={() => onVehicleClick(v)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{v.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||
title={sourceDisplay.title}
|
||||
>
|
||||
{sourceDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:hidden">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden min-w-0 md:block">
|
||||
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
|
||||
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
|
||||
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{Math.max(0, v.dailyKm || 0).toLocaleString()}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
<div className="text-xs font-black tabular-nums text-slate-700">
|
||||
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
{!v.isDataSynced && v.totalKm == null && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||
)}
|
||||
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
|
||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none">总</span>
|
||||
<span className="text-[8px] font-bold text-slate-300">
|
||||
{v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{vehicles.length === 0 && !loadingMore && (
|
||||
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
|
||||
<p className="text-xs font-bold text-slate-300">未找到匹配数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载更多提示 */}
|
||||
{loadingMore && (
|
||||
<div className="py-4 text-center">
|
||||
<span className="text-[10px] font-bold text-slate-400">加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && vehicles.length > 0 && (
|
||||
<div className="py-4 text-center">
|
||||
<span className="text-[10px] font-bold text-slate-300">已加载全部 {total} 条</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 哨兵元素:进入视口时触发加载更多 */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The monitoring day rolls over at 05:00 local time. Before then the dashboard
|
||||
* continues to use the previous calendar day, matching the existing UI rule.
|
||||
*/
|
||||
export function defaultMileageDate(now = new Date()): string {
|
||||
const date = new Date(now);
|
||||
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
|
||||
return formatLocalDate(date);
|
||||
}
|
||||
|
||||
export function normalizeRangeLabel(start: string, end: string): string {
|
||||
if (!start && !end) return '最新数据';
|
||||
if (start && end && start === end) return start;
|
||||
return `${start || end} 至 ${end || start}`;
|
||||
}
|
||||
|
||||
export function getRangePreset(
|
||||
preset: RangePreset,
|
||||
now = new Date(),
|
||||
): { start: string; end: string } {
|
||||
const end = new Date(`${defaultMileageDate(now)}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
|
||||
if (preset === 'yesterday') {
|
||||
start.setDate(start.getDate() - 1);
|
||||
end.setDate(end.getDate() - 1);
|
||||
} else if (preset === 'thisWeek') {
|
||||
const day = start.getDay() || 7;
|
||||
start.setDate(start.getDate() - day + 1);
|
||||
} else if (preset === 'thisMonth') {
|
||||
start.setDate(1);
|
||||
} else if (preset === 'last7') {
|
||||
start.setDate(start.getDate() - 6);
|
||||
} else if (preset === 'last15') {
|
||||
start.setDate(start.getDate() - 14);
|
||||
}
|
||||
|
||||
return { start: formatLocalDate(start), end: formatLocalDate(end) };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { MonitoringVehicle } from '../types';
|
||||
import { defaultMileageDate, getRangePreset, normalizeRangeLabel } from './date-range';
|
||||
import { formatAbsoluteTime, parseOneOsTime, vehicleStatisticTime } from './oneos-time';
|
||||
import {
|
||||
buildExportMonitoringQuery,
|
||||
buildFullscreenMonitoringQuery,
|
||||
buildInitialMonitoringQuery,
|
||||
buildNextPageMonitoringQuery,
|
||||
type MonitoringQueryModel,
|
||||
} from './query-model';
|
||||
import { vehicleSourceDisplay } from './source-display';
|
||||
|
||||
const baseVehicle: MonitoringVehicle = {
|
||||
plate: '粤A00001',
|
||||
vin: 'TESTVIN',
|
||||
dailyKm: 0,
|
||||
totalKm: 0,
|
||||
source: 'ONEOS',
|
||||
sourceProtocol: null,
|
||||
sourceCategory: null,
|
||||
dataTime: null,
|
||||
calculatedAt: null,
|
||||
updatedAt: null,
|
||||
isOnline: false,
|
||||
isDataSynced: false,
|
||||
customer: null,
|
||||
department: null,
|
||||
manager: null,
|
||||
rentStatus: null,
|
||||
entity: null,
|
||||
project: null,
|
||||
region: null,
|
||||
inventoryLocation: null,
|
||||
brand: null,
|
||||
targetNames: [],
|
||||
};
|
||||
|
||||
const queryModel: MonitoringQueryModel = {
|
||||
sortBy: 'statisticTime',
|
||||
sortOrder: 'asc',
|
||||
searchTerm: '粤A',
|
||||
department: '业务一部',
|
||||
customer: '客户甲',
|
||||
project: '项目甲',
|
||||
entity: '主体甲',
|
||||
rentStatus: '租赁',
|
||||
platePrefix: '粤A',
|
||||
targetNames: ['批次甲'],
|
||||
region: '广州',
|
||||
plates: ['粤A00001', '粤A00002'],
|
||||
mileageRange: { min: '10', max: '500' },
|
||||
rangeStart: '2026-08-01',
|
||||
rangeEnd: '2026-08-12',
|
||||
brands: ['现代'],
|
||||
sourcePriority: ['instrument', 'gps'],
|
||||
};
|
||||
|
||||
test('monitoring date rolls over at 05:00 and presets retain existing ranges', () => {
|
||||
assert.equal(defaultMileageDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
|
||||
assert.equal(defaultMileageDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
|
||||
|
||||
const now = new Date(2026, 7, 13, 12, 0);
|
||||
assert.deepEqual(getRangePreset('today', now), { start: '2026-08-13', end: '2026-08-13' });
|
||||
assert.deepEqual(getRangePreset('yesterday', now), { start: '2026-08-12', end: '2026-08-12' });
|
||||
assert.deepEqual(getRangePreset('thisWeek', now), { start: '2026-08-10', end: '2026-08-13' });
|
||||
assert.deepEqual(getRangePreset('thisMonth', now), { start: '2026-08-01', end: '2026-08-13' });
|
||||
assert.deepEqual(getRangePreset('last7', now), { start: '2026-08-07', end: '2026-08-13' });
|
||||
assert.deepEqual(getRangePreset('last15', now), { start: '2026-07-30', end: '2026-08-13' });
|
||||
assert.equal(normalizeRangeLabel('', ''), '最新数据');
|
||||
assert.equal(normalizeRangeLabel('2026-08-12', '2026-08-12'), '2026-08-12');
|
||||
assert.equal(normalizeRangeLabel('2026-08-01', '2026-08-12'), '2026-08-01 至 2026-08-12');
|
||||
});
|
||||
|
||||
test('OneOS timestamps keep China Standard Time parsing and relative labels', () => {
|
||||
assert.equal(
|
||||
parseOneOsTime('2026-08-13 09:30:05'),
|
||||
Date.parse('2026-08-13T09:30:05+08:00'),
|
||||
);
|
||||
assert.equal(parseOneOsTime('invalid'), null);
|
||||
assert.equal(formatAbsoluteTime('2026-08-13 09:30:05'), '2026/08/13 09:30:05');
|
||||
|
||||
const display = vehicleStatisticTime(
|
||||
{
|
||||
...baseVehicle,
|
||||
dataTime: '2026-08-13 09:30:05',
|
||||
calculatedAt: '2026-08-13 09:30:06',
|
||||
updatedAt: '2026-08-13 09:30:07',
|
||||
isDataSynced: true,
|
||||
},
|
||||
Date.parse('2026-08-13T09:31:05+08:00'),
|
||||
);
|
||||
assert.deepEqual(display, {
|
||||
label: '1分钟前',
|
||||
title: [
|
||||
'数据时间:2026/08/13 09:30:05',
|
||||
'计算时间:2026/08/13 09:30:06',
|
||||
'更新时间:2026/08/13 09:30:07',
|
||||
].join('\n'),
|
||||
color: 'text-emerald-500',
|
||||
});
|
||||
|
||||
assert.deepEqual(vehicleStatisticTime({ ...baseVehicle, isDataSynced: true }, Date.now()), {
|
||||
label: '统计时间待接口',
|
||||
title: 'OneOS 当前未返回车辆级数据时间',
|
||||
color: 'text-amber-500',
|
||||
});
|
||||
});
|
||||
|
||||
test('vehicle source display retains all source and missing-data states', () => {
|
||||
assert.deepEqual(vehicleSourceDisplay({
|
||||
...baseVehicle,
|
||||
sourceCategory: 'INSTRUMENT',
|
||||
sourceProtocol: 'MQTT',
|
||||
}), {
|
||||
label: '仪表数据',
|
||||
title: 'MQTT',
|
||||
className: 'bg-violet-50 text-violet-600',
|
||||
});
|
||||
assert.equal(vehicleSourceDisplay({ ...baseVehicle, sourceCategory: 'GPS' }).title, 'JT808');
|
||||
assert.equal(vehicleSourceDisplay({ ...baseVehicle, sourceCategory: 'MIXED' }).label, '仪表+GPS');
|
||||
assert.equal(vehicleSourceDisplay({ ...baseVehicle, isDataSynced: true }).label, '来源待接口');
|
||||
assert.equal(vehicleSourceDisplay(baseVehicle).label, '无数据');
|
||||
});
|
||||
|
||||
test('four monitoring query builders preserve pagination and fullscreen differences', () => {
|
||||
const initial = buildInitialMonitoringQuery(queryModel, 50);
|
||||
assert.equal(initial.limit, 50);
|
||||
assert.equal(initial.page, 1);
|
||||
assert.equal(initial.project, '项目甲');
|
||||
assert.equal(initial.entity, '主体甲');
|
||||
assert.equal(initial.mileageMin, '10');
|
||||
assert.equal(initial.mileageMax, '500');
|
||||
assert.equal(initial.plate, '粤A00001,粤A00002');
|
||||
|
||||
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
|
||||
assert.deepEqual(nextPage, { ...initial, page: 3 });
|
||||
|
||||
const exported = buildExportMonitoringQuery(queryModel);
|
||||
assert.deepEqual(exported, { ...initial, limit: 9999 });
|
||||
|
||||
const fullscreen = buildFullscreenMonitoringQuery(queryModel);
|
||||
assert.equal(fullscreen.limit, 9999);
|
||||
assert.equal(fullscreen.page, 1);
|
||||
assert.equal(fullscreen.customer, '客户甲');
|
||||
assert.equal('project' in fullscreen, false);
|
||||
assert.equal('entity' in fullscreen, false);
|
||||
assert.equal('mileageMin' in fullscreen, false);
|
||||
assert.equal('mileageMax' in fullscreen, false);
|
||||
});
|
||||
|
||||
test('query model converts empty selections exactly as the existing requests do', () => {
|
||||
const query = buildInitialMonitoringQuery({
|
||||
...queryModel,
|
||||
searchTerm: '',
|
||||
department: 'All',
|
||||
customer: 'All',
|
||||
project: 'All',
|
||||
entity: 'All',
|
||||
rentStatus: 'All',
|
||||
platePrefix: 'All',
|
||||
targetNames: [],
|
||||
region: 'All',
|
||||
plates: [],
|
||||
mileageRange: { min: '', max: '' },
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
brands: [],
|
||||
}, 50);
|
||||
|
||||
assert.equal(query.search, undefined);
|
||||
assert.equal(query.dept, undefined);
|
||||
assert.equal(query.project, undefined);
|
||||
assert.equal(query.plate, undefined);
|
||||
assert.equal(query.mileageMin, undefined);
|
||||
assert.equal(query.startDate, undefined);
|
||||
assert.equal(query.brands, undefined);
|
||||
assert.deepEqual(query.sourcePriority, ['instrument', 'gps']);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { MonitoringVehicle } from '../types';
|
||||
|
||||
export interface StatisticTimeDisplay {
|
||||
label: string;
|
||||
title: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** OneOS timestamps without an offset are defined as China Standard Time. */
|
||||
export function parseOneOsTime(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
let normalized = value.trim().replace(' ', 'T');
|
||||
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized += '+08:00';
|
||||
const timestamp = Date.parse(normalized);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
export function formatAbsoluteTime(value: string | null | undefined): string | null {
|
||||
const timestamp = parseOneOsTime(value);
|
||||
if (timestamp === null) return null;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
export function vehicleStatisticTime(
|
||||
vehicle: MonitoringVehicle,
|
||||
nowMs: number,
|
||||
): StatisticTimeDisplay {
|
||||
const primaryTime = vehicle.dataTime || vehicle.updatedAt || vehicle.calculatedAt;
|
||||
const timestamp = parseOneOsTime(primaryTime);
|
||||
if (timestamp === null) {
|
||||
return {
|
||||
label: vehicle.isDataSynced ? '统计时间待接口' : '无有效数据时间',
|
||||
title: 'OneOS 当前未返回车辆级数据时间',
|
||||
color: vehicle.isDataSynced ? 'text-amber-500' : 'text-slate-500',
|
||||
};
|
||||
}
|
||||
|
||||
const deltaSeconds = Math.floor((nowMs - timestamp) / 1000);
|
||||
let label: string;
|
||||
if (deltaSeconds < -60) label = '时间异常';
|
||||
else if (deltaSeconds < 5) label = '刚刚';
|
||||
else if (deltaSeconds < 60) label = `${deltaSeconds}秒前`;
|
||||
else if (deltaSeconds < 3600) label = `${Math.floor(deltaSeconds / 60)}分钟前`;
|
||||
else if (deltaSeconds < 86400) label = `${Math.floor(deltaSeconds / 3600)}小时前`;
|
||||
else label = `${Math.floor(deltaSeconds / 86400)}天前`;
|
||||
|
||||
const absoluteDataTime = formatAbsoluteTime(vehicle.dataTime);
|
||||
const absoluteCalculatedAt = formatAbsoluteTime(vehicle.calculatedAt);
|
||||
const absoluteUpdatedAt = formatAbsoluteTime(vehicle.updatedAt);
|
||||
const title = [
|
||||
absoluteDataTime ? `数据时间:${absoluteDataTime}` : null,
|
||||
absoluteCalculatedAt ? `计算时间:${absoluteCalculatedAt}` : null,
|
||||
absoluteUpdatedAt ? `更新时间:${absoluteUpdatedAt}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
const ageSeconds = Math.max(0, deltaSeconds);
|
||||
const color = ageSeconds <= 300
|
||||
? 'text-emerald-500'
|
||||
: ageSeconds <= 3600
|
||||
? 'text-blue-500'
|
||||
: ageSeconds <= 86400
|
||||
? 'text-amber-500'
|
||||
: 'text-rose-500';
|
||||
|
||||
return { label, title, color };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { fetchMonitoring } from '../api';
|
||||
import type { MileageSourceGroup } from '../types';
|
||||
|
||||
export type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
|
||||
|
||||
export interface MonitoringQueryModel {
|
||||
sortBy: 'today' | 'total' | 'statisticTime';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
searchTerm: string;
|
||||
department: string;
|
||||
customer: string;
|
||||
project: string;
|
||||
entity: string;
|
||||
rentStatus: string;
|
||||
platePrefix: string;
|
||||
targetNames: string[];
|
||||
region: string;
|
||||
plates: string[];
|
||||
mileageRange: { min: string; max: string };
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
brands: string[];
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
function optionalValue(value: string): string | undefined {
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function selectedValue(value: string): string | undefined {
|
||||
return value !== 'All' ? value : undefined;
|
||||
}
|
||||
|
||||
function buildSharedQuery(
|
||||
model: MonitoringQueryModel,
|
||||
limit: number,
|
||||
page: number,
|
||||
): MonitoringRequestParams {
|
||||
return {
|
||||
sortBy: model.sortBy,
|
||||
sortOrder: model.sortOrder,
|
||||
limit,
|
||||
page,
|
||||
search: optionalValue(model.searchTerm),
|
||||
dept: selectedValue(model.department),
|
||||
customer: selectedValue(model.customer),
|
||||
rentStatus: selectedValue(model.rentStatus),
|
||||
platePrefix: selectedValue(model.platePrefix),
|
||||
targetNames: model.targetNames.length > 0 ? model.targetNames : undefined,
|
||||
region: selectedValue(model.region),
|
||||
plate: model.plates.length > 0 ? model.plates.join(',') : undefined,
|
||||
startDate: optionalValue(model.rangeStart),
|
||||
endDate: optionalValue(model.rangeEnd),
|
||||
brands: model.brands.length > 0 ? model.brands : undefined,
|
||||
sourcePriority: model.sourcePriority,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPagedQuery(
|
||||
model: MonitoringQueryModel,
|
||||
limit: number,
|
||||
page: number,
|
||||
): MonitoringRequestParams {
|
||||
return {
|
||||
...buildSharedQuery(model, limit, page),
|
||||
project: selectedValue(model.project),
|
||||
entity: selectedValue(model.entity),
|
||||
mileageMin: optionalValue(model.mileageRange.min),
|
||||
mileageMax: optionalValue(model.mileageRange.max),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInitialMonitoringQuery(
|
||||
model: MonitoringQueryModel,
|
||||
pageSize: number,
|
||||
): MonitoringRequestParams {
|
||||
return buildPagedQuery(model, pageSize, 1);
|
||||
}
|
||||
|
||||
export function buildNextPageMonitoringQuery(
|
||||
model: MonitoringQueryModel,
|
||||
pageSize: number,
|
||||
page: number,
|
||||
): MonitoringRequestParams {
|
||||
return buildPagedQuery(model, pageSize, page);
|
||||
}
|
||||
|
||||
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
||||
return buildPagedQuery(model, 9999, 1);
|
||||
}
|
||||
|
||||
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
||||
// Characterization boundary: fullscreen historically omits these four filters.
|
||||
// Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved.
|
||||
return buildSharedQuery(model, 9999, 1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MileageSourceGroup, MonitoringVehicle } from '../types';
|
||||
|
||||
export const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
|
||||
|
||||
export const MILEAGE_SOURCE_META = {
|
||||
instrument: { label: '仪表数据', protocol: null },
|
||||
gps: { label: 'GPS数据', protocol: 'JT808' },
|
||||
} as const;
|
||||
|
||||
export interface VehicleSourceDisplay {
|
||||
label: string;
|
||||
title: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export function vehicleSourceDisplay(vehicle: MonitoringVehicle): VehicleSourceDisplay {
|
||||
if (vehicle.sourceCategory === 'INSTRUMENT') {
|
||||
return {
|
||||
label: '仪表数据',
|
||||
title: vehicle.sourceProtocol || 'GB32960 / MQTT',
|
||||
className: 'bg-violet-50 text-violet-600',
|
||||
};
|
||||
}
|
||||
if (vehicle.sourceCategory === 'GPS') {
|
||||
return {
|
||||
label: 'GPS数据',
|
||||
title: vehicle.sourceProtocol || 'JT808',
|
||||
className: 'bg-emerald-50 text-emerald-600',
|
||||
};
|
||||
}
|
||||
if (vehicle.sourceCategory === 'MIXED') {
|
||||
return {
|
||||
label: '仪表+GPS',
|
||||
title: '所选区间内包含仪表数据和 GPS 数据',
|
||||
className: 'bg-blue-50 text-blue-600',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: vehicle.isDataSynced ? '来源待接口' : '无数据',
|
||||
title: vehicle.isDataSynced
|
||||
? 'OneOS 当前响应未返回 sourceProtocol'
|
||||
: '所选来源没有有效数据',
|
||||
className: vehicle.isDataSynced
|
||||
? 'bg-amber-50 text-amber-600'
|
||||
: 'bg-slate-100 text-slate-400',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { TargetVehicle } from '../types';
|
||||
import {
|
||||
filterAndSortTargetVehicles,
|
||||
fmtKm,
|
||||
totalTodayMileage,
|
||||
type VehicleSort,
|
||||
} from './model';
|
||||
|
||||
interface AllVehiclesPanelProps {
|
||||
targetId: number | null;
|
||||
targetName: string;
|
||||
vehicles: TargetVehicle[];
|
||||
date: string;
|
||||
loading: boolean;
|
||||
onDateChange: (date: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AllVehiclesPanel({
|
||||
targetId,
|
||||
targetName,
|
||||
vehicles,
|
||||
date,
|
||||
loading,
|
||||
onDateChange,
|
||||
onClose,
|
||||
}: AllVehiclesPanelProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<VehicleSort>('desc');
|
||||
const filteredVehicles = filterAndSortTargetVehicles(vehicles, search, sort);
|
||||
const totalKm = totalTodayMileage(filteredVehicles);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{targetId !== null && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">{targetName}</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setSearch('');
|
||||
}}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索车牌号..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(event) => onDateChange(event.target.value)}
|
||||
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
{loading ? '加载中...' : `${filteredVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSort(previous => previous === 'desc' ? 'asc' : 'desc')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
|
||||
>
|
||||
<ArrowUpDown size={12} />
|
||||
{sort === 'desc' ? '降序' : '升序'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||
{filteredVehicles.map(vehicle => (
|
||||
<div key={vehicle.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${vehicle.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{vehicle.plateNumber}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{vehicle.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{vehicle.rentStatus || ''}{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate">{vehicle.customer || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2">
|
||||
<div className="text-sm font-black text-blue-600">{vehicle.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
||||
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(vehicle.totalMileage || 0)} km</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
|
||||
>
|
||||
关闭明细
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Minimize2, RotateCcw } from 'lucide-react';
|
||||
import type { TargetSummary } from '../types';
|
||||
import { deriveTargetTableRow, fmtKm, summarizeTargets } from './model';
|
||||
|
||||
interface FullscreenTargetTableProps {
|
||||
isOpen: boolean;
|
||||
targets: TargetSummary[];
|
||||
assessmentYearMap: Record<number, number>;
|
||||
onRefresh: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenTargetTable({
|
||||
isOpen,
|
||||
targets,
|
||||
assessmentYearMap,
|
||||
onRefresh,
|
||||
onClose,
|
||||
}: FullscreenTargetTableProps) {
|
||||
const totals = summarizeTargets(targets, assessmentYearMap);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
|
||||
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(totals.today)}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(totals.cumulative)}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{totals.vehicleCount}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{totals.averageCompletion.toFixed(1)}</span> <span className="text-blue-400">%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<tr className="border-b border-slate-800/60">
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{targets.map((target, index) => {
|
||||
const row = deriveTargetTableRow(target, assessmentYearMap[target.id]);
|
||||
return (
|
||||
<tr key={index} className="hover:bg-slate-800/20 transition-colors">
|
||||
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
||||
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
||||
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{row.assessment?.label || '当前年度'}</div>
|
||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((period, periodIndex) => <span key={periodIndex} className="block">{period}</span>)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${row.completion >= 90 ? 'bg-emerald-500' : row.completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(row.completion, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-white w-10 text-right">{row.completion.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||
<span>{fmtKm(row.completed)}</span>
|
||||
<span>/ {fmtKm(row.goal)} km</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(row.taskPerVehicle)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{row.qualified}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{row.halfQualified}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(row.goal)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(row.completed)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(row.remainingMileage)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{row.days}</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||
{row.assessment && row.days === 0 ? '考核已到期' : `${fmtKm(row.daily)} km`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { motion } from 'motion/react';
|
||||
import type { TargetSummary } from '../types';
|
||||
import type { SelectedTargetMetrics } from './model';
|
||||
import { fmtKm, fmtPercent, shortTargetName } from './model';
|
||||
|
||||
interface StatisticsOverviewProps {
|
||||
targets: TargetSummary[];
|
||||
selectedTargetId: number | null;
|
||||
selectedTarget: TargetSummary | undefined;
|
||||
metrics: SelectedTargetMetrics;
|
||||
onSelectTarget: (targetId: number) => void;
|
||||
}
|
||||
|
||||
export default function StatisticsOverview({
|
||||
targets,
|
||||
selectedTargetId,
|
||||
selectedTarget,
|
||||
metrics,
|
||||
onSelectTarget,
|
||||
}: StatisticsOverviewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
||||
{targets.map(target => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => onSelectTarget(target.id)}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.targetName)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTarget && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="grid grid-cols-2 gap-2 md:grid-cols-4"
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
|
||||
<div className={`mt-1 text-lg font-black ${metrics.pressureLevel === '健康' ? 'text-emerald-600' : metrics.pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
|
||||
{metrics.pressureLevel}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(metrics.completion)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, metrics.remaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {metrics.daysLeft} 天</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
|
||||
<div className="mt-1 text-lg font-black text-blue-600">
|
||||
{metrics.daysLeft > 0 ? (
|
||||
<>
|
||||
{fmtKm(metrics.dailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
|
||||
</>
|
||||
) : '已到期'}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
||||
<div className={`mt-1 text-lg font-black ${metrics.trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{metrics.trendDelta >= 0 ? '+' : ''}{fmtKm(metrics.trendDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(metrics.qualifiedRate)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronDown, Maximize2, Truck } from 'lucide-react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { TargetSummary, TargetVehicle } from '../types';
|
||||
import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model';
|
||||
|
||||
interface TargetDetailPanelProps {
|
||||
target: TargetSummary | undefined;
|
||||
expandedTargetId: number | null;
|
||||
selectedYear: number | undefined;
|
||||
vehicles: TargetVehicle[];
|
||||
currentDateLabel: string;
|
||||
onOpenFullscreen: () => void;
|
||||
onExpandTarget: (targetId: number) => void;
|
||||
onAssessmentYearChange: (targetId: number, year: number) => void;
|
||||
onViewAll: (target: TargetSummary) => void;
|
||||
}
|
||||
|
||||
export default function TargetDetailPanel({
|
||||
target,
|
||||
expandedTargetId,
|
||||
selectedYear,
|
||||
vehicles,
|
||||
currentDateLabel,
|
||||
onOpenFullscreen,
|
||||
onExpandTarget,
|
||||
onAssessmentYearChange,
|
||||
onViewAll,
|
||||
}: TargetDetailPanelProps) {
|
||||
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
|
||||
const primaryCompletion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
|
||||
const primaryQualified = assessment?.qualifiedCount ?? target?.yearQualifiedCount ?? 0;
|
||||
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
|
||||
|
||||
return (
|
||||
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 flex-shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
|
||||
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{target?.targetName || '请选择车型'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenFullscreen}
|
||||
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||
{target && (
|
||||
<div
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
||||
onClick={() => onExpandTarget(target.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
|
||||
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
|
||||
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
|
||||
</div>
|
||||
<div className="text-[8px] font-bold text-slate-300">
|
||||
累计: {fmtKm(target.cumulativeTotal)} KM
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: 180 }}
|
||||
className="text-slate-300"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{expandedTargetId === target.id && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||
<select
|
||||
value={assessment?.yearNumber || ''}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onAssessmentYearChange(target.id, Number(event.target.value));
|
||||
}}
|
||||
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
|
||||
>
|
||||
{target.yearlyAssessments.map(item => (
|
||||
<option key={item.yearNumber} value={item.yearNumber}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||
{target.periods.map((period, index) => (
|
||||
<p key={index} className="text-[10px] font-black text-slate-700">{period}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||
</div>
|
||||
</div>
|
||||
{assessment ? (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
||||
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, index) => (
|
||||
<p key={index} className="text-[10px] font-black text-slate-700">{period}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
|
||||
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
|
||||
{assessment.vehicleCount < target.vehicleCount && (
|
||||
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
||||
<p className="text-[8px] font-bold text-slate-300">
|
||||
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
|
||||
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
||||
<p className="text-[10px] font-black text-blue-500">
|
||||
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
||||
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
||||
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-2 mt-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewAll(target);
|
||||
}}
|
||||
className="text-[8px] text-blue-500 font-bold hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{vehicles.slice(0, 5).map(vehicle => (
|
||||
<div key={vehicle.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{vehicle.plateNumber}</Blur></span>
|
||||
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-blue-600">{vehicle.todayMileage}</span>
|
||||
<span className="text-[8px] text-slate-400 ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TargetSummary, TrendPoint } from '../types';
|
||||
import { fmtKm, trendDateLabel, type ChartType } from './model';
|
||||
|
||||
interface TrendPanelProps {
|
||||
selectedTarget: TargetSummary | undefined;
|
||||
selectedCompletion: number;
|
||||
trendData: TrendPoint[];
|
||||
}
|
||||
|
||||
export default function TrendPanel({
|
||||
selectedTarget,
|
||||
selectedCompletion,
|
||||
trendData,
|
||||
}: TrendPanelProps) {
|
||||
const [chartType, setChartType] = useState<ChartType>('bar');
|
||||
|
||||
return (
|
||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{fmtKm(selectedTarget?.todayTotal ?? 0)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{fmtKm(selectedTarget?.cumulativeTotal ?? 0)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{selectedTarget?.vehicleCount ?? 0}
|
||||
<span className="text-blue-500 text-[10px] ml-1">台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{selectedCompletion.toFixed(1)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
|
||||
</div>
|
||||
<div className="flex bg-slate-50 p-1 rounded-lg">
|
||||
{(['bar', 'line', 'area'] as const).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setChartType(type)}
|
||||
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
|
||||
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[280px] w-full min-w-0">
|
||||
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
||||
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
|
||||
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
||||
{trendData.map((_entry: TrendPoint, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
) : chartType === 'line' ? (
|
||||
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
||||
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
|
||||
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
||||
</Line>
|
||||
</LineChart>
|
||||
) : (
|
||||
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
||||
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
|
||||
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
||||
</Area>
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment } from '../types';
|
||||
import {
|
||||
deriveSelectedTargetMetrics,
|
||||
deriveTargetTableRow,
|
||||
filterAndSortTargetVehicles,
|
||||
fmtDateLabel,
|
||||
fmtKm,
|
||||
fmtPercent,
|
||||
getCurrentDateLabel,
|
||||
getDefaultDate,
|
||||
getTargetAssessment,
|
||||
initialAssessmentYears,
|
||||
orderTargets,
|
||||
shortTargetName,
|
||||
summarizeTargets,
|
||||
totalTodayMileage,
|
||||
trendDateLabel,
|
||||
} from './model';
|
||||
|
||||
function assessment(overrides: Partial<TargetYearlyAssessment> = {}): TargetYearlyAssessment {
|
||||
return {
|
||||
yearNumber: 1,
|
||||
label: '考核第1年',
|
||||
vehicleCount: 8,
|
||||
target: 8000,
|
||||
completed: 6000,
|
||||
remaining: 2000,
|
||||
completionRate: 75,
|
||||
qualifiedCount: 4,
|
||||
qualifiedRate: 50,
|
||||
halfQualifiedCount: 6,
|
||||
daysLeft: 20,
|
||||
dailyTarget: 100,
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
periods: ['2026/1/1-2026/12/31'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function target(overrides: Partial<TargetSummary> = {}): TargetSummary {
|
||||
return {
|
||||
id: 1,
|
||||
targetName: '10辆4.5T普货',
|
||||
vehicleCount: 10,
|
||||
totalMileagePerVehicle: 2000,
|
||||
annualMileagePerVehicle: 1000,
|
||||
assessmentYears: 2,
|
||||
periods: ['2026/1/1-2027/12/31'],
|
||||
todayTotal: 300,
|
||||
cumulativeTotal: 5000,
|
||||
avgCompletion: 42,
|
||||
qualifiedCount: 3,
|
||||
yearQualifiedCount: 2,
|
||||
halfQualifiedCount: 5,
|
||||
currentYearTarget: 10000,
|
||||
currentYearCompleted: 4200,
|
||||
remaining: 5800,
|
||||
daysLeft: 50,
|
||||
dailyTarget: 116,
|
||||
firstYearVehicleCount: 10,
|
||||
firstYearTarget: 10000,
|
||||
firstYearCompleted: 4200,
|
||||
firstYearRemaining: 5800,
|
||||
firstYearCompletionRate: 42,
|
||||
firstYearQualifiedCount: 2,
|
||||
firstYearQualifiedRate: 20,
|
||||
firstYearHalfQualifiedCount: 5,
|
||||
firstYearDaysLeft: 50,
|
||||
firstYearDailyTarget: 116,
|
||||
firstYearStartDate: '2026-01-01',
|
||||
firstYearEndDate: '2026-12-31',
|
||||
yearlyAssessments: [assessment()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vehicle(plateNumber: string, todayMileage: number): TargetVehicle {
|
||||
return {
|
||||
plateNumber,
|
||||
todayMileage,
|
||||
totalMileage: 1000,
|
||||
completionRate: 10,
|
||||
isQualified: false,
|
||||
currentYearIsQualified: false,
|
||||
dailyRequiredMileage: 100,
|
||||
rentStatus: null,
|
||||
department: null,
|
||||
customer: null,
|
||||
isOnline: false,
|
||||
};
|
||||
}
|
||||
|
||||
test('statistics dates retain the 05:00 rollover and current local date labels', () => {
|
||||
assert.equal(getDefaultDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
|
||||
assert.equal(getDefaultDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
|
||||
assert.equal(getCurrentDateLabel(new Date(2026, 7, 13, 23, 0)), '2026.8.13');
|
||||
assert.equal(fmtDateLabel('2026-08-03'), '2026.8.3');
|
||||
assert.equal(fmtDateLabel(null), '');
|
||||
});
|
||||
|
||||
test('statistics number, percent, target, and trend labels preserve display precision', () => {
|
||||
assert.equal(fmtKm(12345.67), '1.23万');
|
||||
assert.equal(fmtKm(9999.5), '9,999.5');
|
||||
assert.equal(fmtKm(-12000), '-12,000');
|
||||
assert.equal(fmtPercent(12.34), '12.3%');
|
||||
assert.equal(shortTargetName('10辆4.5T普货'), '10台普货');
|
||||
assert.equal(shortTargetName('8台4.5T冷链车'), '8台冷藏车');
|
||||
assert.equal(shortTargetName('未命名批次'), '未命名批次');
|
||||
assert.equal(trendDateLabel('08-03'), '8.3');
|
||||
});
|
||||
|
||||
test('target ordering and initial assessment years retain first-match fallbacks', () => {
|
||||
const plain = target({ id: 1, targetName: '普通批次' });
|
||||
const focused = target({ id: 2, targetName: '羚牛136台普货', yearlyAssessments: [] });
|
||||
assert.deepEqual(orderTargets([plain, focused]).map(item => item.id), [2, 1]);
|
||||
assert.deepEqual(initialAssessmentYears([plain, focused]), { 1: 1, 2: 1 });
|
||||
assert.equal(getTargetAssessment(plain, 99)?.yearNumber, 1);
|
||||
assert.equal(getTargetAssessment(focused, 1), null);
|
||||
});
|
||||
|
||||
test('selected metrics retain assessment priority, trend delta, and pressure thresholds', () => {
|
||||
const secondYear = assessment({
|
||||
yearNumber: 2,
|
||||
label: '考核第2年',
|
||||
completionRate: 92,
|
||||
remaining: -50,
|
||||
daysLeft: 0,
|
||||
dailyTarget: 7,
|
||||
qualifiedRate: 80,
|
||||
});
|
||||
const metrics = deriveSelectedTargetMetrics(
|
||||
target({ yearlyAssessments: [assessment(), secondYear] }),
|
||||
2,
|
||||
[{ date: '08-01', mileage: 500 }, { date: '08-02', mileage: 420 }],
|
||||
);
|
||||
assert.deepEqual(metrics, {
|
||||
assessment: secondYear,
|
||||
completion: 92,
|
||||
remaining: -50,
|
||||
daysLeft: 0,
|
||||
dailyTarget: 7,
|
||||
qualifiedRate: 80,
|
||||
trendDelta: -80,
|
||||
pressureLevel: '健康',
|
||||
});
|
||||
|
||||
const fallback = deriveSelectedTargetMetrics(target({ yearlyAssessments: [] }), undefined, []);
|
||||
assert.equal(fallback.completion, 42);
|
||||
assert.equal(fallback.dailyTarget, 0);
|
||||
assert.equal(fallback.qualifiedRate, 20);
|
||||
assert.equal(fallback.pressureLevel, '高压');
|
||||
});
|
||||
|
||||
test('table rows and fullscreen totals preserve unweighted selected-year calculations', () => {
|
||||
const first = target({ id: 1, todayTotal: 100, cumulativeTotal: 1000 });
|
||||
const second = target({
|
||||
id: 2,
|
||||
vehicleCount: 30,
|
||||
todayTotal: 200,
|
||||
cumulativeTotal: 3000,
|
||||
yearlyAssessments: [assessment({ yearNumber: 2, completionRate: 95 })],
|
||||
});
|
||||
const row = deriveTargetTableRow(second, 2);
|
||||
assert.equal(row.completion, 95);
|
||||
assert.equal(row.taskPerVehicle, 2000);
|
||||
assert.equal(row.goal, 8000);
|
||||
assert.deepEqual(summarizeTargets([first, second], { 1: 1, 2: 2 }), {
|
||||
today: 300,
|
||||
cumulative: 4000,
|
||||
vehicleCount: 40,
|
||||
averageCompletion: 85,
|
||||
});
|
||||
assert.equal(summarizeTargets([], {}).averageCompletion, 0);
|
||||
});
|
||||
|
||||
test('vehicle search and mileage sorting preserve case-insensitive filtering and zero fallback', () => {
|
||||
const vehicles = [
|
||||
vehicle('YUE-A2', 20),
|
||||
vehicle('yue-A1', 0),
|
||||
vehicle('OTHER', 100),
|
||||
];
|
||||
assert.deepEqual(
|
||||
filterAndSortTargetVehicles(vehicles, 'yue', 'desc').map(item => item.plateNumber),
|
||||
['YUE-A2', 'yue-A1'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterAndSortTargetVehicles(vehicles, 'YUE', 'asc').map(item => item.plateNumber),
|
||||
['yue-A1', 'YUE-A2'],
|
||||
);
|
||||
assert.equal(totalTodayMileage(vehicles), 120);
|
||||
assert.deepEqual(vehicles.map(item => item.plateNumber), ['YUE-A2', 'yue-A1', 'OTHER']);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from '../types';
|
||||
|
||||
export type ChartType = 'bar' | 'line' | 'area';
|
||||
export type VehicleSort = 'asc' | 'desc';
|
||||
export type PressureLevel = '健康' | '关注' | '高压';
|
||||
|
||||
export interface SelectedTargetMetrics {
|
||||
assessment: TargetYearlyAssessment | null;
|
||||
completion: number;
|
||||
remaining: number;
|
||||
daysLeft: number;
|
||||
dailyTarget: number;
|
||||
qualifiedRate: number;
|
||||
trendDelta: number;
|
||||
pressureLevel: PressureLevel;
|
||||
}
|
||||
|
||||
export interface TargetTableRow {
|
||||
assessment: TargetYearlyAssessment | null;
|
||||
completion: number;
|
||||
qualified: number;
|
||||
halfQualified: number;
|
||||
goal: number;
|
||||
completed: number;
|
||||
remainingMileage: number;
|
||||
days: number;
|
||||
daily: number;
|
||||
taskPerVehicle: number;
|
||||
}
|
||||
|
||||
export interface TargetTotals {
|
||||
today: number;
|
||||
cumulative: number;
|
||||
vehicleCount: number;
|
||||
averageCompletion: number;
|
||||
}
|
||||
|
||||
export function getDefaultDate(now = new Date()): string {
|
||||
const date = new Date(now);
|
||||
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function getCurrentDateLabel(now = new Date()): string {
|
||||
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
||||
}
|
||||
|
||||
export function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function fmtPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function getTargetAssessment(
|
||||
target: TargetSummary,
|
||||
selectedYear?: number,
|
||||
): TargetYearlyAssessment | null {
|
||||
if (target.yearlyAssessments.length === 0) return null;
|
||||
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
||||
}
|
||||
|
||||
export function fmtDateLabel(date: string | null): string {
|
||||
if (!date) return '';
|
||||
const [year, month, day] = date.split('-');
|
||||
return `${year}.${Number(month)}.${Number(day)}`;
|
||||
}
|
||||
|
||||
export function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
const count = match[1];
|
||||
let desc = match[2];
|
||||
desc = desc.replace('4.5T普货', '普货');
|
||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||||
desc = desc.replace('18T', '18T');
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
|
||||
export function trendDateLabel(value: string): string {
|
||||
const [month, day] = value.split('-');
|
||||
return `${parseInt(month)}.${parseInt(day)}`;
|
||||
}
|
||||
|
||||
export function orderTargets(targets: TargetSummary[]): TargetSummary[] {
|
||||
const focused = targets.find(item => item.targetName.includes('羚牛136')) || targets[0];
|
||||
return focused
|
||||
? [focused, ...targets.filter(item => item.id !== focused.id)]
|
||||
: targets;
|
||||
}
|
||||
|
||||
export function initialAssessmentYears(targets: TargetSummary[]): Record<number, number> {
|
||||
return Object.fromEntries(
|
||||
targets.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1]),
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveSelectedTargetMetrics(
|
||||
target: TargetSummary | undefined,
|
||||
selectedYear: number | undefined,
|
||||
trendData: TrendPoint[],
|
||||
): SelectedTargetMetrics {
|
||||
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
|
||||
const completion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
|
||||
const remaining = assessment?.remaining ?? 0;
|
||||
const daysLeft = assessment?.daysLeft ?? target?.daysLeft ?? 0;
|
||||
const dailyTarget = assessment?.dailyTarget ?? (daysLeft > 0 ? remaining / daysLeft : 0);
|
||||
const qualifiedRate = assessment?.qualifiedRate
|
||||
?? (target?.vehicleCount ? target.yearQualifiedCount / target.vehicleCount * 100 : 0);
|
||||
const latestTrend = trendData[trendData.length - 1];
|
||||
const previousTrend = trendData[trendData.length - 2];
|
||||
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
||||
const pressureLevel = completion >= 90 ? '健康' : completion >= 70 ? '关注' : '高压';
|
||||
|
||||
return {
|
||||
assessment,
|
||||
completion,
|
||||
remaining,
|
||||
daysLeft,
|
||||
dailyTarget,
|
||||
qualifiedRate,
|
||||
trendDelta,
|
||||
pressureLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveTargetTableRow(
|
||||
target: TargetSummary,
|
||||
selectedYear?: number,
|
||||
): TargetTableRow {
|
||||
const assessment = getTargetAssessment(target, selectedYear);
|
||||
return {
|
||||
assessment,
|
||||
completion: assessment?.completionRate ?? target.avgCompletion,
|
||||
qualified: assessment?.qualifiedCount ?? target.yearQualifiedCount,
|
||||
halfQualified: assessment?.halfQualifiedCount ?? target.halfQualifiedCount,
|
||||
goal: assessment?.target ?? target.currentYearTarget,
|
||||
completed: assessment?.completed ?? target.currentYearCompleted,
|
||||
remainingMileage: assessment?.remaining ?? target.remaining,
|
||||
days: assessment?.daysLeft ?? target.daysLeft,
|
||||
daily: assessment?.dailyTarget ?? target.dailyTarget,
|
||||
taskPerVehicle: target.annualMileagePerVehicle * (assessment?.yearNumber || 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeTargets(
|
||||
targets: TargetSummary[],
|
||||
assessmentYearMap: Record<number, number>,
|
||||
): TargetTotals {
|
||||
const totals = targets.reduce((result, target) => ({
|
||||
today: result.today + target.todayTotal,
|
||||
cumulative: result.cumulative + target.cumulativeTotal,
|
||||
vehicleCount: result.vehicleCount + target.vehicleCount,
|
||||
completion: result.completion
|
||||
+ (getTargetAssessment(target, assessmentYearMap[target.id])?.completionRate ?? target.avgCompletion),
|
||||
}), { today: 0, cumulative: 0, vehicleCount: 0, completion: 0 });
|
||||
|
||||
return {
|
||||
today: totals.today,
|
||||
cumulative: totals.cumulative,
|
||||
vehicleCount: totals.vehicleCount,
|
||||
averageCompletion: targets.length > 0 ? totals.completion / targets.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterAndSortTargetVehicles(
|
||||
vehicles: TargetVehicle[],
|
||||
search: string,
|
||||
sort: VehicleSort,
|
||||
): TargetVehicle[] {
|
||||
return vehicles
|
||||
.filter(vehicle => vehicle.plateNumber.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const valueA = a.todayMileage || 0;
|
||||
const valueB = b.todayMileage || 0;
|
||||
return sort === 'desc' ? valueB - valueA : valueA - valueB;
|
||||
});
|
||||
}
|
||||
|
||||
export function totalTodayMileage(vehicles: TargetVehicle[]): number {
|
||||
return vehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
||||
}
|
||||
@@ -1,43 +1,31 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react';
|
||||
import { Activity, RotateCcw, Search, ChevronDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
||||
import SuggestionList from './SuggestionList';
|
||||
import SuggestionDetail from './SuggestionDetail';
|
||||
import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
interface AdvancedFilters {
|
||||
plateSearch: string;
|
||||
region: string;
|
||||
vehicleType: string;
|
||||
customer: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
const count = match[1];
|
||||
let desc = match[2];
|
||||
desc = desc.replace('4.5T普货', '普货');
|
||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
|
||||
function hasActiveFilters(f: AdvancedFilters): boolean {
|
||||
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
||||
}
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import ActiveFilterTags from './scheduling-module/ActiveFilterTags';
|
||||
import BatchActionBar from './scheduling-module/BatchActionBar';
|
||||
import BatchConfirmModal from './scheduling-module/BatchConfirmModal';
|
||||
import ListHeader from './scheduling-module/ListHeader';
|
||||
import ListLoadingSkeleton from './scheduling-module/ListLoadingSkeleton';
|
||||
import SchedulingSkeleton from './scheduling-module/SchedulingSkeleton';
|
||||
import SummaryCards from './scheduling-module/SummaryCards';
|
||||
import {
|
||||
EMPTY_FILTERS,
|
||||
buildBatchItems,
|
||||
buildFilterOptions,
|
||||
countActiveFilters,
|
||||
filterSuggestions,
|
||||
hasResettableFilters,
|
||||
type AdvancedFilters,
|
||||
type TypeFilter,
|
||||
} from './scheduling-module/model';
|
||||
|
||||
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
|
||||
@@ -88,56 +76,6 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
);
|
||||
}
|
||||
|
||||
function Sk({ className }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
function SkeletonPage() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="智能调度工作台"
|
||||
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
|
||||
icon={Activity}
|
||||
eyebrow="SCHEDULING OPS"
|
||||
meta="建议生成中 · 正在计算候选车辆"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
|
||||
</div>
|
||||
<SurfaceCard>
|
||||
<div className="space-y-3 p-4">
|
||||
<SkeletonBlock className="h-5 w-40" />
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 py-3">
|
||||
<SkeletonBlock className="h-10 w-1 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonBlock className="h-3.5 w-48" />
|
||||
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
|
||||
</div>
|
||||
<SkeletonBlock className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||
// Business rule: at most one active intervention per suggestion. If ANY
|
||||
// candidate is already intervened, skip the whole suggestion in batch flow.
|
||||
const hasActive = s.candidates.some(
|
||||
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
|
||||
);
|
||||
if (hasActive) return null;
|
||||
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
|
||||
}
|
||||
|
||||
export default function SchedulingModule() {
|
||||
const [data, setData] = useState<SchedulingResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -187,16 +125,7 @@ export default function SchedulingModule() {
|
||||
}, []);
|
||||
|
||||
const batchItems = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...selectedIds]
|
||||
.map(id => data.suggestions.find(s => s.id === id))
|
||||
.filter((s): s is SchedulingSuggestion => !!s)
|
||||
.map(s => {
|
||||
const candidate = pickBestCandidate(s);
|
||||
if (!candidate) return null;
|
||||
return { suggestion: s, candidate };
|
||||
})
|
||||
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
|
||||
return buildBatchItems(data, selectedIds);
|
||||
}, [data, selectedIds]);
|
||||
|
||||
const handleBatchSubmit = useCallback(async () => {
|
||||
@@ -222,38 +151,18 @@ export default function SchedulingModule() {
|
||||
}, [batchItems, loadData, exitSelectMode]);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||
for (const s of data.suggestions) {
|
||||
const v = s.currentVehicle;
|
||||
if (v.region) r.add(v.region);
|
||||
if (v.vehicleType) t.add(v.vehicleType);
|
||||
if (v.customer) c.add(v.customer);
|
||||
if (v.department) d.add(v.department);
|
||||
if (v.manager) m.add(v.manager);
|
||||
}
|
||||
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
|
||||
return buildFilterOptions(data);
|
||||
}, [data]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let list = data.suggestions;
|
||||
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
||||
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
||||
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
|
||||
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
|
||||
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
|
||||
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
|
||||
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
|
||||
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
|
||||
return list;
|
||||
return filterSuggestions(data, typeFilter, filters);
|
||||
}, [data, typeFilter, filters]);
|
||||
|
||||
const summary = data?.summary;
|
||||
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
|
||||
const activeFilterCount = countActiveFilters(filters);
|
||||
|
||||
// Initial load — full page skeleton
|
||||
if (loading && !data) return <SkeletonPage />;
|
||||
if (loading && !data) return <SchedulingSkeleton />;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
@@ -271,95 +180,36 @@ export default function SchedulingModule() {
|
||||
>
|
||||
|
||||
{/* ===== Summary Cards ===== */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button type="button" onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
|
||||
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
|
||||
</button>
|
||||
</div>
|
||||
<SummaryCards
|
||||
summary={summary}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
onShowRecentHistory={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
||||
/>
|
||||
|
||||
{/* ===== List Card ===== */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={loadData} disabled={loading}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
|
||||
disabled={filteredSuggestions.length === 0}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="导出 CSV"
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||
title="调度记录"
|
||||
>
|
||||
<Clock size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectMode) exitSelectMode();
|
||||
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||||
}}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectMode ? '退出多选' : '多选模式'}
|
||||
>
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Filter size={15} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
</button>
|
||||
{data?.targets.map(t => (
|
||||
<button key={t.id}
|
||||
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(t.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ListHeader
|
||||
loading={loading}
|
||||
targets={data?.targets}
|
||||
selectedTargetId={selectedTargetId}
|
||||
selectMode={selectMode}
|
||||
showFilter={showFilter}
|
||||
activeFilterCount={activeFilterCount}
|
||||
canExport={filteredSuggestions.length > 0}
|
||||
onRefresh={loadData}
|
||||
onExport={() => exportSuggestionsCsv(filteredSuggestions)}
|
||||
onShowHistory={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||||
onToggleSelectMode={() => {
|
||||
if (selectMode) exitSelectMode();
|
||||
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||||
}}
|
||||
onToggleFilter={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||
onSelectAll={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
onSelectTarget={targetId => { setSelectedTargetId(targetId); setTypeFilter('all'); }}
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<AnimatePresence>
|
||||
@@ -368,7 +218,7 @@ export default function SchedulingModule() {
|
||||
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
||||
{hasActiveFilters(tempFilters) && (
|
||||
{hasResettableFilters(tempFilters) && (
|
||||
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">重置</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -400,16 +250,11 @@ export default function SchedulingModule() {
|
||||
|
||||
{/* Active filter tags */}
|
||||
{activeFilterCount > 0 && !showFilter && (
|
||||
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-slate-400">筛选:</span>
|
||||
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">车牌 "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
|
||||
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
|
||||
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
|
||||
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
|
||||
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
|
||||
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
|
||||
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">清除</button>
|
||||
</div>
|
||||
<ActiveFilterTags
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onClear={() => setFilters(EMPTY_FILTERS)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
||||
@@ -418,26 +263,7 @@ export default function SchedulingModule() {
|
||||
|
||||
{loading ? (
|
||||
/* List skeleton while refreshing */
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||||
<Sk className="w-1 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sk className="h-3.5 w-20" />
|
||||
<Sk className="h-3 w-10 rounded-full" />
|
||||
<Sk className="h-3 w-14" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Sk className="h-2.5 w-28" />
|
||||
<Sk className="h-2.5 w-16" />
|
||||
<Sk className="h-2.5 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<Sk className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListLoadingSkeleton />
|
||||
) : (
|
||||
<SuggestionList
|
||||
suggestions={filteredSuggestions}
|
||||
@@ -465,97 +291,23 @@ export default function SchedulingModule() {
|
||||
{/* Batch action bar */}
|
||||
<AnimatePresence>
|
||||
{selectMode && (
|
||||
<motion.div
|
||||
initial={{ y: 80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 80, opacity: 0 }}
|
||||
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">已选</span>
|
||||
<span className="text-lg font-black">{selectedIds.size}</span>
|
||||
<span className="text-xs text-slate-400">条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={exitSelectMode}
|
||||
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchConfirm(true)}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={12} /> 批量干预
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<BatchActionBar
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={exitSelectMode}
|
||||
onConfirm={() => setShowBatchConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Batch confirmation modal */}
|
||||
{showBatchConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
|
||||
>
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-white font-bold text-sm">确认批量干预</span>
|
||||
<button
|
||||
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
将登记 <span className="font-bold text-slate-800">{batchItems.length}</span> 条干预,已排除无可用候选车的建议。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{batchItems.map(({ suggestion, candidate }) => (
|
||||
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||||
</div>
|
||||
{candidate.canQualifyAfterSwap ? (
|
||||
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||||
) : (
|
||||
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0">需关注</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{batchResultMsg && (
|
||||
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchSubmit}
|
||||
disabled={batchInFlight || batchItems.length === 0}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length} 条`}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<BatchConfirmModal
|
||||
batchItems={batchItems}
|
||||
batchInFlight={batchInFlight}
|
||||
batchResultMsg={batchResultMsg}
|
||||
onClose={() => setShowBatchConfirm(false)}
|
||||
onSubmit={handleBatchSubmit}
|
||||
/>
|
||||
)}
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</PageFrame>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { AdvancedFilters } from './model';
|
||||
|
||||
interface ActiveFilterTagsProps {
|
||||
filters: AdvancedFilters;
|
||||
onChange: Dispatch<SetStateAction<AdvancedFilters>>;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActiveFilterTags({ filters, onChange, onClear }: ActiveFilterTagsProps) {
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-slate-400">筛选:</span>
|
||||
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">车牌 "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, plateSearch: '' }))} /></span>}
|
||||
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, region: '' }))} /></span>}
|
||||
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, vehicleType: '' }))} /></span>}
|
||||
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, department: '' }))} /></span>}
|
||||
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, manager: '' }))} /></span>}
|
||||
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, customer: '' }))} /></span>}
|
||||
<button onClick={onClear} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">清除</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Send } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface BatchActionBarProps {
|
||||
selectedCount: number;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function BatchActionBar({ selectedCount, onCancel, onConfirm }: BatchActionBarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 80, opacity: 0 }}
|
||||
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">已选</span>
|
||||
<span className="text-lg font-black">{selectedCount}</span>
|
||||
<span className="text-xs text-slate-400">条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={selectedCount === 0}
|
||||
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={12} /> 批量干预
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { BatchItem } from './model';
|
||||
|
||||
interface BatchConfirmModalProps {
|
||||
batchItems: BatchItem[];
|
||||
batchInFlight: boolean;
|
||||
batchResultMsg: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function BatchConfirmModal({
|
||||
batchItems,
|
||||
batchInFlight,
|
||||
batchResultMsg,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BatchConfirmModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && onClose()}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
|
||||
>
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-white font-bold text-sm">确认批量干预</span>
|
||||
<button
|
||||
onClick={() => !batchInFlight && onClose()}
|
||||
disabled={batchInFlight}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
将登记 <span className="font-bold text-slate-800">{batchItems.length}</span> 条干预,已排除无可用候选车的建议。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{batchItems.map(({ suggestion, candidate }) => (
|
||||
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||||
</div>
|
||||
{candidate.canQualifyAfterSwap ? (
|
||||
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||||
) : (
|
||||
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0">需关注</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{batchResultMsg && (
|
||||
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={batchInFlight}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={batchInFlight || batchItems.length === 0}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length} 条`}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheckSquare, Clock, Download, Filter, RotateCcw } from 'lucide-react';
|
||||
import type { SchedulingTargetOption } from '../types';
|
||||
import { shortTargetName } from './model';
|
||||
|
||||
interface ListHeaderProps {
|
||||
loading: boolean;
|
||||
targets: SchedulingTargetOption[] | undefined;
|
||||
selectedTargetId: number | undefined;
|
||||
selectMode: boolean;
|
||||
showFilter: boolean;
|
||||
activeFilterCount: number;
|
||||
canExport: boolean;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onShowHistory: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onToggleFilter: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectTarget: (targetId: number) => void;
|
||||
}
|
||||
|
||||
export default function ListHeader({
|
||||
loading,
|
||||
targets,
|
||||
selectedTargetId,
|
||||
selectMode,
|
||||
showFilter,
|
||||
activeFilterCount,
|
||||
canExport,
|
||||
onRefresh,
|
||||
onExport,
|
||||
onShowHistory,
|
||||
onToggleSelectMode,
|
||||
onToggleFilter,
|
||||
onSelectAll,
|
||||
onSelectTarget,
|
||||
}: ListHeaderProps) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onRefresh} disabled={loading}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={!canExport}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="导出 CSV"
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowHistory}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||
title="调度记录"
|
||||
>
|
||||
<Clock size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectMode ? '退出多选' : '多选模式'}
|
||||
>
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleFilter}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Filter size={15} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={onSelectAll}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
</button>
|
||||
{targets?.map(target => (
|
||||
<button key={target.id}
|
||||
onClick={() => onSelectTarget(target.id)}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === target.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
function Sk({ className }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export default function ListLoadingSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||||
<Sk className="w-1 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sk className="h-3.5 w-20" />
|
||||
<Sk className="h-3 w-10 rounded-full" />
|
||||
<Sk className="h-3 w-14" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Sk className="h-2.5 w-28" />
|
||||
<Sk className="h-2.5 w-16" />
|
||||
<Sk className="h-2.5 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<Sk className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { PageFrame, SkeletonBlock, SurfaceCard } from '../../../components/ui/surface';
|
||||
|
||||
export default function SchedulingSkeleton() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="智能调度工作台"
|
||||
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
|
||||
icon={Activity}
|
||||
eyebrow="SCHEDULING OPS"
|
||||
meta="建议生成中 · 正在计算候选车辆"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
|
||||
</div>
|
||||
<SurfaceCard>
|
||||
<div className="space-y-3 p-4">
|
||||
<SkeletonBlock className="h-5 w-40" />
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 py-3">
|
||||
<SkeletonBlock className="h-10 w-1 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonBlock className="h-3.5 w-48" />
|
||||
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
|
||||
</div>
|
||||
<SkeletonBlock className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Activity, AlertTriangle, CheckCircle2, SendHorizonal } from 'lucide-react';
|
||||
import { MetricTile } from '../../../components/ui/surface';
|
||||
import type { SchedulingSummary } from '../types';
|
||||
import type { TypeFilter } from './model';
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SchedulingSummary | undefined;
|
||||
typeFilter: TypeFilter;
|
||||
onTypeFilterChange: (filter: TypeFilter) => void;
|
||||
onShowRecentHistory: () => void;
|
||||
}
|
||||
|
||||
export default function SummaryCards({
|
||||
summary,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
onShowRecentHistory,
|
||||
}: SummaryCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button type="button" onClick={() => onTypeFilterChange(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onTypeFilterChange(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onTypeFilterChange('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
|
||||
</button>
|
||||
<button type="button" onClick={onShowRecentHistory} className="rounded-2xl">
|
||||
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user