Compare commits
46 Commits
44c6f98254
...
e57b8d8801
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57b8d8801 | ||
|
|
8b95e53098 | ||
|
|
bfee8344b9 | ||
|
|
ca4a84f84b | ||
|
|
94277efc24 | ||
|
|
787fa27949 | ||
|
|
c5ee78e892 | ||
|
|
50eaeb05ae | ||
|
|
1d8e827374 | ||
|
|
54c8449f7b | ||
|
|
b7b254546c | ||
|
|
82dac759be | ||
|
|
0b8bbbb063 | ||
|
|
cbf0e18634 | ||
|
|
66de41d50b | ||
|
|
c73e20bacf | ||
|
|
8fffa141f4 | ||
|
|
cb620e5101 | ||
|
|
2469da310d | ||
|
|
863ab17b58 | ||
|
|
2f6269e071 | ||
|
|
d8f25448d0 | ||
|
|
c3300359a0 | ||
|
|
aa024f1b64 | ||
|
|
1fb9d53873 | ||
|
|
ad17803ed1 | ||
|
|
152935819b | ||
|
|
2a10c5ae31 | ||
|
|
ee3db94c75 | ||
|
|
dd1834477d | ||
|
|
7e2eefc3da | ||
|
|
167842408c | ||
|
|
0a2cfc22c4 | ||
|
|
75b4e55dca | ||
|
|
5ff3372f2a | ||
|
|
a7e617bc6f | ||
|
|
3d6c31a86e | ||
|
|
7cf7bc945a | ||
|
|
968e9369a0 | ||
|
|
caec13eec5 | ||
|
|
bb3dbde1c7 | ||
|
|
40e84a1eaa | ||
|
|
be6598a940 | ||
|
|
de0320bfcd | ||
|
|
b495cac0fe | ||
|
|
cfd81b1b9d |
815
docs/superpowers/plans/2026-04-01-mileage-module.md
Normal file
815
docs/superpowers/plans/2026-04-01-mileage-module.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# 里程管理模块实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现里程管理 BI 模块,1:1 复刻原型 UI,接入 hydrogen_energy + lingniu_prod 两个数据库。
|
||||
|
||||
**Architecture:** 后端新增 hydrogen_energy 数据库连接 + `/api/mileage/*` 路由(4 个端点)。前端在 `src/modules/mileage/` 下拆分为 MileageModule(Tab 切换)、MonitoringView(实时监控)、StatisticsView(统计报表)、DailyReportView(占位)。UI 1:1 复刻原型 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx`。
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, Recharts, Motion, Lucide Icons, Hono, mysql2
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| 创建 | `src/server/mileage-db.ts` | hydrogen_energy 数据库连接池 |
|
||||
| 创建 | `src/server/routes/mileage.ts` | 里程管理 API 路由(4 个端点) |
|
||||
| 修改 | `src/server/index.ts` | 注册 `/api/mileage` 路由 |
|
||||
| 创建 | `src/modules/mileage/types.ts` | 里程管理类型定义 |
|
||||
| 创建 | `src/modules/mileage/api.ts` | API 客户端函数 |
|
||||
| 重写 | `src/modules/mileage/MileageModule.tsx` | 主组件:子 Tab 切换 |
|
||||
| 创建 | `src/modules/mileage/MonitoringView.tsx` | 实时监控视图 |
|
||||
| 创建 | `src/modules/mileage/StatisticsView.tsx` | 统计报表视图 |
|
||||
| 创建 | `src/modules/mileage/DailyReportView.tsx` | 每日汇报(占位) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — 数据库连接 + 类型定义
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/mileage-db.ts`
|
||||
- Create: `src/modules/mileage/types.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 hydrogen_energy 数据库连接池**
|
||||
|
||||
创建 `src/server/mileage-db.ts`:
|
||||
|
||||
```ts
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const mileagePool = mysql.createPool({
|
||||
host: '101.133.130.65',
|
||||
port: 3306,
|
||||
user: 'bi_reader_02',
|
||||
password: 'bi_reader_02_Pass',
|
||||
database: 'hydrogen_energy',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export default mileagePool;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建前端类型定义**
|
||||
|
||||
创建 `src/modules/mileage/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface MonitoringVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
isOnline: boolean;
|
||||
isDataSynced: boolean;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
vehicles: MonitoringVehicle[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TargetSummary {
|
||||
id: number;
|
||||
targetName: string;
|
||||
vehicleCount: number;
|
||||
totalMileagePerVehicle: number;
|
||||
annualMileagePerVehicle: number;
|
||||
assessmentYears: number;
|
||||
period: string;
|
||||
todayTotal: number;
|
||||
cumulativeTotal: number;
|
||||
avgCompletion: number;
|
||||
qualifiedCount: number;
|
||||
yearQualifiedCount: number;
|
||||
halfQualifiedCount: number;
|
||||
currentYearTarget: number;
|
||||
currentYearCompleted: number;
|
||||
remaining: number;
|
||||
daysLeft: number;
|
||||
dailyTarget: number;
|
||||
}
|
||||
|
||||
export interface TargetVehicle {
|
||||
plateNumber: string;
|
||||
todayMileage: number;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
isQualified: boolean;
|
||||
currentYearIsQualified: boolean;
|
||||
dailyRequiredMileage: number;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
mileage: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/server/mileage-db.ts src/modules/mileage/types.ts
|
||||
git commit -m "feat: 添加 hydrogen_energy 数据库连接和里程管理类型定义"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — API 路由
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage.ts`
|
||||
- Modify: `src/server/index.ts`
|
||||
|
||||
- [ ] **Step 1: 创建里程管理路由**
|
||||
|
||||
创建 `src/server/routes/mileage.ts`:
|
||||
|
||||
```ts
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../db.js';
|
||||
import mileagePool from '../mileage-db.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 车辆关联信息 SQL(客户名、部门、经理)
|
||||
const VEHICLE_INFO_SQL = `SELECT
|
||||
truck.plate_number AS plate,
|
||||
cus.customer_name AS customer,
|
||||
dep.dep_name AS department,
|
||||
u.user_name AS manager
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
|
||||
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
|
||||
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
|
||||
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
|
||||
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
|
||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
|
||||
|
||||
// GET /monitoring — 实时监控数据
|
||||
app.get('/monitoring', async (c) => {
|
||||
try {
|
||||
// 1. 从 hydrogen_energy 取最新日期的里程数据
|
||||
const [dateRows] = await mileagePool.execute(
|
||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||||
) as any;
|
||||
const latestDate = dateRows[0]?.latest;
|
||||
if (!latestDate) return c.json({ vehicles: [], updatedAt: new Date().toISOString() });
|
||||
|
||||
const [mileageRows] = await mileagePool.execute(
|
||||
`SELECT plate, vin, daily_km, total_km, source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date = ?`,
|
||||
[latestDate]
|
||||
) as any;
|
||||
|
||||
// 对于同一 plate 可能有多条记录(不同 source),取 daily_km 最大的
|
||||
const mileageMap = new Map<string, any>();
|
||||
for (const row of mileageRows) {
|
||||
const existing = mileageMap.get(row.plate);
|
||||
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
|
||||
mileageMap.set(row.plate, row);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从 lingniu_prod 取车辆关联信息
|
||||
const [infoRows] = await pool.execute(VEHICLE_INFO_SQL) as any;
|
||||
const infoMap = new Map<string, any>();
|
||||
for (const row of infoRows) {
|
||||
infoMap.set(row.plate, row);
|
||||
}
|
||||
|
||||
// 3. 合并
|
||||
const vehicles = Array.from(mileageMap.values()).map((m: any) => {
|
||||
const info = infoMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const source = m.source || 'NONE';
|
||||
return {
|
||||
plate: m.plate,
|
||||
vin: m.vin,
|
||||
dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
source,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
isDataSynced: source !== 'NONE',
|
||||
customer: info?.customer || null,
|
||||
department: info?.department || null,
|
||||
manager: info?.manager || null,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ vehicles, updatedAt: new Date().toISOString() });
|
||||
} catch (e) {
|
||||
console.error('monitoring error:', e);
|
||||
return c.json({ vehicles: [], updatedAt: new Date().toISOString() }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /targets — 考核项目列表 + 汇总
|
||||
app.get('/targets', async (c) => {
|
||||
try {
|
||||
const [targets] = await pool.execute(
|
||||
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
|
||||
) as any;
|
||||
|
||||
const [vehicleStats] = await pool.execute(`
|
||||
SELECT
|
||||
target_id,
|
||||
COUNT(*) as total,
|
||||
SUM(today_mileage) as today_total,
|
||||
SUM(current_mileage) as cumulative_total,
|
||||
AVG(completion_rate) as avg_completion,
|
||||
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
|
||||
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
|
||||
SUM(CASE WHEN completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
|
||||
SUM(current_year_mileage_task) as current_year_target,
|
||||
SUM(current_year_mileage) as current_year_completed,
|
||||
MAX(current_year_assessment_end_date) as year_end_date
|
||||
FROM tab_mileage_assessment_vehicle
|
||||
WHERE is_deleted = 0
|
||||
GROUP BY target_id
|
||||
`) as any;
|
||||
|
||||
const statsMap = new Map<number, any>();
|
||||
for (const s of vehicleStats) {
|
||||
statsMap.set(s.target_id, s);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const result = targets.map((t: any) => {
|
||||
const s = statsMap.get(t.id) || {};
|
||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
||||
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||
const dailyTarget = remaining / daysLeft;
|
||||
|
||||
const startDate = t.default_start_date
|
||||
? new Date(t.default_start_date).toISOString().split('T')[0]
|
||||
: '';
|
||||
const endDate = t.default_end_date
|
||||
? new Date(t.default_end_date).toISOString().split('T')[0]
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
targetName: t.target_name,
|
||||
vehicleCount: Number(s.total) || t.vehicle_count,
|
||||
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||
assessmentYears: t.assessment_years,
|
||||
period: `${startDate} ~ ${endDate}`,
|
||||
todayTotal: Number(s.today_total) || 0,
|
||||
cumulativeTotal: Number(s.cumulative_total) || 0,
|
||||
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
||||
qualifiedCount: Number(s.qualified_count) || 0,
|
||||
yearQualifiedCount: Number(s.year_qualified_count) || 0,
|
||||
halfQualifiedCount: Number(s.half_qualified_count) || 0,
|
||||
currentYearTarget,
|
||||
currentYearCompleted,
|
||||
remaining,
|
||||
daysLeft,
|
||||
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error('targets error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /target/:id/vehicles — 某项目的车辆明细
|
||||
app.get('/target/:id/vehicles', async (c) => {
|
||||
const targetId = c.req.param('id');
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT plate_number, today_mileage, vehicle_total_mileage,
|
||||
completion_rate, is_qualified, current_year_is_qualified,
|
||||
daily_required_mileage
|
||||
FROM tab_mileage_assessment_vehicle
|
||||
WHERE target_id = ? AND is_deleted = 0
|
||||
ORDER BY today_mileage DESC`,
|
||||
[targetId]
|
||||
) as any;
|
||||
|
||||
const result = rows.map((r: any) => ({
|
||||
plateNumber: r.plate_number,
|
||||
todayMileage: Number(r.today_mileage) || 0,
|
||||
totalMileage: Number(r.vehicle_total_mileage) || 0,
|
||||
completionRate: Number(r.completion_rate) || 0,
|
||||
isQualified: r.is_qualified === 1,
|
||||
currentYearIsQualified: r.current_year_is_qualified === 1,
|
||||
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error('target vehicles error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /trend — 7天里程趋势
|
||||
app.get('/trend', async (c) => {
|
||||
const targetId = c.req.query('targetId');
|
||||
const days = Number(c.req.query('days')) || 7;
|
||||
try {
|
||||
let plates: string[] = [];
|
||||
if (targetId) {
|
||||
const [vehicleRows] = await pool.execute(
|
||||
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
|
||||
[targetId]
|
||||
) as any;
|
||||
plates = vehicleRows.map((r: any) => r.plate_number);
|
||||
if (plates.length === 0) return c.json([]);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
`;
|
||||
const params: any[] = [days];
|
||||
|
||||
if (plates.length > 0) {
|
||||
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
|
||||
params.push(...plates);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY stat_date ORDER BY stat_date';
|
||||
|
||||
const [rows] = await mileagePool.execute(sql, params) as any;
|
||||
|
||||
const result = rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
mileage: Math.round(Number(r.mileage) || 0),
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error('trend error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册路由到 server/index.ts**
|
||||
|
||||
在 `src/server/index.ts` 中,在 `import vehiclesRouter` 之后添加:
|
||||
|
||||
```ts
|
||||
import mileageRouter from './routes/mileage.js';
|
||||
```
|
||||
|
||||
在 `app.route('/api/vehicles', vehiclesRouter);` 之后添加:
|
||||
|
||||
```ts
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: 验证 API 端点可访问**
|
||||
|
||||
Run: `npm run dev:server &` 然后:
|
||||
```bash
|
||||
curl -s http://localhost:3001/api/mileage/targets | head -c 200
|
||||
curl -s http://localhost:3001/api/mileage/trend?days=7 | head -c 200
|
||||
```
|
||||
Expected: 返回 JSON 数据(非空数组)
|
||||
|
||||
关闭服务器后继续。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/server/mileage-db.ts src/server/routes/mileage.ts src/server/index.ts
|
||||
git commit -m "feat: 添加里程管理 API 路由(monitoring/targets/trend)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 — API 客户端
|
||||
|
||||
**Files:**
|
||||
- Create: `src/modules/mileage/api.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 API 客户端**
|
||||
|
||||
创建 `src/modules/mileage/api.ts`:
|
||||
|
||||
```ts
|
||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
|
||||
const BASE = '/api/mileage';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMonitoring(): Promise<MonitoringData> {
|
||||
return fetchJson<MonitoringData>(`${BASE}/monitoring`);
|
||||
}
|
||||
|
||||
export async function fetchTargets(): Promise<TargetSummary[]> {
|
||||
return fetchJson<TargetSummary[]>(`${BASE}/targets`);
|
||||
}
|
||||
|
||||
export async function fetchTargetVehicles(targetId: number): Promise<TargetVehicle[]> {
|
||||
return fetchJson<TargetVehicle[]>(`${BASE}/target/${targetId}/vehicles`);
|
||||
}
|
||||
|
||||
export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoint[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (targetId) params.set('targetId', String(targetId));
|
||||
params.set('days', String(days));
|
||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add src/modules/mileage/api.ts
|
||||
git commit -m "feat: 添加里程管理 API 客户端"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — MileageModule + DailyReportView
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/modules/mileage/MileageModule.tsx`
|
||||
- Create: `src/modules/mileage/DailyReportView.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 DailyReportView 占位组件**
|
||||
|
||||
创建 `src/modules/mileage/DailyReportView.tsx`:
|
||||
|
||||
```tsx
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
export default function DailyReportView() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 MileageModule.tsx**
|
||||
|
||||
这个组件负责子 Tab 切换,1:1 复刻原型中 `MileageView` 组件的导航部分(原型文件第 1585-1691 行和第 2296-2301 行)。
|
||||
|
||||
用以下内容替换 `src/modules/mileage/MileageModule.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
|
||||
export default function MileageModule() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 px-1 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
||||
{/* Sub-navigation */}
|
||||
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('monitoring')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<LayoutDashboard size={14} />
|
||||
<span className="text-[11px] font-bold">实时监控</span>
|
||||
{activeSubTab === 'monitoring' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('statistics')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
<span className="text-[11px] font-bold">统计报表</span>
|
||||
{activeSubTab === 'statistics' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('report')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span className="text-[11px] font-bold">每日汇报</span>
|
||||
{activeSubTab === 'report' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
<StatisticsView />
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript 编译**
|
||||
|
||||
注意:此时 MonitoringView 和 StatisticsView 尚未创建,可能会报错。先创建空的占位文件:
|
||||
|
||||
临时创建 `src/modules/mileage/MonitoringView.tsx`:
|
||||
```tsx
|
||||
export default function MonitoringView() {
|
||||
return <div>MonitoringView placeholder</div>;
|
||||
}
|
||||
```
|
||||
|
||||
临时创建 `src/modules/mileage/StatisticsView.tsx`:
|
||||
```tsx
|
||||
export default function StatisticsView() {
|
||||
return <div>StatisticsView placeholder</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/modules/mileage/MileageModule.tsx src/modules/mileage/DailyReportView.tsx src/modules/mileage/MonitoringView.tsx src/modules/mileage/StatisticsView.tsx
|
||||
git commit -m "feat: MileageModule Tab 切换 + DailyReportView 占位"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 — MonitoringView(实时监控)
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/modules/mileage/MonitoringView.tsx`
|
||||
|
||||
**重要:** 此组件需要 1:1 复刻原型。原型代码在 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx` 的以下行范围:
|
||||
- `MileageView` 组件:第 1585-2303 行(当 `activeSubTab === 'monitoring'` 时渲染的部分:第 1693-2295 行)
|
||||
- `SearchableSelect` 组件:第 644-722 行
|
||||
|
||||
- [ ] **Step 1: 实现 MonitoringView**
|
||||
|
||||
用完整实现替换 `src/modules/mileage/MonitoringView.tsx`。
|
||||
|
||||
**实现要求:**
|
||||
|
||||
1. **读取原型文件** `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx`,提取以下内容:
|
||||
- `SearchableSelect` 组件定义(第 644-722 行)— 在 MonitoringView 文件内部定义
|
||||
- `MileageView` 中 `activeSubTab === 'monitoring'` 分支的所有 JSX(第 1693-2295 行)
|
||||
|
||||
2. **数据来源变更**(将 mock 数据替换为 API 调用):
|
||||
- 删除对 `MOCK_VEHICLES` 的所有引用
|
||||
- 添加状态 `const [allVehicles, setAllVehicles] = useState<MonitoringVehicle[]>([]);`
|
||||
- 使用 `useEffect` 调用 `fetchMonitoring()` 加载数据,每 60 秒刷新
|
||||
- `filteredVehicles` 的 `useMemo` 改为对 `allVehicles` 进行过滤和排序
|
||||
- `departments`、`plateNumbers`、`projects` 从 `allVehicles` 提取唯一值
|
||||
- `stats` 的 `useMemo` 保持原逻辑,但基于 `filteredVehicles` 计算
|
||||
|
||||
3. **字段映射变更**:
|
||||
- `v.plateNumber` → `v.plate`
|
||||
- `v.customer` 保持不变
|
||||
- `v.department` 保持不变
|
||||
- `v.todayMileage` → `v.dailyKm`
|
||||
- `v.totalMileage` → `v.totalKm`
|
||||
- `v.isOnline` 保持不变
|
||||
- `v.isDataSynced` 保持不变
|
||||
- `v.id` → `v.plate`(作为 key)
|
||||
- `v.model` → 不可用,过滤中移除 model 匹配
|
||||
- `v.assetOwner` → 不可用,移除 entity 过滤逻辑
|
||||
- `v.location` → 不可用,移除 regionCode 过滤逻辑
|
||||
|
||||
4. **保持不变的部分**:
|
||||
- 所有 CSS className(Tailwind 类名 1:1 保留)
|
||||
- 所有动画(motion, AnimatePresence)
|
||||
- 全屏叠加层的完整 JSX
|
||||
- 高级筛选面板的完整 JSX(保留所有筛选器的 UI,即使部分过滤器暂无数据源如 entity/regionCode,保留 UI 但过滤逻辑可为空操作)
|
||||
- KPI 卡片布局
|
||||
- 车辆列表卡片样式
|
||||
- `toggleFullscreen` 函数
|
||||
|
||||
5. **Import 列表**(完整):
|
||||
```tsx
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Truck, Search, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: 验证 Vite 构建**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/modules/mileage/MonitoringView.tsx
|
||||
git commit -m "feat: 实现里程管理实时监控视图(1:1 复刻原型)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 — StatisticsView(统计报表)
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/modules/mileage/StatisticsView.tsx`
|
||||
|
||||
**重要:** 此组件需要 1:1 复刻原型。原型代码在 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx` 的以下行范围:
|
||||
- `StatisticsView` 组件:第 843-1364 行
|
||||
|
||||
- [ ] **Step 1: 实现 StatisticsView**
|
||||
|
||||
用完整实现替换 `src/modules/mileage/StatisticsView.tsx`。
|
||||
|
||||
**实现要求:**
|
||||
|
||||
1. **读取原型文件** `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx`,提取 `StatisticsView` 组件定义(第 843-1364 行)的完整 JSX。
|
||||
|
||||
2. **数据来源变更**:
|
||||
- 删除对 `MOCK_MODEL_STATS`、`MOCK_PROJECT_MILEAGE`、`MOCK_VEHICLES` 的引用
|
||||
- 添加状态:
|
||||
```tsx
|
||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [targetVehicles, setTargetVehicles] = useState<Record<number, TargetVehicle[]>>({});
|
||||
```
|
||||
- 使用 `useEffect` 调用 `fetchTargets()` 加载考核项目列表
|
||||
- `selectedProject` 改为 `selectedTargetId: number | null`,默认选第一个 target 的 id
|
||||
- 当 `selectedTargetId` 变化时调用 `fetchTrend(selectedTargetId)` 加载趋势数据
|
||||
- 展开车辆明细时调用 `fetchTargetVehicles(targetId)` 加载
|
||||
|
||||
3. **字段映射变更**(`MOCK_MODEL_STATS` 的字段 → `TargetSummary` 的字段):
|
||||
- `row.model` → `target.targetName`
|
||||
- `row.count` → `target.vehicleCount`
|
||||
- `row.target` → `target.totalMileagePerVehicle * target.vehicleCount`
|
||||
- `row.driven` → `target.cumulativeTotal`
|
||||
- `row.completion` → `target.avgCompletion`
|
||||
- `row.period` → `target.period`
|
||||
- `row.year1Target` → `target.annualMileagePerVehicle`
|
||||
- `row.reachedCount` → `target.yearQualifiedCount`
|
||||
- `row.halfReachedCount` → `target.halfQualifiedCount`
|
||||
- `row.todayTotal` → `target.todayTotal`
|
||||
- `row.currentYearTarget` → `target.currentYearTarget`
|
||||
- `row.completedAsOf` → `target.currentYearCompleted`
|
||||
- `row.remaining` → `target.remaining`
|
||||
- `row.daysLeft` → `target.daysLeft`
|
||||
- `row.dailyTarget` → `target.dailyTarget`
|
||||
|
||||
4. **项目选择器变更**:
|
||||
- `projectList` → `targets.map(t => t.targetName)`
|
||||
- 选择按钮的 onClick 改为设置 `selectedTargetId`
|
||||
- `currentData.trend` → `trendData`
|
||||
|
||||
5. **车辆明细变更**:
|
||||
- 原型中通过 `MOCK_VEHICLES.filter(v => ...)` 匹配车辆 → 改为使用 `targetVehicles[target.id]`
|
||||
- 车辆明细中 `v.plateNumber` → `tv.plateNumber`
|
||||
- `v.isOnline` → 不可用,暂时全部显示为在线
|
||||
- `v.todayMileage` → `tv.todayMileage`
|
||||
- `v.totalMileage` → `tv.totalMileage`
|
||||
|
||||
6. **查看全部侧滑面板变更**:
|
||||
- `viewAllModel` 改为 `viewAllTargetId: number | null`
|
||||
- 点击"查看全部"时设置 `viewAllTargetId` 并调用 `fetchTargetVehicles(id)`
|
||||
- 面板内车辆数据来自 `targetVehicles[viewAllTargetId]`
|
||||
|
||||
7. **保持不变**:
|
||||
- 所有 CSS className
|
||||
- 图表配置(Recharts BarChart/LineChart/AreaChart 的 props、样式、颜色)
|
||||
- 全屏表格叠加层的布局和样式
|
||||
- 侧滑面板的动画和布局
|
||||
- 展开/折叠的 AnimatePresence 动画
|
||||
|
||||
8. **Import 列表**(完整):
|
||||
```tsx
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: 验证 Vite 构建**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/modules/mileage/StatisticsView.tsx
|
||||
git commit -m "feat: 实现里程管理统计报表视图(1:1 复刻原型)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 集成验证
|
||||
|
||||
**Files:**
|
||||
- 无新增文件,全面验证
|
||||
|
||||
- [ ] **Step 1: 构建验证**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 2: 启动开发服务器验证**
|
||||
|
||||
Run: `npm run dev`
|
||||
|
||||
手动检查:
|
||||
1. 打开 `http://localhost:3000` → 侧边栏/底部导航有"里程管理"入口
|
||||
2. 点击进入里程管理 → 看到 3 个子 Tab(实时监控/统计报表/每日汇报)
|
||||
3. **实时监控 Tab**:
|
||||
- KPI 卡片显示真实数据(总里程、平均单车、监控台数)
|
||||
- 车辆列表加载 1004 辆车,按今日里程降序
|
||||
- 筛选器正常工作(按部门、按客户、按车牌)
|
||||
- 高级筛选面板可展开/折叠
|
||||
- 排序切换(今日/累计、升/降序)正常
|
||||
- 全屏模式正常打开/关闭
|
||||
4. **统计报表 Tab**:
|
||||
- 5 个项目按钮正确显示
|
||||
- 趋势图切换(柱状/折线/面积)正常
|
||||
- 车型考核汇总卡片展开/折叠正常
|
||||
- 展开详情显示真实考核数据
|
||||
- 车辆明细(前5台)正确显示
|
||||
- "查看全部"侧滑面板正常
|
||||
- 全屏表格正常
|
||||
5. **每日汇报 Tab**:显示"开发中"占位
|
||||
|
||||
- [ ] **Step 3: 提交(如有修复)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: 里程管理模块集成修复"
|
||||
```
|
||||
509
docs/superpowers/plans/2026-04-01-modular-refactor.md
Normal file
509
docs/superpowers/plans/2026-04-01-modular-refactor.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# 模块化重构实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将单体 App.tsx 拆分为模块化架构,支持多 BI 大类(资产管理、里程管理)通过全局导航切换。
|
||||
|
||||
**Architecture:** 新增 Shell 布局组件管理全局导航(Web 侧边栏 / 移动端底部导航),每个 BI 模块作为独立目录(modules/assets、modules/mileage),通过 hash 路由切换。现有资产管理逻辑原样迁入 modules/assets/,去掉其内部底部导航。
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, Lucide Icons, Vite
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| 创建 | `src/components/SearchSelect.tsx` | 从 App.tsx 抽取的公共搜索下拉组件 |
|
||||
| 创建 | `src/components/Shell.tsx` | 全局布局壳(侧边栏 + 底部导航 + 内容区) |
|
||||
| 移动 | `src/types.ts` → `src/modules/assets/types.ts` | 资产管理类型定义 |
|
||||
| 移动 | `src/api.ts` → `src/modules/assets/api.ts` | 资产管理 API 客户端 |
|
||||
| 创建 | `src/modules/assets/AssetsModule.tsx` | 资产管理主组件(现 App.tsx 逻辑迁入) |
|
||||
| 创建 | `src/modules/mileage/MileageModule.tsx` | 里程管理占位组件 |
|
||||
| 重写 | `src/App.tsx` | 顶层壳:模块注册 + Shell 渲染 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 抽取 SearchSelect 公共组件
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/SearchSelect.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 SearchSelect 组件文件**
|
||||
|
||||
从现有 `src/App.tsx` 第 38-106 行抽取 SearchSelect 组件,加上必要的 import:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export function SearchSelect({ value, onChange, options, placeholder, className }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return options;
|
||||
const q = query.toLowerCase();
|
||||
return options.filter((o) => o.toLowerCase().includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
const displayValue = value || '';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
|
||||
placeholder={displayValue || placeholder}
|
||||
value={open ? query : displayValue}
|
||||
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
|
||||
onFocus={() => { setOpen(true); setQuery(''); }}
|
||||
/>
|
||||
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
|
||||
<div
|
||||
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
{filtered.map((o) => (
|
||||
<div
|
||||
key={o}
|
||||
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
|
||||
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
|
||||
>
|
||||
{o}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-gray-400 text-center">无匹配项</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: 无新增错误(SearchSelect 尚未被引用,不影响现有代码)
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add src/components/SearchSelect.tsx
|
||||
git commit -m "refactor: 抽取 SearchSelect 为公共组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 移动 types.ts 和 api.ts 到 assets 模块
|
||||
|
||||
**Files:**
|
||||
- Move: `src/types.ts` → `src/modules/assets/types.ts`
|
||||
- Move: `src/api.ts` → `src/modules/assets/api.ts`
|
||||
- Modify: `src/App.tsx` (更新 import 路径)
|
||||
- Modify: `src/modules/assets/api.ts` (更新 import 路径)
|
||||
|
||||
- [ ] **Step 1: 创建目录并移动文件**
|
||||
|
||||
```bash
|
||||
mkdir -p src/modules/assets
|
||||
git mv src/types.ts src/modules/assets/types.ts
|
||||
git mv src/api.ts src/modules/assets/api.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 api.ts 内部的 import 路径**
|
||||
|
||||
`src/modules/assets/api.ts` 第 1-9 行,import 路径从 `'./types'` 保持不变(同目录),无需修改。
|
||||
|
||||
- [ ] **Step 3: 更新 App.tsx 的 import 路径**
|
||||
|
||||
将 `src/App.tsx` 中的两处 import:
|
||||
|
||||
```ts
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './modules/assets/types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './modules/assets/api';
|
||||
import type { WeeklyDetailItem } from './modules/assets/api';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS,无错误
|
||||
|
||||
- [ ] **Step 5: 验证开发服务器启动**
|
||||
|
||||
Run: `npm run dev:client` (启动后 Ctrl+C 关闭)
|
||||
Expected: Vite 正常启动,无编译错误
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: 移动 types.ts 和 api.ts 到 modules/assets/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 创建 AssetsModule 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `src/modules/assets/AssetsModule.tsx`
|
||||
- Modify: `src/App.tsx` (后续 Task 5 重写时替换)
|
||||
|
||||
这一步将 `src/App.tsx` 中 `export default function App()` 及其上方的 `TABS` 常量迁移为 `AssetsModule`,并做以下调整:
|
||||
|
||||
- [ ] **Step 1: 创建 AssetsModule.tsx**
|
||||
|
||||
复制 `src/App.tsx` 的全部内容到 `src/modules/assets/AssetsModule.tsx`,然后做以下修改:
|
||||
|
||||
**修改 1 — import 路径调整(文件顶部):**
|
||||
|
||||
将:
|
||||
```ts
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './modules/assets/types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './modules/assets/api';
|
||||
import type { WeeklyDetailItem } from './modules/assets/api';
|
||||
```
|
||||
改为:
|
||||
```ts
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
```
|
||||
|
||||
**修改 2 — SearchSelect 改为外部导入:**
|
||||
|
||||
删除文件中第 37-106 行的 SearchSelect 组件定义(`// --- SearchSelect Component ---` 到闭合的 `}`),替换为 import:
|
||||
|
||||
```ts
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
```
|
||||
|
||||
**修改 3 — 删除 import 中不再需要的图标:**
|
||||
|
||||
从 lucide-react import 中移除 `Users` 和 `Building2`(这两个只被底部导航使用,删除底部导航后不再需要)。`MapPin` 保留(在内容区域第 2106 行仍被使用)。
|
||||
|
||||
**修改 4 — 组件名改为 AssetsModule:**
|
||||
|
||||
将:
|
||||
```ts
|
||||
export default function App() {
|
||||
```
|
||||
改为:
|
||||
```ts
|
||||
export default function AssetsModule() {
|
||||
```
|
||||
|
||||
**修改 5 — 删除底部导航栏(原第 2772-2802 行):**
|
||||
|
||||
删除从 `{/* Footer / Navigation */}` 到其对应 `</div>` 的整个代码块:
|
||||
|
||||
```tsx
|
||||
{/* Footer / Navigation */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-40">
|
||||
...全部删除...
|
||||
</div>
|
||||
```
|
||||
|
||||
**修改 6 — 去掉底部导航的 padding 留白:**
|
||||
|
||||
将根 div 的 className:
|
||||
```
|
||||
min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 pb-20 md:pb-6 relative
|
||||
```
|
||||
改为:
|
||||
```
|
||||
min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative
|
||||
```
|
||||
|
||||
即去掉 `pb-20 md:pb-6`,统一使用 `p-6` 的 padding。
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS(AssetsModule 已自包含,App.tsx 仍引用旧路径但即将被重写)
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add src/modules/assets/AssetsModule.tsx
|
||||
git commit -m "refactor: 创建 AssetsModule,迁移资产管理逻辑"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 Shell 布局组件
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/Shell.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 Shell.tsx**
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, type ComponentType } from 'react';
|
||||
import { Truck, Route } from 'lucide-react';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
function getHashModule(modules: ModuleConfig[]): string {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return modules.some((m) => m.id === hash) ? hash : modules[0]?.id ?? '';
|
||||
}
|
||||
|
||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
const [activeModule, setActiveModule] = useState(() => getHashModule(modules));
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setActiveModule(getHashModule(modules));
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [modules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = modules[0]?.id ?? '';
|
||||
}
|
||||
}, [modules]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
window.location.hash = id;
|
||||
};
|
||||
|
||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Web 侧边栏 (md 及以上) */}
|
||||
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0">
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-[10px] mt-1">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS(Shell 尚未被引用)
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add src/components/Shell.tsx
|
||||
git commit -m "feat: 创建 Shell 布局组件(侧边栏 + 底部导航)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 创建里程管理占位组件
|
||||
|
||||
**Files:**
|
||||
- Create: `src/modules/mileage/MileageModule.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 MileageModule.tsx**
|
||||
|
||||
```tsx
|
||||
import { Route } from 'lucide-react';
|
||||
|
||||
export default function MileageModule() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<Route size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-500">里程管理</h2>
|
||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
mkdir -p src/modules/mileage
|
||||
git add src/modules/mileage/MileageModule.tsx
|
||||
git commit -m "feat: 创建里程管理占位组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 重写 App.tsx 为顶层壳
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: 重写 App.tsx**
|
||||
|
||||
用以下内容完全替换 `src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Truck, Route } from 'lucide-react';
|
||||
import { Shell, type ModuleConfig } from './components/Shell';
|
||||
import AssetsModule from './modules/assets/AssetsModule';
|
||||
import MileageModule from './modules/mileage/MileageModule';
|
||||
|
||||
const MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
return <Shell modules={MODULES} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 编译**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: PASS,无错误
|
||||
|
||||
- [ ] **Step 3: 验证 Vite 构建**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 构建成功,无错误
|
||||
|
||||
- [ ] **Step 4: 本地验证功能**
|
||||
|
||||
Run: `npm run dev`
|
||||
|
||||
手动检查:
|
||||
1. 打开 `http://localhost:3000` — 应看到左侧侧边栏(Web)或底部导航(移动端模拟)
|
||||
2. 默认进入资产管理,所有 Tab(总览/按部门/按区域/按客户)正常
|
||||
3. 数据正常加载显示
|
||||
4. 点击"里程管理"切换到占位页面
|
||||
5. 点击"资产管理"切回,数据和状态正常
|
||||
6. URL hash 随切换变化(`#assets` / `#mileage`)
|
||||
7. 原有移动端底部的资产内部导航已消失
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "refactor: 重写 App.tsx 为模块化顶层壳"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 清理旧文件引用
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/types.ts` (如果 git mv 未处理干净)
|
||||
- Delete: `src/api.ts` (如果 git mv 未处理干净)
|
||||
- Verify: 无残留的旧 import 路径
|
||||
|
||||
- [ ] **Step 1: 确认无残留文件**
|
||||
|
||||
```bash
|
||||
ls src/types.ts src/api.ts 2>/dev/null && echo "STALE FILES EXIST" || echo "CLEAN"
|
||||
```
|
||||
|
||||
Expected: `CLEAN`(Task 2 已用 `git mv` 移动)
|
||||
|
||||
如果有残留,删除它们:
|
||||
```bash
|
||||
rm -f src/types.ts src/api.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 确认无旧 import 引用**
|
||||
|
||||
```bash
|
||||
grep -r "from '\./types'" src/ --include="*.tsx" --include="*.ts" | grep -v modules/assets/ | grep -v node_modules
|
||||
grep -r "from '\./api'" src/ --include="*.tsx" --include="*.ts" | grep -v modules/assets/ | grep -v node_modules
|
||||
```
|
||||
|
||||
Expected: 无输出(所有引用都已更新到新路径)
|
||||
|
||||
- [ ] **Step 3: 最终构建验证**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 提交(如有清理)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status
|
||||
# 如果有变更则提交:
|
||||
git commit -m "chore: 清理残留文件和旧引用"
|
||||
```
|
||||
242
docs/superpowers/specs/2026-04-01-mileage-module-design.md
Normal file
242
docs/superpowers/specs/2026-04-01-mileage-module-design.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 里程管理模块设计
|
||||
|
||||
## 背景
|
||||
|
||||
在模块化重构的基础上,实现里程管理 BI 模块,1:1 复刻原型 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1` 中的里程管理部分。包含 3 个子 Tab:实时监控、统计报表、每日汇报(占位)。
|
||||
|
||||
## 目标
|
||||
|
||||
- 1:1 复刻原型 UI(样式、动画、交互细节完全一致)
|
||||
- 接入真实数据源(两个数据库)
|
||||
- 每日汇报 Tab 暂做占位
|
||||
|
||||
## 数据源
|
||||
|
||||
### 数据库 1:lingniu_prod(已有连接)
|
||||
|
||||
- `tab_mileage_assessment_target` — 5 个考核项目定义(目标名称、车辆数、年考核里程、考核年限等)
|
||||
- `tab_mileage_assessment_vehicle` — 492 辆考核车辆(今日里程、累计里程、完成率、达标状态等)
|
||||
- `tab_truck` → `tab_truck_status_info` → `tab_contract` → `tab_customer` / `tab_user` → `tab_department` — 车辆关联客户名、部门、经理
|
||||
|
||||
### 数据库 2:hydrogen_energy(新增连接)
|
||||
|
||||
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy`
|
||||
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source)
|
||||
|
||||
## 架构
|
||||
|
||||
### 后端
|
||||
|
||||
新增 `src/server/mileage-db.ts` — hydrogen_energy 数据库连接池。
|
||||
新增 `src/server/routes/mileage.ts` — 里程管理 API 路由。
|
||||
修改 `src/server/index.ts` — 注册新路由 `/api/mileage`。
|
||||
|
||||
### 前端
|
||||
|
||||
```
|
||||
src/modules/mileage/
|
||||
├── MileageModule.tsx # 主组件:3个子Tab切换(实时监控/统计报表/每日汇报)
|
||||
├── MonitoringView.tsx # 实时监控视图
|
||||
├── StatisticsView.tsx # 统计报表视图
|
||||
├── DailyReportView.tsx # 每日汇报(占位)
|
||||
├── api.ts # API 客户端
|
||||
└── types.ts # 类型定义
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
前端 MileageModule → fetch /api/mileage/*
|
||||
↓
|
||||
后端 mileage.ts 路由
|
||||
├── lingniu_prod 池:考核目标/车辆、车辆关联信息(客户/部门/经理)
|
||||
└── hydrogen_energy 池:v_vehicle_daily_stats(日里程/趋势)
|
||||
↓ 内存合并
|
||||
前端渲染(Recharts 图表 + 列表)
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
### `GET /api/mileage/monitoring`
|
||||
|
||||
实时监控数据:全部 1004 辆车的今日里程 + 关联信息。
|
||||
|
||||
**查询逻辑:**
|
||||
1. 从 `v_vehicle_daily_stats` 取最新日期的所有车辆数据(plate, daily_km, total_km, source)
|
||||
2. 从 `lingniu_prod` 取车辆关联信息(客户名、部门、经理),使用现有的 `MAIN_SQL` 关联链
|
||||
3. 内存按 plate 合并
|
||||
|
||||
**返回:**
|
||||
```ts
|
||||
{
|
||||
vehicles: Array<{
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
totalKm: number | null;
|
||||
source: string; // TBOX / G7S / NONE
|
||||
isOnline: boolean; // source !== 'NONE' && dailyKm > 0
|
||||
isDataSynced: boolean; // source !== 'NONE'
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
}>;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/mileage/targets`
|
||||
|
||||
考核项目列表 + 每个项目的汇总统计。
|
||||
|
||||
**查询逻辑:**
|
||||
1. 从 `tab_mileage_assessment_target` 取全部未删除项目
|
||||
2. 从 `tab_mileage_assessment_vehicle` 按 target_id 聚合统计
|
||||
|
||||
**返回:**
|
||||
```ts
|
||||
Array<{
|
||||
id: number;
|
||||
targetName: string;
|
||||
vehicleCount: number;
|
||||
totalMileagePerVehicle: number;
|
||||
annualMileagePerVehicle: number;
|
||||
assessmentYears: number;
|
||||
period: string; // "YYYY-MM-DD ~ YYYY-MM-DD"
|
||||
todayTotal: number; // SUM(today_mileage)
|
||||
cumulativeTotal: number; // SUM(current_mileage)
|
||||
avgCompletion: number; // AVG(completion_rate) * 100
|
||||
qualifiedCount: number; // SUM(is_qualified)
|
||||
yearQualifiedCount: number; // SUM(current_year_is_qualified)
|
||||
halfQualifiedCount: number; // completion_rate >= 0.5 的车辆数
|
||||
currentYearTarget: number; // SUM(current_year_mileage_task)
|
||||
currentYearCompleted: number; // SUM(current_year_mileage)
|
||||
remaining: number; // currentYearTarget - currentYearCompleted
|
||||
daysLeft: number; // current_year_assessment_end_date - today
|
||||
dailyTarget: number; // remaining / daysLeft
|
||||
}>
|
||||
```
|
||||
|
||||
### `GET /api/mileage/target/:id/vehicles`
|
||||
|
||||
某考核项目的车辆明细列表。
|
||||
|
||||
**查询逻辑:**
|
||||
从 `tab_mileage_assessment_vehicle` WHERE target_id = :id AND is_deleted = 0
|
||||
|
||||
**返回:**
|
||||
```ts
|
||||
Array<{
|
||||
plateNumber: string;
|
||||
todayMileage: number;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
isQualified: boolean;
|
||||
currentYearIsQualified: boolean;
|
||||
dailyRequiredMileage: number;
|
||||
}>
|
||||
```
|
||||
|
||||
### `GET /api/mileage/trend?targetId=...&days=7`
|
||||
|
||||
7天里程趋势,按考核项目筛选。
|
||||
|
||||
**查询逻辑:**
|
||||
1. 若有 targetId:从 `tab_mileage_assessment_vehicle` 取该项目的所有 plate_number
|
||||
2. 从 `v_vehicle_daily_stats` WHERE plate IN (...) AND stat_date >= (today - days) GROUP BY stat_date
|
||||
|
||||
**返回:**
|
||||
```ts
|
||||
Array<{
|
||||
date: string; // "MM-DD"
|
||||
mileage: number; // SUM(daily_km)
|
||||
}>
|
||||
```
|
||||
|
||||
## 前端组件设计
|
||||
|
||||
### MileageModule.tsx
|
||||
|
||||
主组件,管理子 Tab 切换(monitoring / statistics / report),包含:
|
||||
- 子导航栏(实时监控/统计报表/每日汇报),带 motion layoutId 动画下划线
|
||||
- 条件渲染对应 View 组件
|
||||
|
||||
### MonitoringView.tsx
|
||||
|
||||
1:1 复刻原型实时监控视图。
|
||||
|
||||
**状态:**
|
||||
- activeSubTab 由父组件管理
|
||||
- searchTerm, filterDept, filterPlate, filterProject, filterEntity, filterRegionCode, filterYear, filterDate, filterDateRange, filterMileageRange
|
||||
- sortBy ('today' | 'total'), sortOrder ('asc' | 'desc')
|
||||
- isFilterOpen, isFullscreen
|
||||
|
||||
**UI 结构:**
|
||||
1. 看板头部(标题 + 全屏按钮 + 排序切换)
|
||||
2. 快捷筛选栏(3 个 SearchableSelect + 高级筛选图标)
|
||||
3. 可展开高级筛选面板
|
||||
4. KPI 卡片网格(4列:总里程深色卡、平均单车、监控台数)
|
||||
5. 车辆详情清单(motion.div 列表)
|
||||
6. 全屏叠加层(AnimatePresence)
|
||||
|
||||
**SearchableSelect 组件:** 在 MonitoringView 内部定义(原型中的实现与公共 SearchSelect 不同,它使用 motion 动画、"无限制"默认选项、不同样式)。
|
||||
|
||||
### StatisticsView.tsx
|
||||
|
||||
1:1 复刻原型统计报表视图。
|
||||
|
||||
**状态:**
|
||||
- selectedProject, chartType ('bar' | 'line' | 'area')
|
||||
- isTableFullscreen, expandedModel, viewAllModel, viewAllSearch, viewAllSort
|
||||
|
||||
**UI 结构:**
|
||||
1. 项目选择器(横向滚动按钮组)
|
||||
2. 左侧:7天趋势图(Recharts BarChart/LineChart/AreaChart 切换)+ landscape KPI 卡片
|
||||
3. 右侧:车型考核里程汇总卡片列表(可展开详情 + 车辆明细前5台)
|
||||
4. 全屏表格叠加层(15列明细表)
|
||||
5. 查看全部侧滑面板(搜索 + 排序 + 车辆列表)
|
||||
|
||||
### DailyReportView.tsx
|
||||
|
||||
占位组件,显示"每日汇报 - 开发中"。
|
||||
|
||||
## 数据映射
|
||||
|
||||
### 实时监控
|
||||
|
||||
| UI 字段 | 数据来源 |
|
||||
|---------|---------|
|
||||
| 车牌号 | `v_vehicle_daily_stats.plate` |
|
||||
| 今日里程 | `daily_km`(最新日期) |
|
||||
| 累计里程 | `total_km`(最近非空值,用用户提供的变量填充 SQL) |
|
||||
| 在线状态 | `source !== 'NONE' && daily_km > 0` |
|
||||
| 数据同步 | `source !== 'NONE'` |
|
||||
| 客户名 | `lingniu_prod`: tab_truck → tab_truck_status_info → tab_contract → tab_customer.customer_name |
|
||||
| 部门 | `lingniu_prod`: → tab_user → tab_department.dep_name |
|
||||
|
||||
### 统计报表
|
||||
|
||||
| UI 字段 | 数据来源 |
|
||||
|---------|---------|
|
||||
| 项目列表 | `tab_mileage_assessment_target`(target_name, vehicle_count 等) |
|
||||
| 今日总里程 | `SUM(tab_mileage_assessment_vehicle.today_mileage)` by target_id |
|
||||
| 累计总里程 | `SUM(current_mileage)` by target_id |
|
||||
| 平均完成率 | `AVG(completion_rate) * 100` by target_id |
|
||||
| 达标车辆数 | `SUM(current_year_is_qualified)` by target_id |
|
||||
| 50%达标数 | `COUNT(completion_rate >= 0.5)` by target_id |
|
||||
| 考核区间 | `default_start_date ~ default_end_date` |
|
||||
| 年考核任务/辆 | `annual_mileage_per_vehicle` |
|
||||
| 本年需完成 | `SUM(current_year_mileage_task)` |
|
||||
| 已完成 | `SUM(current_year_mileage)` |
|
||||
| 未完成总数 | 本年需完成 - 已完成 |
|
||||
| 剩余天数 | `current_year_assessment_end_date - today`(取 vehicle 中的值) |
|
||||
| 日均需完成 | 未完成 / 剩余天数 |
|
||||
| 7天趋势 | `v_vehicle_daily_stats` 按项目车牌过滤聚合 |
|
||||
| 车辆明细 | `tab_mileage_assessment_vehicle` 的 plate_number, today_mileage, total_mileage 等 |
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 每日汇报 Tab 具体实现(占位)
|
||||
- landscape 适配(原型中有 landscape: 前缀样式,照搬即可但不做额外适配工作)
|
||||
- 后端缓存
|
||||
- 新增依赖
|
||||
106
docs/superpowers/specs/2026-04-01-modular-refactor-design.md
Normal file
106
docs/superpowers/specs/2026-04-01-modular-refactor-design.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 模块化重构设计:支持多 BI 大类
|
||||
|
||||
## 背景
|
||||
|
||||
当前项目是一个单体 BI 看板(资产管理),所有前端逻辑集中在 `App.tsx`(~2800 行)。后续需要扩展"里程管理"等新 BI 大类。本次重构目标是建立模块化架构,让新模块可以低成本接入,同时不影响现有功能。
|
||||
|
||||
## 目标
|
||||
|
||||
- 建立模块级拆分架构,每个 BI 大类作为独立模块
|
||||
- 新增全局导航:Web 端侧边栏、移动端底部导航栏
|
||||
- 去掉现有资产管理内部的移动端底部导航(与顶部 Tab 重复)
|
||||
- 里程管理模块仅做占位,具体功能后续实现
|
||||
- 后端不动
|
||||
|
||||
## 重构后目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.tsx # 顶层:Shell + hash 路由分发
|
||||
├── main.tsx # 入口(不变)
|
||||
├── index.css # 全局样式(不变)
|
||||
│
|
||||
├── components/ # 公共组件
|
||||
│ ├── Shell.tsx # 布局壳(侧边栏 + 底部导航 + 内容区)
|
||||
│ └── SearchSelect.tsx # 搜索下拉组件(从 App.tsx 抽出)
|
||||
│
|
||||
├── modules/
|
||||
│ ├── assets/ # 资产管理模块
|
||||
│ │ ├── AssetsModule.tsx # 现有 App.tsx 全部逻辑迁入
|
||||
│ │ ├── api.ts # 现有 src/api.ts 迁入
|
||||
│ │ └── types.ts # 现有 src/types.ts 迁入
|
||||
│ │
|
||||
│ └── mileage/ # 里程管理模块(占位)
|
||||
│ └── MileageModule.tsx # 占位组件
|
||||
│
|
||||
└── server/ # 后端完全不动
|
||||
```
|
||||
|
||||
## Shell 布局设计
|
||||
|
||||
### 模块注册
|
||||
|
||||
```ts
|
||||
const MODULES = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
// 未来新模块只需往数组加一项
|
||||
```
|
||||
|
||||
### Web 端(md 及以上)— 左侧侧边栏
|
||||
|
||||
- 侧边栏窄版(约 64px),图标 + 文字纵向排列
|
||||
- 固定定位,内容区有左 margin
|
||||
- 选中项高亮
|
||||
|
||||
### 移动端(md 以下)— 底部导航栏
|
||||
|
||||
- 底部固定导航栏,切换 BI 大类(资产管理 / 里程管理)
|
||||
- 替换掉现有资产管理内部的底部导航
|
||||
|
||||
### 路由机制
|
||||
|
||||
- `window.location.hash`:`#assets`、`#mileage`
|
||||
- 默认无 hash 时进入 `#assets`
|
||||
- Shell 监听 `hashchange` 事件切换模块
|
||||
- 不引入任何路由库
|
||||
|
||||
## AssetsModule 迁移策略
|
||||
|
||||
### 改动
|
||||
|
||||
1. 去掉底部导航栏(原 App.tsx 2773-2802 行)
|
||||
2. 去掉底部导航的 padding 留白
|
||||
3. SearchSelect 改为从 `components/SearchSelect.tsx` 导入
|
||||
4. 组件名从 `App` 改为 `AssetsModule`
|
||||
|
||||
### 不改
|
||||
|
||||
- 所有内部状态管理、Tab 切换、数据加载、图表、弹窗逻辑
|
||||
- 顶部 Tab 栏(总览/按部门/按区域/按客户)
|
||||
- API 调用逻辑(仅调整 import 路径)
|
||||
|
||||
## 文件迁移映射
|
||||
|
||||
| 原文件 | 目标 | 操作 |
|
||||
|--------|------|------|
|
||||
| `src/App.tsx` | `src/modules/assets/AssetsModule.tsx` | 迁移内容,去掉底部导航和 SearchSelect |
|
||||
| `src/api.ts` | `src/modules/assets/api.ts` | 直接移动 |
|
||||
| `src/types.ts` | `src/modules/assets/types.ts` | 直接移动 |
|
||||
| App.tsx 中 SearchSelect | `src/components/SearchSelect.tsx` | 抽取为独立文件 |
|
||||
|
||||
## 新增文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/App.tsx`(重写) | 模块注册 + Shell 渲染 |
|
||||
| `src/components/Shell.tsx` | 全局布局(侧边栏 / 底部导航 + 内容区) |
|
||||
| `src/modules/mileage/MileageModule.tsx` | 占位页面 |
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- AssetsModule 内部进一步拆分(保持现有结构)
|
||||
- 里程管理具体功能实现
|
||||
- 后端改动
|
||||
- 新增依赖
|
||||
2808
src/App.tsx
2808
src/App.tsx
File diff suppressed because it is too large
Load Diff
71
src/components/SearchSelect.tsx
Normal file
71
src/components/SearchSelect.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export function SearchSelect({ value, onChange, options, placeholder, className }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return options;
|
||||
const q = query.toLowerCase();
|
||||
return options.filter((o) => o.toLowerCase().includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
const displayValue = value || '';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
|
||||
placeholder={displayValue || placeholder}
|
||||
value={open ? query : displayValue}
|
||||
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
|
||||
onFocus={() => { setOpen(true); setQuery(''); }}
|
||||
/>
|
||||
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
|
||||
<div
|
||||
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
{filtered.map((o) => (
|
||||
<div
|
||||
key={o}
|
||||
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
|
||||
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
|
||||
>
|
||||
{o}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-gray-400 text-center">无匹配项</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/Shell.tsx
Normal file
84
src/components/Shell.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect, type ComponentType } from 'react';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
function getHashModule(modules: ModuleConfig[]): string {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return modules.some((m) => m.id === hash) ? hash : modules[0]?.id ?? '';
|
||||
}
|
||||
|
||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
const [activeModule, setActiveModule] = useState(() => getHashModule(modules));
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setActiveModule(getHashModule(modules));
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [modules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = modules[0]?.id ?? '';
|
||||
}
|
||||
}, [modules]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
window.location.hash = id;
|
||||
};
|
||||
|
||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Web 侧边栏 (md 及以上) */}
|
||||
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-[10px] mt-1">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2704
src/modules/assets/AssetsModule.tsx
Normal file
2704
src/modules/assets/AssetsModule.tsx
Normal file
File diff suppressed because it is too large
Load Diff
13
src/modules/mileage/DailyReportView.tsx
Normal file
13
src/modules/mileage/DailyReportView.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
export default function DailyReportView() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/modules/mileage/MileageModule.tsx
Normal file
58
src/modules/mileage/MileageModule.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
|
||||
export default function MileageModule() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
||||
{/* Sub-navigation — sticky */}
|
||||
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('monitoring')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<LayoutDashboard size={14} />
|
||||
<span className="text-[11px] font-bold">实时监控</span>
|
||||
{activeSubTab === 'monitoring' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('statistics')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
<span className="text-[11px] font-bold">统计报表</span>
|
||||
{activeSubTab === 'statistics' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('report')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span className="text-[11px] font-bold">每日汇报</span>
|
||||
{activeSubTab === 'report' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
<StatisticsView />
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
813
src/modules/mileage/MonitoringView.tsx
Normal file
813
src/modules/mileage/MonitoringView.tsx
Normal file
@@ -0,0 +1,813 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Truck, Search, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp,
|
||||
} from 'lucide-react';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
|
||||
const 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}
|
||||
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}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
|
||||
无匹配项
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MonitoringView() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterDept, setFilterDept] = useState('All');
|
||||
const [sortBy, setSortBy] = useState<'today' | 'total'>('today');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// New filters from image
|
||||
const [filterPlate, setFilterPlate] = useState('All');
|
||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||
const [filterProject, setFilterProject] = useState('All');
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
const [filterRegionCode, setFilterRegionCode] = useState('All');
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [filterDate, setFilterDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [] });
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const departments = filterOptions.departments;
|
||||
const plateNumbers = filterOptions.plates;
|
||||
|
||||
// 加载首页数据
|
||||
const loadFirstPage = useCallback(() => {
|
||||
fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
page: 1,
|
||||
search: searchTerm || undefined,
|
||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
project: filterProject !== 'All' ? filterProject : undefined,
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
}).then(d => {
|
||||
setVehicles(d.vehicles);
|
||||
setStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
setTotal(d.total);
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {});
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterPlate, appliedMileageRange, filterDate]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
const nextPage = page + 1;
|
||||
setLoadingMore(true);
|
||||
fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
page: nextPage,
|
||||
search: searchTerm || undefined,
|
||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
project: filterProject !== 'All' ? filterProject : undefined,
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
}).then(d => {
|
||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
useEffect(() => {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
||||
const loadMoreRef = useRef(loadMore);
|
||||
loadMoreRef.current = loadMore;
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMoreRef.current();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = topSentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setShowBackToTop(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
document.documentElement.scrollTop = 0;
|
||||
};
|
||||
|
||||
const filteredVehicles = vehicles;
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isFullscreen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
|
||||
<div ref={topSentinelRef} className="h-0" />
|
||||
|
||||
{/* Fullscreen Landscape View Overlay */}
|
||||
<AnimatePresence>
|
||||
{isFullscreen && (
|
||||
<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: title + KPI row + close */}
|
||||
<div className="flex-shrink-0 p-3 border-b border-slate-800 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-5 bg-blue-500 rounded-full"></div>
|
||||
<h2 className="text-white font-bold text-sm">全屏监控</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setFilterDept('All'); setFilterCustomer('All'); setFilterPlate('All'); setSearchTerm(''); }}
|
||||
className="p-1.5 bg-slate-800 text-slate-400 rounded-full hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={toggleFullscreen} className="p-1.5 bg-slate-800 text-slate-400 rounded-full hover:text-white transition-colors">
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* KPI — single row */}
|
||||
<div className="flex gap-2">
|
||||
<div className={`flex-1 bg-slate-900/50 border px-3 py-2 rounded-xl ${sortBy === 'today' ? 'border-blue-500/50' : 'border-slate-800'}`}>
|
||||
<div className="text-[8px] font-bold text-slate-500 uppercase">今日总里程</div>
|
||||
<div className="text-base font-black text-white">{Math.round(stats.totalToday).toLocaleString()} <span className="text-blue-400 text-[9px]">km</span></div>
|
||||
</div>
|
||||
<div className={`flex-1 bg-slate-900/50 border px-3 py-2 rounded-xl ${sortBy === 'total' ? 'border-blue-500/50' : 'border-slate-800'}`}>
|
||||
<div className="text-[8px] font-bold text-slate-500 uppercase">累计总里程</div>
|
||||
<div className="text-base font-black text-white">{Math.round(stats.totalAll).toLocaleString()} <span className="text-blue-400 text-[9px]">km</span></div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-900/50 border border-slate-800 px-3 py-2 rounded-xl">
|
||||
<div className="text-[8px] font-bold text-slate-500 uppercase">监控台数</div>
|
||||
<div className="text-base font-black text-white">{stats.vehicleCount} <span className="text-blue-400 text-[9px]">台</span></div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-900/50 border border-slate-800 px-3 py-2 rounded-xl">
|
||||
<div className="text-[8px] font-bold text-slate-500 uppercase">平均单车</div>
|
||||
<div className="text-base font-black text-white">{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)} <span className="text-blue-400 text-[9px]">km</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-slate-800 flex justify-between items-center flex-shrink-0">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">车辆实时明细数据</span>
|
||||
<div className="flex items-center gap-3 text-[9px] text-slate-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span>在线</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-500"></div>
|
||||
<span>离线</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400"></div>
|
||||
<span>未对接车机</span>
|
||||
</div>
|
||||
<span>最后更新: {new Date().toLocaleTimeString()}</span>
|
||||
</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">
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span>车牌号</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded-lg px-2 py-1 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterPlate}
|
||||
onChange={(e) => setFilterPlate(e.target.value)}
|
||||
>
|
||||
<option value="All">全部车牌</option>
|
||||
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">在线状态</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span>客户</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded-lg px-2 py-1 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterCustomer}
|
||||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||||
>
|
||||
<option value="All">全部客户</option>
|
||||
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span>业务部门</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded-lg px-2 py-1 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterDept}
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
>
|
||||
<option value="All">全部部门</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="p-4 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={() => {
|
||||
if (sortBy === 'today') {
|
||||
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
|
||||
} else {
|
||||
setSortBy('today');
|
||||
setSortOrder('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="p-4 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={() => {
|
||||
if (sortBy === 'total') {
|
||||
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
|
||||
} else {
|
||||
setSortBy('total');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>累计里程</span>
|
||||
{sortBy === 'total' && (
|
||||
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{filteredVehicles.map((v) => (
|
||||
<tr key={v.plate} className="hover:bg-slate-800/30 transition-colors group">
|
||||
<td className="p-4 text-sm font-bold text-white">{v.plate}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${v.isOnline ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : 'bg-slate-600'}`}></div>
|
||||
<span className={`text-[10px] font-bold ${v.isOnline ? 'text-green-500' : 'text-slate-500'}`}>
|
||||
{v.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-xs text-slate-400">{v.customer}</td>
|
||||
<td className="p-4 text-xs text-slate-400">{v.department || v.rentStatus || ''}</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!v.isDataSynced && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></div>
|
||||
)}
|
||||
<span className={`text-sm font-mono font-bold ${v.isDataSynced ? 'text-blue-400' : 'text-amber-400'}`}>
|
||||
{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-500 font-bold">km</span>
|
||||
</span>
|
||||
</div>
|
||||
{!v.isDataSynced && <span className="text-[8px] text-amber-500/50 font-bold">未对接</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className={`text-sm font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-amber-400/70'}`}>
|
||||
{v.totalKm?.toLocaleString()} <span className="text-[8px] text-slate-500 font-bold">km</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-2 py-0.5 rounded-full ${v.isOnline ? 'bg-green-500/10 text-green-500' : 'bg-slate-500/10 text-slate-500'} text-[9px] font-bold uppercase`}>
|
||||
{v.isOnline ? '运行中' : '静止/离线'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Ultra Compact Header - Two Rows */}
|
||||
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3">
|
||||
{/* Top Row: Title & Sort */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-8 bg-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-none">里程看板</h1>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors"
|
||||
title="全屏视图"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">实时监控 • 每分钟更新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
onClick={() => setSortBy('today')}
|
||||
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
|
||||
>
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('total')}
|
||||
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
|
||||
>
|
||||
累计
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-slate-200 mx-0.5"></div>
|
||||
<button
|
||||
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||
className="p-1 text-blue-600 hover:bg-white rounded-md transition-all"
|
||||
>
|
||||
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<SearchableSelect
|
||||
options={departments}
|
||||
value={filterDept}
|
||||
onChange={setFilterDept}
|
||||
placeholder="按部门"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={filterOptions.customers}
|
||||
value={filterCustomer}
|
||||
onChange={setFilterCustomer}
|
||||
placeholder="按客户"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={plateNumbers}
|
||||
value={filterPlate}
|
||||
onChange={setFilterPlate}
|
||||
placeholder="按车牌"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Filter Panel */}
|
||||
<AnimatePresence>
|
||||
{isFilterOpen && (
|
||||
<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 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询日期</label>
|
||||
<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={filterDate}
|
||||
onChange={(e) => setFilterDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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) => setFilterProject(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</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) => setFilterDept(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 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) => setFilterEntity(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.entities.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region Code */}
|
||||
<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={filterRegionCode}
|
||||
onChange={(e) => setFilterRegionCode(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="330400">330400 (嘉兴)</option>
|
||||
<option value="440100">440100 (广州)</option>
|
||||
<option value="110100">110100 (北京)</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) => setFilterMileageRange(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) => setFilterMileageRange(prev => ({ ...prev, max: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setFilterDept('All');
|
||||
setFilterPlate('All');
|
||||
setFilterCustomer('All');
|
||||
setFilterProject('All');
|
||||
setFilterEntity('All');
|
||||
setFilterRegionCode('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
}}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
重置所有
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAppliedMileageRange({ ...filterMileageRange });
|
||||
setIsFilterOpen(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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Active Filter Tags */}
|
||||
{(() => {
|
||||
const tags: { label: string; onClear: () => void }[] = [];
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept}`, onClear: () => setFilterDept('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
||||
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
|
||||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
|
||||
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
|
||||
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
|
||||
if (filterRegionCode !== 'All') tags.push({ label: `地区: ${filterRegionCode}`, onClear: () => setFilterRegionCode('All') });
|
||||
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||||
if (tags.length === 0) return null;
|
||||
const clearAll = () => {
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterPlate('All'); setSearchTerm(''); setFilterRegionCode('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterDate('');
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold">
|
||||
{tag.label}
|
||||
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Sticky header: KPI + 清单标题 */}
|
||||
<div className="sticky top-[44px] z-20 bg-[#F8F9FB] pt-1 pb-1 space-y-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
|
||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? '今日' : '累计'}总里程</div>
|
||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||
{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()}
|
||||
<span className="text-[8px] text-slate-400">km</span>
|
||||
{sortBy === 'today' && stats.yesterdayTotal > 0 && (() => {
|
||||
const change = ((stats.totalToday - stats.yesterdayTotal) / stats.yesterdayTotal) * 100;
|
||||
const isUp = change >= 0;
|
||||
return <span className={`text-[9px] font-bold ${isUp ? 'text-blue-400' : 'text-rose-400'}`}>{isUp ? '\u2191' : '\u2193'}{Math.abs(change).toFixed(1)}%</span>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
|
||||
<div className="text-[7px] text-slate-400">km/台</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{stats.vehicleCount}</div>
|
||||
<div className="text-[7px] text-slate-400">台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<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>
|
||||
|
||||
{/* Vehicle List */}
|
||||
<div className="space-y-1.5">
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{filteredVehicles.map((v) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key={v.plate}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(v.plate);
|
||||
}}
|
||||
>
|
||||
<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 ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
|
||||
</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">{v.plate}</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>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{v.department ? v.department.replace('业务', '') : v.rentStatus || ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate">{v.customer || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2 flex flex-col items-end">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
{!v.isDataSynced && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||
)}
|
||||
<div className={`text-sm font-black leading-none ${v.isDataSynced ? 'text-blue-600' : 'text-amber-600'}`}>
|
||||
{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-400">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[8px] font-bold text-slate-300">
|
||||
{v.totalKm?.toLocaleString()} km
|
||||
</span>
|
||||
{!v.isDataSynced && (
|
||||
<span className="text-[7px] font-bold text-amber-500/70 bg-amber-50 px-1 rounded">未同步</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredVehicles.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 && filteredVehicles.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>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<AnimatePresence>
|
||||
{showBackToTop && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 w-10 h-10 bg-blue-600 text-white rounded-full shadow-lg shadow-blue-200 flex items-center justify-center active:scale-95 transition-transform"
|
||||
>
|
||||
<ChevronsUp size={18} />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
575
src/modules/mileage/StatisticsView.tsx
Normal file
575
src/modules/mileage/StatisticsView.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export default function StatisticsView() {
|
||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||
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 [expandedModel, setExpandedModel] = useState<string | null>(null);
|
||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Load targets on mount
|
||||
useEffect(() => {
|
||||
fetchTargets().then(data => {
|
||||
setTargets(data);
|
||||
if (data.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(data[0].id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load trend when selectedTargetId changes
|
||||
useEffect(() => {
|
||||
if (selectedTargetId === null) return;
|
||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||
}, [selectedTargetId]);
|
||||
|
||||
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" style={{ overflowX: 'clip' }}>
|
||||
{/* Project Selector - Full width even in landscape */}
|
||||
<div className="bg-white landscape:bg-slate-900/50 landscape:border-slate-800 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)}
|
||||
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 landscape:bg-slate-800 text-slate-500 landscape:text-slate-400 hover:bg-slate-100 landscape:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.targetName)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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 */}
|
||||
<div className="hidden landscape:grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">今日总里程</div>
|
||||
<div className="text-xl font-black text-white tracking-tighter">
|
||||
{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}
|
||||
<span className="text-blue-400 text-[10px] ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">累计总里程</div>
|
||||
<div className="text-xl font-black text-white tracking-tighter">
|
||||
{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}
|
||||
<span className="text-blue-400 text-[10px] ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">总考核车辆</div>
|
||||
<div className="text-xl font-black text-white tracking-tighter">
|
||||
{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}
|
||||
<span className="text-blue-400 text-[10px] ml-1">台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">平均完成率</div>
|
||||
<div className="text-xl font-black text-white tracking-tighter">
|
||||
{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}
|
||||
<span className="text-blue-400 text-[10px] ml-1">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white landscape:bg-slate-900/40 landscape:border-slate-800 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 landscape:text-white">7天里程趋势</h3>
|
||||
</div>
|
||||
<div className="flex bg-slate-50 landscape:bg-slate-800 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 landscape:bg-slate-700 text-blue-600 landscape:text-blue-400 shadow-sm' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 w-full min-h-[250px] relative">
|
||||
<div className="absolute inset-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" strokeOpacity={0.1} />
|
||||
<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="#f1f5f9" strokeOpacity={0.1} />
|
||||
<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="#f1f5f9" strokeOpacity={0.1} />
|
||||
<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>
|
||||
</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 items-center gap-2">
|
||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest">车型考核里程汇总</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsTableFullscreen(true)}
|
||||
className="p-1.5 bg-white landscape:bg-slate-800 text-slate-400 rounded-lg border border-slate-100 landscape:border-slate-700 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">
|
||||
{targets.map((target, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white landscape:bg-slate-900/50 px-3 py-2 rounded-xl border border-slate-50 landscape:border-slate-800 shadow-sm flex flex-col active:bg-slate-50 landscape:active:bg-slate-800 transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
const name = target.targetName;
|
||||
setExpandedModel(expandedModel === name ? null : name);
|
||||
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 landscape:bg-slate-800 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 landscape:text-white">{target.targetName}</span>
|
||||
<span className="text-[8px] px-1 rounded bg-blue-50 landscape:bg-blue-900/30 text-blue-600 landscape:text-blue-400 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">完成率:</span>
|
||||
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">达标:</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 landscape:text-slate-400">{target.yearQualifiedCount}台</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 landscape:text-white 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: expandedModel === target.targetName ? 180 : 0 }}
|
||||
className="text-slate-300"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedModel === target.targetName && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-3 mt-2 border-t border-slate-50 landscape:border-slate-800 grid grid-cols-2 gap-x-4 gap-y-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 landscape:text-slate-300">{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 landscape:text-slate-300">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</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 landscape:text-slate-300">{fmtKm(target.annualMileagePerVehicle)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
||||
<p className="text-[10px] font-black text-blue-600 landscape:text-blue-400">{target.halfQualifiedCount} 台</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 landscape:text-slate-300">{fmtKm(target.currentYearTarget)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">已完成(截止3.31)</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</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-rose-500">{fmtKm(target.remaining)} km</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-blue-500">{fmtKm(target.dailyTarget)} km</p>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 landscape:bg-slate-800 p-2 rounded-lg">
|
||||
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
||||
<span className="text-[10px] font-black text-slate-900 landscape:text-white">{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);
|
||||
if (!targetVehiclesMap[target.id]) {
|
||||
fetchTargetVehicles(target.id).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}}
|
||||
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 landscape:bg-slate-800/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 landscape:text-slate-300">{tv.plateNumber}</span>
|
||||
<span className="text-[7px] px-1 rounded bg-green-100 landscape:bg-green-900/30 text-green-600 landscape:text-green-400 font-bold">
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-blue-600 landscape:text-blue-400">{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>
|
||||
</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 p-4 landscape:flex-row gap-4 overflow-hidden"
|
||||
>
|
||||
{/* Sidebar with KPI Cards */}
|
||||
<div className="flex flex-col gap-4 w-full landscape:w-72 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||
<h2 className="text-white font-bold text-lg">车型考核汇总</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsTableFullscreen(false)}
|
||||
className="p-2 bg-slate-800 text-slate-400 rounded-full hover:text-white transition-colors"
|
||||
>
|
||||
<Minimize2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 landscape:grid-cols-1 gap-3">
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">今日总里程</div>
|
||||
<div className="text-2xl font-black text-white tracking-tighter">
|
||||
{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}
|
||||
<span className="text-blue-400 text-xs ml-2">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">累计总里程</div>
|
||||
<div className="text-2xl font-black text-white tracking-tighter">
|
||||
{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}
|
||||
<span className="text-blue-400 text-xs ml-2">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">总考核车辆</div>
|
||||
<div className="text-2xl font-black text-white tracking-tighter">
|
||||
{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}
|
||||
<span className="text-blue-400 text-xs ml-2">台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">平均完成率</div>
|
||||
<div className="text-2xl font-black text-white tracking-tighter">
|
||||
{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}
|
||||
<span className="text-blue-400 text-xs ml-2">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-900/30 border border-slate-800 rounded-2xl overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">车型考核明细数据</span>
|
||||
<span className="text-[10px] text-slate-500">最后更新: {new Date().toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-left border-collapse min-w-[1200px]">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<tr className="border-b border-slate-800">
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-10">车型</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">车辆数</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">总考核里程</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">已行驶总里程</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">总完成率</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">考核区间</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">年考核任务/辆</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-emerald-400">达标车辆数</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-blue-400">50%达标数</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-white">今日总里程</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">本年需完成</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">已完成(截止3.31)</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-rose-400">未完成总数</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">剩余天数</th>
|
||||
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-blue-400">日均需完成</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{targets.map((target, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="p-4 text-sm font-bold text-white sticky left-0 bg-slate-900 z-10 border-r border-slate-800">{target.targetName}</td>
|
||||
<td className="p-4 text-xs text-slate-300">{target.vehicleCount}</td>
|
||||
<td className="p-4 text-xs text-slate-300">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</td>
|
||||
<td className="p-4 text-xs text-slate-300">{fmtKm(target.cumulativeTotal)} km</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden min-w-[60px]">
|
||||
<div
|
||||
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 80 ? 'bg-blue-500' : 'bg-amber-500'}`}
|
||||
style={{ width: `${target.avgCompletion}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-white">{target.avgCompletion.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-[10px] text-slate-400">{target.periods.join('\n')}</td>
|
||||
<td className="p-4 text-xs text-slate-300">{fmtKm(target.annualMileagePerVehicle)} km</td>
|
||||
<td className="p-4 text-xs font-bold text-emerald-400">{target.yearQualifiedCount}</td>
|
||||
<td className="p-4 text-xs font-bold text-blue-400">{target.halfQualifiedCount}</td>
|
||||
<td className="p-4 text-xs font-bold text-white">{fmtKm(target.todayTotal)} km</td>
|
||||
<td className="p-4 text-xs text-slate-300">{fmtKm(target.currentYearTarget)} km</td>
|
||||
<td className="p-4 text-xs text-slate-300">{fmtKm(target.currentYearCompleted)} km</td>
|
||||
<td className="p-4 text-xs font-bold text-rose-400">{fmtKm(target.remaining)} km</td>
|
||||
<td className="p-4 text-xs text-slate-300">{target.daysLeft}</td>
|
||||
<td className="p-4 text-xs font-bold text-blue-400">{target.dailyTarget} km</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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-4 border-b border-slate-50 space-y-3">
|
||||
<div className="relative">
|
||||
<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-4 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="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">排序方式: 今日里程</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-slate-50 p-4 rounded-2xl border border-slate-100 flex items-center justify-between group hover:border-blue-200 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-white border border-slate-100 flex items-center justify-center shadow-sm">
|
||||
<Truck size={18} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-slate-900 font-mono">{tv.plateNumber}</span>
|
||||
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-bold bg-green-100 text-green-600">
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 mt-0.5"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-black text-blue-600">{tv.todayMileage} <span className="text-[9px] text-slate-400">KM</span></div>
|
||||
<div className="text-[9px] font-bold text-slate-400 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/modules/mileage/api.ts
Normal file
57
src/modules/mileage/api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
|
||||
const BASE = '/api/mileage';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMonitoring(params?: {
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
search?: string;
|
||||
dept?: string;
|
||||
customer?: string;
|
||||
project?: string;
|
||||
entity?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
date?: string;
|
||||
}): Promise<MonitoringData> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||
if (params?.sortOrder) query.set('sortOrder', params.sortOrder);
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.dept) query.set('dept', params.dept);
|
||||
if (params?.customer) query.set('customer', params.customer);
|
||||
if (params?.project) query.set('project', params.project);
|
||||
if (params?.entity) query.set('entity', params.entity);
|
||||
if (params?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
if (params?.date) query.set('date', params.date);
|
||||
const qs = query.toString();
|
||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function fetchTargets(): Promise<TargetSummary[]> {
|
||||
return fetchJson<TargetSummary[]>(`${BASE}/targets`);
|
||||
}
|
||||
|
||||
export async function fetchTargetVehicles(targetId: number): Promise<TargetVehicle[]> {
|
||||
return fetchJson<TargetVehicle[]>(`${BASE}/target/${targetId}/vehicles`);
|
||||
}
|
||||
|
||||
export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoint[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (targetId) params.set('targetId', String(targetId));
|
||||
params.set('days', String(days));
|
||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
||||
}
|
||||
76
src/modules/mileage/types.ts
Normal file
76
src/modules/mileage/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface MonitoringVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
isOnline: boolean;
|
||||
isDataSynced: boolean;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
rentStatus: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringStats {
|
||||
totalToday: number;
|
||||
totalAll: number;
|
||||
vehicleCount: number;
|
||||
yesterdayTotal: number;
|
||||
}
|
||||
|
||||
export interface MonitoringFilters {
|
||||
departments: string[];
|
||||
customers: string[];
|
||||
plates: string[];
|
||||
projects: string[];
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
vehicles: MonitoringVehicle[];
|
||||
stats: MonitoringStats;
|
||||
filters: MonitoringFilters;
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TargetSummary {
|
||||
id: number;
|
||||
targetName: string;
|
||||
vehicleCount: number;
|
||||
totalMileagePerVehicle: number;
|
||||
annualMileagePerVehicle: number;
|
||||
assessmentYears: number;
|
||||
periods: string[];
|
||||
todayTotal: number;
|
||||
cumulativeTotal: number;
|
||||
avgCompletion: number;
|
||||
qualifiedCount: number;
|
||||
yearQualifiedCount: number;
|
||||
halfQualifiedCount: number;
|
||||
currentYearTarget: number;
|
||||
currentYearCompleted: number;
|
||||
remaining: number;
|
||||
daysLeft: number;
|
||||
dailyTarget: number;
|
||||
}
|
||||
|
||||
export interface TargetVehicle {
|
||||
plateNumber: string;
|
||||
todayMileage: number;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
isQualified: boolean;
|
||||
currentYearIsQualified: boolean;
|
||||
dailyRequiredMileage: number;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
mileage: number;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import dotenv from 'dotenv';
|
||||
import vehiclesRouter from './routes/vehicles.js';
|
||||
import mileageRouter from './routes/mileage.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -11,6 +12,7 @@ const app = new Hono();
|
||||
|
||||
app.use('/api/*', cors());
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||
|
||||
|
||||
14
src/server/mileage-db.ts
Normal file
14
src/server/mileage-db.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const mileagePool = mysql.createPool({
|
||||
host: '101.133.130.65',
|
||||
port: 3306,
|
||||
user: 'bi_reader_02',
|
||||
password: 'bi_reader_02_Pass',
|
||||
database: 'hydrogen_energy',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export default mileagePool;
|
||||
471
src/server/routes/mileage.ts
Normal file
471
src/server/routes/mileage.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../db.js';
|
||||
import mileagePool from '../mileage-db.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 车辆关联信息 SQL(客户名、部门、经理)
|
||||
const VEHICLE_INFO_SQL = `SELECT
|
||||
truck.plate_number AS plate,
|
||||
cus.customer_name AS customer,
|
||||
dep.dep_name AS department,
|
||||
u.user_name AS manager,
|
||||
dic_status.dic_name AS rent_status,
|
||||
org_truck.org_name AS entity,
|
||||
c.project_name AS project
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
|
||||
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
|
||||
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
|
||||
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
|
||||
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
|
||||
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
|
||||
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
|
||||
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
|
||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
|
||||
|
||||
// ========== 实时监控缓存(每2分钟刷新) ==========
|
||||
interface CachedVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
isOnline: boolean;
|
||||
isDataSynced: boolean;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
rentStatus: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
yesterdayKm: number;
|
||||
}
|
||||
|
||||
interface MonitoringCache {
|
||||
vehicles: CachedVehicle[];
|
||||
stats: { totalToday: number; totalAll: number; vehicleCount: number };
|
||||
filters: { departments: string[]; customers: string[]; plates: string[]; projects: string[]; entities: string[] };
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
let monitoringCache: MonitoringCache | null = null;
|
||||
|
||||
async function refreshMonitoringCache() {
|
||||
try {
|
||||
console.log('[mileage] refreshing monitoring cache...');
|
||||
const start = Date.now();
|
||||
|
||||
// 并行查询两个数据库
|
||||
const [mileageResult, yesterdayResult, infoRows] = await Promise.all([
|
||||
(async () => {
|
||||
const [dateRows] = await mileagePool.execute(
|
||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||||
) as any;
|
||||
const latestDate = dateRows[0]?.latest;
|
||||
if (!latestDate) return [];
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT plate, vin, daily_km, total_km, source
|
||||
FROM v_vehicle_daily_stats WHERE stat_date = ?`,
|
||||
[latestDate]
|
||||
) as any;
|
||||
return rows;
|
||||
})(),
|
||||
(async () => {
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT plate, daily_km FROM v_vehicle_daily_stats
|
||||
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
|
||||
) as any;
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const existing = map.get(r.plate) || 0;
|
||||
const km = Number(r.daily_km) || 0;
|
||||
if (km > existing) map.set(r.plate, km);
|
||||
}
|
||||
return map;
|
||||
})(),
|
||||
pool.execute(VEHICLE_INFO_SQL).then(([rows]) => rows as any[]),
|
||||
]);
|
||||
|
||||
// 车辆关联信息 map
|
||||
const infoMap = new Map<string, any>();
|
||||
for (const row of infoRows) {
|
||||
infoMap.set(row.plate, row);
|
||||
}
|
||||
|
||||
// 去重:同一 plate 取 daily_km 最大的
|
||||
const mileageMap = new Map<string, any>();
|
||||
for (const row of mileageResult) {
|
||||
const existing = mileageMap.get(row.plate);
|
||||
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
|
||||
mileageMap.set(row.plate, row);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并
|
||||
const vehicles: CachedVehicle[] = Array.from(mileageMap.values()).map((m: any) => {
|
||||
const info = infoMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const source = m.source || 'NONE';
|
||||
return {
|
||||
plate: m.plate,
|
||||
vin: m.vin,
|
||||
dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
source,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
isDataSynced: source !== 'NONE',
|
||||
customer: info?.customer || null,
|
||||
department: info?.department || null,
|
||||
manager: info?.manager || null,
|
||||
rentStatus: info?.rent_status || null,
|
||||
entity: info?.entity || null,
|
||||
project: info?.project || null,
|
||||
yesterdayKm: yesterdayResult.get(m.plate) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 预计算统计信息
|
||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||
|
||||
|
||||
// 预提取筛选选项
|
||||
const deptOrder = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
const departments = (Array.from(new Set(vehicles.map(v => v.department).filter(Boolean))) as string[])
|
||||
.sort((a, b) => {
|
||||
const ai = deptOrder.findIndex(d => a.includes(d));
|
||||
const bi = deptOrder.findIndex(d => b.includes(d));
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter(Boolean))) as string[];
|
||||
const plates = vehicles.map(v => v.plate);
|
||||
const projects = Array.from(new Set(vehicles.map(v => v.project).filter(Boolean))) as string[];
|
||||
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter(Boolean))) as string[];
|
||||
|
||||
monitoringCache = {
|
||||
vehicles,
|
||||
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
|
||||
filters: { departments, customers, plates, projects, entities },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
|
||||
} catch (e) {
|
||||
console.error('[mileage] cache refresh error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时立即刷新,之后每2分钟刷新
|
||||
refreshMonitoringCache();
|
||||
setInterval(refreshMonitoringCache, 2 * 60 * 1000);
|
||||
|
||||
// 查询指定日期的里程数据(非缓存)
|
||||
async function queryDateMileage(dateStr: string): Promise<{ vehicles: CachedVehicle[] }> {
|
||||
const [mileageRows, yesterdayRows, infoRows] = await Promise.all([
|
||||
mileagePool.execute(`SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?`, [dateStr]).then(([r]) => r as any[]),
|
||||
mileagePool.execute(`SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)`, [dateStr]).then(([r]) => r as any[]),
|
||||
pool.execute(VEHICLE_INFO_SQL).then(([r]) => r as any[]),
|
||||
]);
|
||||
const infoMap = new Map<string, any>();
|
||||
for (const row of infoRows) infoMap.set(row.plate, row);
|
||||
const yesterdayMap = new Map<string, number>();
|
||||
for (const r of yesterdayRows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const existing = yesterdayMap.get(r.plate) || 0;
|
||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||
}
|
||||
const mileageMap = new Map<string, any>();
|
||||
for (const row of mileageRows) {
|
||||
const existing = mileageMap.get(row.plate);
|
||||
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) mileageMap.set(row.plate, row);
|
||||
}
|
||||
const vehicles: CachedVehicle[] = Array.from(mileageMap.values()).map((m: any) => {
|
||||
const info = infoMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const source = m.source || 'NONE';
|
||||
return {
|
||||
plate: m.plate, vin: m.vin, dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
source, isOnline: source !== 'NONE' && dailyKm > 0, isDataSynced: source !== 'NONE',
|
||||
customer: info?.customer || null, department: info?.department || null,
|
||||
manager: info?.manager || null, rentStatus: info?.rent_status || null,
|
||||
entity: info?.entity || null, project: info?.project || null,
|
||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||
};
|
||||
});
|
||||
return { vehicles };
|
||||
}
|
||||
|
||||
// GET /monitoring — 从缓存取数据(或指定日期实时查询),支持筛选/排序/分页
|
||||
app.get('/monitoring', async (c) => {
|
||||
const emptyResponse = { vehicles: [], stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, filters: { departments: [], customers: [], plates: [], projects: [], entities: [] }, total: 0, page: 1, totalPages: 1, updatedAt: new Date().toISOString() };
|
||||
|
||||
const sortBy = c.req.query('sortBy') || 'today';
|
||||
const sortOrder = c.req.query('sortOrder') || 'desc';
|
||||
const limit = Number(c.req.query('limit')) || 50;
|
||||
const page = Number(c.req.query('page')) || 1;
|
||||
const search = c.req.query('search') || '';
|
||||
const dept = c.req.query('dept') || '';
|
||||
const customer = c.req.query('customer') || '';
|
||||
const project = c.req.query('project') || '';
|
||||
const entity = c.req.query('entity') || '';
|
||||
const mileageMin = c.req.query('mileageMin') || '';
|
||||
const mileageMax = c.req.query('mileageMax') || '';
|
||||
const plate = c.req.query('plate') || '';
|
||||
const date = c.req.query('date') || '';
|
||||
|
||||
let allVehicles: CachedVehicle[];
|
||||
let filters: MonitoringCache['filters'];
|
||||
|
||||
if (date) {
|
||||
// 指定日期:实时查询
|
||||
try {
|
||||
const result = await queryDateMileage(date);
|
||||
allVehicles = result.vehicles;
|
||||
const deptOrder = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
filters = {
|
||||
departments: (Array.from(new Set(allVehicles.map(v => v.department).filter(Boolean))) as string[]).sort((a, b) => {
|
||||
const ai = deptOrder.findIndex(d => a.includes(d)); const bi = deptOrder.findIndex(d => b.includes(d));
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
}),
|
||||
customers: Array.from(new Set(allVehicles.map(v => v.customer).filter(Boolean))) as string[],
|
||||
plates: allVehicles.map(v => v.plate),
|
||||
projects: Array.from(new Set(allVehicles.map(v => v.project).filter(Boolean))) as string[],
|
||||
entities: Array.from(new Set(allVehicles.map(v => v.entity).filter(Boolean))) as string[],
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('monitoring date query error:', e);
|
||||
return c.json(emptyResponse, 500);
|
||||
}
|
||||
} else {
|
||||
if (!monitoringCache) return c.json(emptyResponse);
|
||||
allVehicles = monitoringCache.vehicles;
|
||||
filters = monitoringCache.filters;
|
||||
}
|
||||
|
||||
let vehicles = allVehicles;
|
||||
|
||||
// 筛选
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
vehicles = vehicles.filter(v =>
|
||||
v.plate.toLowerCase().includes(q) ||
|
||||
(v.customer || '').toLowerCase().includes(q) ||
|
||||
(v.project || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (dept) vehicles = vehicles.filter(v => v.department === dept);
|
||||
if (customer) vehicles = vehicles.filter(v => v.customer === customer);
|
||||
if (project) vehicles = vehicles.filter(v => v.project === project);
|
||||
if (entity) vehicles = vehicles.filter(v => v.entity === entity);
|
||||
if (plate) vehicles = vehicles.filter(v => v.plate === plate);
|
||||
if (mileageMin) vehicles = vehicles.filter(v => v.dailyKm >= Number(mileageMin));
|
||||
if (mileageMax) vehicles = vehicles.filter(v => v.dailyKm <= Number(mileageMax));
|
||||
|
||||
const total = vehicles.length;
|
||||
|
||||
// 基于筛选后的数据计算统计(yesterdayTotal 也基于筛选后的车辆)
|
||||
const filteredStats = {
|
||||
totalToday: vehicles.reduce((sum, v) => sum + v.dailyKm, 0),
|
||||
totalAll: vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
||||
vehicleCount: vehicles.length,
|
||||
yesterdayTotal: vehicles.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
||||
};
|
||||
|
||||
// 排序
|
||||
vehicles = [...vehicles].sort((a, b) => {
|
||||
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
|
||||
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
|
||||
return sortOrder === 'desc' ? valB - valA : valA - valB;
|
||||
});
|
||||
|
||||
// 分页
|
||||
const offset = (page - 1) * limit;
|
||||
const paged = vehicles.slice(offset, offset + limit);
|
||||
|
||||
return c.json({
|
||||
vehicles: paged,
|
||||
stats: filteredStats,
|
||||
filters,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
updatedAt: date || monitoringCache?.updatedAt || new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /targets — 考核项目列表 + 汇总
|
||||
app.get('/targets', async (c) => {
|
||||
try {
|
||||
const [targets] = await pool.execute(
|
||||
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
|
||||
) as any;
|
||||
|
||||
const [vehicleStats] = await pool.execute(`
|
||||
SELECT
|
||||
target_id,
|
||||
COUNT(*) as total,
|
||||
SUM(today_mileage) as today_total,
|
||||
SUM(current_mileage) as cumulative_total,
|
||||
AVG(current_year_completion_rate) as avg_completion,
|
||||
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
|
||||
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
|
||||
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
|
||||
SUM(current_year_mileage_task) as current_year_target,
|
||||
SUM(current_year_mileage) as current_year_completed,
|
||||
MAX(current_year_assessment_end_date) as year_end_date
|
||||
FROM tab_mileage_assessment_vehicle
|
||||
WHERE is_deleted = 0
|
||||
GROUP BY target_id
|
||||
`) as any;
|
||||
|
||||
const statsMap = new Map<number, any>();
|
||||
for (const s of vehicleStats) {
|
||||
statsMap.set(s.target_id, s);
|
||||
}
|
||||
|
||||
// 查询每个 target 的不同考核区间
|
||||
const [periodRows] = await pool.execute(`
|
||||
SELECT target_id,
|
||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
|
||||
COUNT(*) as cnt
|
||||
FROM tab_mileage_assessment_vehicle
|
||||
WHERE is_deleted = 0
|
||||
GROUP BY target_id, assessment_start_date, assessment_end_date
|
||||
ORDER BY target_id, assessment_start_date
|
||||
`) as any;
|
||||
|
||||
const periodsMap = new Map<number, string[]>();
|
||||
for (const p of periodRows) {
|
||||
const list = periodsMap.get(p.target_id) || [];
|
||||
list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`);
|
||||
periodsMap.set(p.target_id, list);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const result = targets.map((t: any) => {
|
||||
const s = statsMap.get(t.id) || {};
|
||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
||||
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||
const dailyTarget = remaining / daysLeft;
|
||||
|
||||
const periods = periodsMap.get(t.id) || [];
|
||||
if (periods.length === 0) {
|
||||
const startDate = t.default_start_date
|
||||
? new Date(t.default_start_date).toISOString().split('T')[0]
|
||||
: '';
|
||||
const endDate = t.default_end_date
|
||||
? new Date(t.default_end_date).toISOString().split('T')[0]
|
||||
: '';
|
||||
if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
targetName: t.target_name,
|
||||
vehicleCount: Number(s.total) || t.vehicle_count,
|
||||
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||
assessmentYears: t.assessment_years,
|
||||
periods,
|
||||
todayTotal: Number(s.today_total) || 0,
|
||||
cumulativeTotal: Number(s.cumulative_total) || 0,
|
||||
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
||||
qualifiedCount: Number(s.qualified_count) || 0,
|
||||
yearQualifiedCount: Number(s.year_qualified_count) || 0,
|
||||
halfQualifiedCount: Number(s.half_qualified_count) || 0,
|
||||
currentYearTarget,
|
||||
currentYearCompleted,
|
||||
remaining,
|
||||
daysLeft,
|
||||
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error('targets error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /target/:id/vehicles — 某项目的车辆明细
|
||||
app.get('/target/:id/vehicles', async (c) => {
|
||||
const targetId = c.req.param('id');
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT plate_number, today_mileage, vehicle_total_mileage,
|
||||
completion_rate, is_qualified, current_year_is_qualified,
|
||||
daily_required_mileage
|
||||
FROM tab_mileage_assessment_vehicle
|
||||
WHERE target_id = ? AND is_deleted = 0
|
||||
ORDER BY today_mileage DESC`,
|
||||
[targetId]
|
||||
) as any;
|
||||
|
||||
const result = rows.map((r: any) => ({
|
||||
plateNumber: r.plate_number,
|
||||
todayMileage: Number(r.today_mileage) || 0,
|
||||
totalMileage: Number(r.vehicle_total_mileage) || 0,
|
||||
completionRate: Number(r.completion_rate) || 0,
|
||||
isQualified: r.is_qualified === 1,
|
||||
currentYearIsQualified: r.current_year_is_qualified === 1,
|
||||
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error('target vehicles error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /trend — 7天里程趋势
|
||||
app.get('/trend', async (c) => {
|
||||
const targetId = c.req.query('targetId');
|
||||
const days = Number(c.req.query('days')) || 7;
|
||||
try {
|
||||
let plates: string[] = [];
|
||||
if (targetId) {
|
||||
const [vehicleRows] = await pool.execute(
|
||||
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
|
||||
[targetId]
|
||||
) as any;
|
||||
plates = vehicleRows.map((r: any) => r.plate_number);
|
||||
if (plates.length === 0) return c.json([]);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
|
||||
`;
|
||||
const params: any[] = [days];
|
||||
|
||||
if (plates.length > 0) {
|
||||
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
|
||||
params.push(...plates);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY stat_date ORDER BY stat_date';
|
||||
|
||||
const [rows] = await mileagePool.execute(sql, params) as any;
|
||||
|
||||
const result = rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
mileage: Math.round(Number(r.mileage) || 0),
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error('trend error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user