Compare commits

...

46 Commits

Author SHA1 Message Date
kkfluous
e57b8d8801 fix: 全屏模式重新设计为纵向布局
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 去掉 CSS transform 旋转(移动端不兼容)
- KPI 改为单行横排4个卡片
- 标题栏+KPI 紧凑排列在顶部
- 表格区域占满剩余空间,可滚动查看所有列
- 移动端和桌面端统一布局

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:38:26 +08:00
kkfluous
8b95e53098 fix: 回到顶部改为 scrollTo(0) 确保完全回到页面顶端
用 window.scrollTo + documentElement.scrollTop 双重保险,
替代 scrollIntoView 避免只滚动到哨兵位置。
全屏模式改为 CSS transform 旋转实现移动端横屏。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:32:35 +08:00
kkfluous
bfee8344b9 fix: 全屏按钮增加横屏锁定
点击全屏按钮后:进入浏览器全屏 + 锁定横屏方向
退出全屏时:解除横屏锁定

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:27:14 +08:00
kkfluous
ca4a84f84b fix: 查询日期默认为当天
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:22:33 +08:00
kkfluous
94277efc24 fix: 车辆详情清单标题也吸顶,与KPI合为一个sticky块
Tab栏 + KPI统计 + 清单标题 三层吸顶:
- Tab栏 sticky top-0
- KPI + 清单标题 sticky top-[44px]
移动端和Web端都生效。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:19:49 +08:00
kkfluous
787fa27949 fix: overflow-x-hidden 改为 overflow-x:clip 修复 sticky 吸顶
overflow-x:hidden 会创建滚动容器导致 position:sticky 失效,
改用 overflow-x:clip 裁剪溢出但不破坏 sticky 定位。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:15:38 +08:00
kkfluous
c5ee78e892 feat: Tab栏和KPI卡片吸顶固定
滚动列表时:
- Tab栏(实时监控/统计报表/每日汇报)sticky固定在顶部
- KPI统计卡片sticky固定在Tab栏下方,略缩小间距
- 背景色匹配页面避免透出内容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:11:35 +08:00
kkfluous
50eaeb05ae fix: 统计报表用年度完成率替代总完成率
- 完成率改用 current_year_completion_rate 平均值
- 50%达标数改用 current_year_completion_rate >= 0.5
- 修复后数据:40台普货 完成率51.6% 50%达标15台

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:08:46 +08:00
kkfluous
1d8e827374 fix: 回到顶部按钮用 IntersectionObserver 检测+scrollIntoView
- 顶部放哨兵元素,离开视口时显示回到顶部按钮
- 点击用 scrollIntoView 替代 window.scrollTo,兼容各种布局

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:05:42 +08:00
kkfluous
54c8449f7b fix: 用 IntersectionObserver 替代 scroll 事件实现瀑布流
scroll 事件在某些布局下不触发,改用 IntersectionObserver
监听列表底部哨兵元素,进入视口时自动加载下一页,更可靠。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:59:54 +08:00
kkfluous
b7b254546c fix: 改进瀑布流滚动和回到顶部的可靠性
- 使用 ref 避免 loadMore 依赖导致事件重复注册
- 同时监听 window 和 document 的 scroll 事件
- 降低回到顶部按钮触发阈值到 400px
- 增大触底检测距离到 300px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:52:38 +08:00
kkfluous
82dac759be fix: 环比统计跟随筛选条件正确计算
每辆车缓存其昨日里程(yesterdayKm),筛选后的环比基于
相同筛选条件下的车辆计算,而非全局对比。
例如筛选"业务一部"后,今日和昨日都只统计一部的车辆。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:50:07 +08:00
kkfluous
0b8bbbb063 feat: 支持查询指定日期里程+删除搜索关键词和车牌号筛选
- 后端支持 date 参数,指定日期时实时查询数据库(不用缓存)
- 同时查询前一天数据计算环比
- 高级筛选添加"查询日期"日期选择器
- 删除高级筛选中的"搜索关键词"和"车牌号"(已有快捷筛选)
- 筛选标签支持显示日期条件

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:41:03 +08:00
kkfluous
cbf0e18634 fix: 里程环比改为真实值(与前一天对比)
- 后端缓存刷新时查询前一天总里程(yesterdayTotal)
- 前端计算真实环比:(今日-昨日)/昨日*100%
- 上涨显示蓝色↑,下跌显示红色↓
- 昨日无数据时不显示环比

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:33:52 +08:00
kkfluous
66de41d50b fix: KPI统计跟随筛选条件变化+客户筛选修正+部门排序
- KPI统计(总里程/平均单车/监控台数)改为基于筛选后数据计算
- 移除不需要的 onlineCount 字段
- 快捷筛选"按客户"和全屏表格"客户"列改为真正的客户筛选
- 删除混乱的 projects 变量映射
- 部门列表按 一部→二部→三部 顺序排序

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:29:31 +08:00
kkfluous
c73e20bacf fix: 快捷筛选按客户改为客户筛选、删除日期筛选、更新频率文案
- 快捷筛选"按客户"改为真正的客户名称筛选(独立于项目筛选)
- 删除高级筛选中的"日期区间"和"日期"(无后端支持)
- "40MIN更新"改为"每分钟更新"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:26:34 +08:00
kkfluous
8fffa141f4 fix: 修复车牌搜索失效,确保所有筛选条件正常
- 后端新增 plate 查询参数支持
- 前端将 filterPlate 传给 API 并加入依赖数组
- 所有筛选条件(部门/项目/主体/车牌/搜索/里程范围)
  均正确传递到后端并触发数据刷新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:22:12 +08:00
kkfluous
cb620e5101 feat: 筛选条件标签展示+单独删除+清除全部
筛选后在 KPI 卡片上方展示活跃筛选条件标签(蓝色圆角),
每个标签可单独点×删除,右侧"清除"按钮重置所有筛选。
支持:部门/项目/主体/车牌/搜索/里程范围/地区。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:18:55 +08:00
kkfluous
2469da310d fix: 里程范围筛选接入后端
- 后端支持 mileageMin/mileageMax 查询参数
- 前端点击"完成筛选"时将里程范围提交到后端
- "重置所有"同时清除已应用的里程范围

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:14:56 +08:00
kkfluous
863ab17b58 fix: 筛选器和显示优化
- 删除年份筛选
- 项目筛选改用真实项目数据(ln_vehicle_contract.project_name)
- 主体查询改用 tab_truck → tab_org 的 org_name
- 里程区间改为两个独立条件(里程≥ / 里程≤)
- 未分配客户显示为 -
- 统计报表日期格式改为 M.D(如 3.25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:06:56 +08:00
kkfluous
2f6269e071 feat: 实时监控改为瀑布流无限滚动+回到顶部
- 移除分页按钮,改为滚动触底自动加载下一页
- 滚动超过600px时显示蓝色回到顶部按钮
- 底部提示加载状态(加载中.../已加载全部 N 条)
- 筛选/排序变化时自动重置为首页

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:52:05 +08:00
kkfluous
d8f25448d0 fix: 实时监控显示优化
1. KPI 总里程不保留小数
2. 车辆卡片先展示部门再展示客户名称,客户名称不截断
3. 无部门时展示租赁状态(自营/租赁/挂靠等)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:45:46 +08:00
kkfluous
c3300359a0 fix: 所有里程数据添加 km 单位
实时监控:全屏表格、车辆卡片的今日/累计里程添加 km
统计报表:全屏表格、考核详情、侧滑面板的里程值添加 km
统一使用小写 km

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:35:45 +08:00
kkfluous
aa024f1b64 perf: 实时监控改为缓存+分页架构
后端:
- 每2分钟刷新全量数据到内存缓存(并行查询两库)
- 预计算统计信息(totalToday/totalAll/onlineCount/vehicleCount)
- 预提取筛选选项(departments/customers/plates)
- API 直接从缓存读取,支持分页(每页50条)+筛选+排序

前端:
- KPI 统计使用后端返回的 stats
- 车辆列表分页,带翻页控件
- 筛选选项从后端 filters 获取

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:23:25 +08:00
kkfluous
1fb9d53873 perf: 实时监控性能优化
- 后端:车辆关联信息缓存5分钟、两库并行查询、支持服务端
  筛选/排序/分页(默认返回100条)
- 前端:筛选和排序参数传给后端,不再加载全量数据
- 筛选选项(部门/客户/车牌)仅首次加载获取

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:11:52 +08:00
kkfluous
ad17803ed1 fix: 里程数超过10000显示为xx.xx万KM
添加 fmtKm() 格式化函数,统计报表中所有里程数值
超过10000时自动转为万单位显示。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:47:35 +08:00
kkfluous
152935819b fix: 考核区间支持多批次显示
190辆冷链车有3个不同考核区间(40台、50台、100台),
恒运有2个。后端改为查询每个target的不同考核区间并返回
periods数组,前端换行显示每个区间及其车辆数。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:43:17 +08:00
kkfluous
2a10c5ae31 fix: 修复统计报表移动端适屏问题
- Shell main 添加 min-w-0 overflow-x-hidden 防止 flex 子元素溢出
- trend API 排除当天数据 (stat_date < CURDATE()),只返回前7天
- StatisticsView 移除移动端 overflow-hidden,改为仅 landscape 模式
- 图表和列表卡片在移动端正确显示全宽内容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:36:25 +08:00
kkfluous
ee3db94c75 fix: 修复统计报表在移动端的溢出问题
- MileageModule 减少移动端 padding (p-6→p-3)
- StatisticsView 添加 overflow-x-hidden 防止横向溢出
- 图表容器添加 overflow-hidden
- 减少图表右侧 margin 防止标签被截断

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:29:54 +08:00
kkfluous
dd1834477d fix: 修复统计报表完成率格式和项目名称显示
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:26:31 +08:00
kkfluous
7e2eefc3da feat: 实现里程管理统计报表视图(1:1 复刻原型)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:16:39 +08:00
kkfluous
167842408c feat: 实现里程管理实时监控视图(1:1 复刻原型)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:11:26 +08:00
kkfluous
0a2cfc22c4 feat: MileageModule Tab 切换 + DailyReportView 占位
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:06:05 +08:00
kkfluous
75b4e55dca feat: 添加里程管理 API 路由(monitoring/targets/trend)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:06:01 +08:00
kkfluous
5ff3372f2a feat: 添加里程管理 API 客户端 2026-04-01 21:05:29 +08:00
kkfluous
a7e617bc6f feat: 添加 hydrogen_energy 数据库连接和里程管理类型定义
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:03:52 +08:00
kkfluous
3d6c31a86e 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>
2026-04-01 21:01:58 +08:00
kkfluous
7cf7bc945a docs: 添加里程管理模块设计文档
覆盖架构、API 端点、前端组件、数据映射,
1:1 复刻原型的实时监控和统计报表。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:45 +08:00
kkfluous
968e9369a0 refactor: 重写 App.tsx 为模块化顶层壳 2026-04-01 19:22:30 +08:00
kkfluous
caec13eec5 refactor: 创建 AssetsModule,迁移资产管理逻辑
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:21:39 +08:00
kkfluous
bb3dbde1c7 feat: 创建 Shell 布局组件(侧边栏 + 底部导航)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:19:26 +08:00
kkfluous
40e84a1eaa feat: 创建里程管理占位组件 2026-04-01 19:19:22 +08:00
kkfluous
be6598a940 refactor: 移动 types.ts 和 api.ts 到 modules/assets/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:19:11 +08:00
kkfluous
de0320bfcd refactor: 抽取 SearchSelect 为公共组件
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:18:16 +08:00
kkfluous
b495cac0fe docs: 添加模块化重构实施计划
7 个 Task 的详细步骤,覆盖 SearchSelect 抽取、文件迁移、
AssetsModule 创建、Shell 布局、里程占位、App.tsx 重写和清理。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:07:02 +08:00
kkfluous
cfd81b1b9d docs: 添加模块化重构设计文档
支持多 BI 大类(资产管理、里程管理)的架构重构设计,
包括目录结构、Shell 布局、导航机制和迁移策略。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:03:13 +08:00
19 changed files with 6618 additions and 2800 deletions

View 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/` 下拆分为 MileageModuleTab 切换、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 classNameTailwind 类名 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: 里程管理模块集成修复"
```

View 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: PASSAssetsModule 已自包含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: PASSShell 尚未被引用)
- [ ] **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: 清理残留文件和旧引用"
```

View File

@@ -0,0 +1,242 @@
# 里程管理模块设计
## 背景
在模块化重构的基础上,实现里程管理 BI 模块1:1 复刻原型 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1` 中的里程管理部分。包含 3 个子 Tab实时监控、统计报表、每日汇报占位
## 目标
- 1:1 复刻原型 UI样式、动画、交互细节完全一致
- 接入真实数据源(两个数据库)
- 每日汇报 Tab 暂做占位
## 数据源
### 数据库 1lingniu_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` — 车辆关联客户名、部门、经理
### 数据库 2hydrogen_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: 前缀样式,照搬即可但不做额外适配工作)
- 后端缓存
- 新增依赖

View 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 内部进一步拆分(保持现有结构)
- 里程管理具体功能实现
- 后端改动
- 新增依赖

File diff suppressed because it is too large Load Diff

View 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
View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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">&times;</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>
</>
);
}

View 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">&nbsp;</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>
);
}

View 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()}`);
}

View 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;
}

View File

@@ -4,6 +4,7 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import vehiclesRouter from './routes/vehicles.js'; import vehiclesRouter from './routes/vehicles.js';
import mileageRouter from './routes/mileage.js';
dotenv.config(); dotenv.config();
@@ -11,6 +12,7 @@ const app = new Hono();
app.use('/api/*', cors()); app.use('/api/*', cors());
app.route('/api/vehicles', vehiclesRouter); app.route('/api/vehicles', vehiclesRouter);
app.route('/api/mileage', mileageRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));

14
src/server/mileage-db.ts Normal file
View 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;

View 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;