2 Commits
Author SHA1 Message Date
kkfluous 37a9f303ec chore: release v1.1.14
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 12:04:02 +08:00
kkfluous 06fe75b4c7 refactor: modularize application domains 2026-08-13 12:03:34 +08:00
143 changed files with 14648 additions and 8888 deletions
@@ -0,0 +1,97 @@
# LN-BI 渐进式重构方案
## 目标
本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约:
- 页面文案、布局、交互和 URL 行为
- API 路径、请求参数、状态码和响应字段
- SQL、数据源优先级、权限与脱敏顺序
- 里程、资产、能源和调度的统计口径
- 缓存刷新频率与日报归档时间
## 当前结构
项目已经按业务模块组织,但部分文件仍承担了过多职责:
| 热点 | 规模 | 主要职责混合 |
| --- | ---: | --- |
| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 |
| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 |
| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 |
| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 |
目标结构遵循同一方向:
```text
route / page
-> controller / hook # 流程与状态
-> service / model # 业务规则与纯计算
-> repository / api # 数据访问
```
React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。
## 实施顺序
1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。
2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。
3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。
4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。
5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。
每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。
## 模块迭代路线
| 批次 | 模块 | 主要边界 | 风险控制 |
| --- | --- | --- | --- |
| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 |
| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 |
| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 |
| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 |
| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 |
高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。
## 当前进度
截至 2026-08-13,前五轮可独立验证的边界已经完成:
- 应用路由配置与服务器启动副作用已经从入口文件分离。
- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。
- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。
- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。
- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。
- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。
- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。
- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。
- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。
- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。
- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。
下一批继续只处理能够独立回归的边界:
1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。
2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。
3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。
4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。
## 注释约定
注释解释“为什么”,不复述语句本身。优先说明:
- 统计边界和历史口径
- 时区、日期范围和特殊状态映射
- 外部 API 的兼容处理
- 看似可合并但必须保持差异的流程
命名和类型能够表达清楚的代码不额外添加注释。
## 已识别但不在本轮修复的问题
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
- 监控页不同入口的请求参数目前不完全一致。
- 数据库连接配置与部署凭据需要独立安全治理。
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.13",
"version": "1.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -10,6 +10,7 @@
"build": "vite build",
"start": "node --import tsx src/server/index.ts",
"lint": "tsc --noEmit",
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
},
"dependencies": {
+15 -141
View File
@@ -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 (
+38
View File
@@ -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'],
);
});
+72
View File
@@ -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;
}
+20
View File
@@ -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'), '');
});
+56
View File
@@ -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
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
export type FeedbackStep = 1 | 2 | 3;
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
export interface UploadedImg {
url: string;
thumbDataUrl: string;
}
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;
};
+310
View File
@@ -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);
});
+412
View File
@@ -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);
}
+73 -294
View File
@@ -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>
);
}
+44
View File
@@ -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');
});
+41
View File
@@ -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);
}
+52
View File
@@ -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;
}
+29 -112
View File
@@ -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} />
+28 -118
View File
@@ -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} />
+51 -659
View File
@@ -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>
);
}
+48
View File
@@ -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
File diff suppressed because it is too large Load Diff
+92 -703
View File
@@ -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> &gt;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);
});
+170
View File
@@ -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">&times;</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']);
});
+185
View File
@@ -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);
}
+69 -317
View File
@@ -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>
);
}

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