docs: 添加里程管理模块实施计划
7 个 Task:后端数据库连接+API 路由、前端类型+API 客户端、 MileageModule Tab 切换、MonitoringView 1:1 复刻、 StatisticsView 1:1 复刻、集成验证。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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: 里程管理模块集成修复"
|
||||
```
|
||||
Reference in New Issue
Block a user