Compare commits
160 Commits
cf8f7cf969
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
433a75f9d1 | ||
|
|
0193e78f18 | ||
|
|
2a851fc243 | ||
|
|
6142af7617 | ||
|
|
26f7d7ab3f | ||
|
|
f06b0d21eb | ||
|
|
6ad4b5e2a4 | ||
|
|
ad8ec50038 | ||
|
|
dc6f541c8b | ||
|
|
034654265c | ||
|
|
5958bb581e | ||
|
|
4153f329b8 | ||
|
|
ee981639eb | ||
|
|
fe70ec389b | ||
|
|
c3b463d9ca | ||
|
|
f2acb73033 | ||
|
|
015ff9bc7e | ||
|
|
d24ce55a59 | ||
|
|
e0183986ee | ||
|
|
234b44ea03 | ||
|
|
8d861538af | ||
|
|
bdefb878a5 | ||
|
|
2aeff0c2f4 | ||
|
|
9bbd11cc86 | ||
|
|
1a3d48b2d1 | ||
|
|
c5541fbbf5 | ||
|
|
90b34b681e | ||
|
|
90b1266fe5 | ||
|
|
20ebb16e08 | ||
|
|
e8f1604c11 | ||
|
|
08f21b7e24 | ||
|
|
57207debfb | ||
|
|
d3fa2fd4d6 | ||
|
|
8b4fb6563f | ||
|
|
e187c0d02e | ||
|
|
3d4d862d73 | ||
|
|
23a7722583 | ||
|
|
213037c2ac | ||
|
|
3efa701395 | ||
|
|
e775acb8fe | ||
|
|
c788dd4577 | ||
|
|
3851335843 | ||
|
|
d0a644cf18 | ||
|
|
0d30ee2df5 | ||
|
|
9a20a7cb79 | ||
|
|
d1d79f1c7c | ||
|
|
5217e19b25 | ||
|
|
57fdd346cf | ||
|
|
d8189329ac | ||
|
|
e2d04db06d | ||
|
|
5493e27e49 | ||
|
|
355c45a2e4 | ||
|
|
66779a98e3 | ||
|
|
97ac92a0da | ||
|
|
7ca8ef24dc | ||
|
|
f9c6155ea7 | ||
|
|
cab86556f3 | ||
|
|
e0c609168e | ||
|
|
ebd82893bc | ||
|
|
3809e785c1 | ||
|
|
d1acdafa7e | ||
|
|
c3b43837fb | ||
|
|
c02c1aa62c | ||
|
|
9a4f1945d9 | ||
|
|
7de2d1ecd5 | ||
|
|
42ec6e1c01 | ||
|
|
313325553d | ||
|
|
d9b9ff495e | ||
|
|
bdd039a2c4 | ||
|
|
2a92d991b0 | ||
|
|
ccf76cba79 | ||
|
|
a40fd2be34 | ||
|
|
c8a1e8506e | ||
|
|
dc1f0326fc | ||
|
|
e6880cba17 | ||
|
|
09b9862f1f | ||
|
|
deb2f2d5da | ||
|
|
ccd97d3aae | ||
|
|
61db692980 | ||
|
|
cfe79cace2 | ||
|
|
9ea2f306c4 | ||
|
|
a472e543ce | ||
|
|
0c258dd1a2 | ||
|
|
200172f0af | ||
|
|
a954fb90f6 | ||
|
|
2ea00a5383 | ||
|
|
cf138f67c0 | ||
|
|
e32b0b58b3 | ||
|
|
9d1e8c4d30 | ||
|
|
ba1e0e9f16 | ||
|
|
1b2ad68743 | ||
|
|
210db7f8ff | ||
|
|
1d9f4cb43d | ||
|
|
3ef0d4edfa | ||
|
|
31716c6547 | ||
|
|
335282a2c3 | ||
|
|
dfc32c4485 | ||
|
|
ceed067807 | ||
|
|
2f11afc25f | ||
|
|
9f781c766a | ||
|
|
8664317852 | ||
|
|
c3de4ebaf5 | ||
|
|
aa9a29fed8 | ||
|
|
a52a77f3a2 | ||
|
|
9012a955b8 | ||
|
|
caff78c5f3 | ||
|
|
f6f872d2ce | ||
|
|
dbefb90089 | ||
|
|
db568c1ebb | ||
|
|
ba6a38973d | ||
|
|
7aa0d961ce | ||
|
|
4f02a54d38 | ||
|
|
073496cd44 | ||
|
|
bea67b6710 | ||
|
|
d0984a430b | ||
|
|
b3a6beb26b | ||
|
|
0a7a9a096d | ||
|
|
dd03157804 | ||
|
|
694e9a207a | ||
|
|
fb89c9beed | ||
|
|
75f0aca5d1 | ||
|
|
8598aea445 | ||
|
|
25199b507c | ||
|
|
6a3a5ba319 | ||
|
|
0785c78382 | ||
|
|
afec75a1cc | ||
|
|
1d1f8901aa | ||
|
|
81305be2df | ||
|
|
64f47d5ad6 | ||
|
|
9398688829 | ||
|
|
48fa3bc73f | ||
|
|
1a5a1c1514 | ||
|
|
73080a605d | ||
|
|
6f7555a407 | ||
|
|
bcbeb64e28 | ||
|
|
6ee811c937 | ||
|
|
495f4bf44f | ||
|
|
ec3b079311 | ||
|
|
033af15814 | ||
|
|
253cc2f2c0 | ||
|
|
db5ca2e686 | ||
|
|
2e82a30893 | ||
|
|
9c005bebc8 | ||
|
|
82ee7f5480 | ||
|
|
4169e04a9c | ||
|
|
86d5bc8738 | ||
|
|
460c9906e1 | ||
|
|
569b5ea349 | ||
|
|
ebe46c6f73 | ||
|
|
32b297c731 | ||
|
|
9bf9bdd8ff | ||
|
|
188d2b105f | ||
|
|
26d59190c9 | ||
|
|
f3b795e8a9 | ||
|
|
71ff459111 | ||
|
|
4acf10ef79 | ||
|
|
820fde5547 | ||
|
|
d6c31dd2b6 | ||
|
|
8660c0d999 | ||
|
|
b4c4929dbb |
@@ -13,6 +13,7 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY src/server ./src/server
|
||||
COPY src/shared ./src/shared
|
||||
COPY tsconfig.json ./
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
@@ -5,10 +5,10 @@ services:
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||
network_mode: host
|
||||
environment:
|
||||
DB_HOST: "192.168.130.111"
|
||||
DB_HOST: "47.101.148.99"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "linsset_01"
|
||||
DB_PASSWORD: "LN3456#&"
|
||||
DB_USER: "root"
|
||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
||||
DB_NAME: "lingniu_prod"
|
||||
SERVER_PORT: "8111"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
|
||||
BIN
docs/.DS_Store
vendored
Normal file
BIN
docs/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
docs/superpowers/.DS_Store
vendored
Normal file
BIN
docs/superpowers/.DS_Store
vendored
Normal file
Binary file not shown.
753
docs/superpowers/plans/2026-03-27-three-modules.md
Normal file
753
docs/superpowers/plans/2026-03-27-three-modules.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# Three Operations Modules Implementation Plan
|
||||
|
||||
> **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:** Add department, region, and customer operations statistics sections to the dashboard, ported from lnoneos prototype with real MySQL data.
|
||||
|
||||
**Architecture:** All 3 new API endpoints aggregate from the existing `getVehicles()` cache (no new DB queries). A new macro-region mapping function converts province/city to 华东/华南/etc. The frontend adds 3 new collapsible sections below the existing asset summary table, each with desktop table + mobile card views. The existing vehicle list modal is extended with new filter params (manager, customer, isColdChain, isTrailer).
|
||||
|
||||
**Tech Stack:** Hono (backend), React + Tailwind CSS + Motion (frontend), TypeScript throughout.
|
||||
|
||||
**Reference:** lnoneos prototype at `/Users/kkfluous/Projects/ai-coding/lnoneos/src/App.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Macro-region mapping + vehicle type classification helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routes/vehicles.ts` (add functions after line ~111)
|
||||
|
||||
- [ ] **Step 1: Add macro-region mapping function**
|
||||
|
||||
Add after `mapInventoryRegion` (line 111) in `src/server/routes/vehicles.ts`:
|
||||
|
||||
```typescript
|
||||
// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他
|
||||
function mapMacroRegion(province: string | null, city: string | null): string {
|
||||
const prov = (province || '').trim();
|
||||
const c = (city || '').trim();
|
||||
const loc = prov + c;
|
||||
// 华东: 上海/江苏/浙江/安徽/福建/江西/山东
|
||||
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东';
|
||||
// 华南: 广东/广西/海南
|
||||
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南';
|
||||
// 华北: 北京/天津/河北/山西/内蒙古
|
||||
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北';
|
||||
// 华中: 河南/湖北/湖南
|
||||
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中';
|
||||
// 西南: 重庆/四川/贵州/云南/西藏
|
||||
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南';
|
||||
// 西北: 陕西/甘肃/青海/宁夏/新疆
|
||||
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北';
|
||||
return '其他';
|
||||
}
|
||||
|
||||
// Vehicle type classification for per-type counts
|
||||
type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number };
|
||||
|
||||
function classifyVehicleType(v: Vehicle): keyof Omit<VehicleTypeCounts, 'total'> {
|
||||
if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5';
|
||||
if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c';
|
||||
if (v.type === '18T') return 't18';
|
||||
if (v.type === '49T') return 't49';
|
||||
if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
|
||||
const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 };
|
||||
for (const v of vehicles) {
|
||||
counts[classifyVehicleType(v)]++;
|
||||
counts.total++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/vehicles.ts
|
||||
git commit -m "feat: add macro-region mapping and vehicle type classification helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — Three new API endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routes/vehicles.ts` (add endpoints before the `/list` endpoint)
|
||||
|
||||
- [ ] **Step 1: Add `/dept-stats` endpoint**
|
||||
|
||||
Add before the `VEHICLE_TYPE_FILTERS` const (which is before `/list`) in `src/server/routes/vehicles.ts`:
|
||||
|
||||
```typescript
|
||||
// GET /api/vehicles/dept-stats
|
||||
app.get('/dept-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
// Only count operating vehicles for department stats (those with a customerManager)
|
||||
const withManager = vehicles.filter((v) => v.customerManager);
|
||||
|
||||
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
||||
for (const v of withManager) {
|
||||
const dept = v.departmentName || '未分配';
|
||||
const mgr = v.customerManager!;
|
||||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||||
const mgrMap = deptMap.get(dept)!;
|
||||
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
|
||||
mgrMap.get(mgr)!.push(v);
|
||||
}
|
||||
|
||||
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
|
||||
const allDeptVehicles = Array.from(mgrMap.values()).flat();
|
||||
const managers = Array.from(mgrMap.entries())
|
||||
.map(([manager, mvs]) => ({
|
||||
manager,
|
||||
department,
|
||||
...countByType(mvs),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return {
|
||||
department,
|
||||
totalAssets: allDeptVehicles.length,
|
||||
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
|
||||
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
|
||||
managers,
|
||||
};
|
||||
}).sort((a, b) => b.totalAssets - a.totalAssets);
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `/region-stats` endpoint**
|
||||
|
||||
```typescript
|
||||
// GET /api/vehicles/region-stats
|
||||
app.get('/region-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
|
||||
const regionMap = new Map<string, Vehicle[]>();
|
||||
for (const v of operating) {
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||
regionMap.get(region)!.push(v);
|
||||
}
|
||||
|
||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||
const result = regionOrder
|
||||
.filter((r) => regionMap.has(r))
|
||||
.map((region) => {
|
||||
const rv = regionMap.get(region)!;
|
||||
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
|
||||
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
|
||||
const typeVehicles = rv.filter((v) => v.type === type);
|
||||
return {
|
||||
type,
|
||||
total: typeVehicles.length,
|
||||
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
|
||||
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
|
||||
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
};
|
||||
}).filter((t) => t.total > 0);
|
||||
|
||||
return {
|
||||
region,
|
||||
totalAssets: rv.length,
|
||||
operatingCount: rv.filter((v) => v.status === 'Operating').length,
|
||||
inventoryCount: rv.filter((v) => v.status === 'Inventory').length,
|
||||
customers,
|
||||
typeBreakdown,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `/customer-stats` endpoint**
|
||||
|
||||
```typescript
|
||||
// GET /api/vehicles/customer-stats
|
||||
app.get('/customer-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
|
||||
|
||||
const custMap = new Map<string, Vehicle[]>();
|
||||
for (const v of operating) {
|
||||
const cust = v.customerName!;
|
||||
if (!custMap.has(cust)) custMap.set(cust, []);
|
||||
custMap.get(cust)!.push(v);
|
||||
}
|
||||
|
||||
const result = Array.from(custMap.entries())
|
||||
.map(([customer, cvs]) => {
|
||||
const first = cvs[0];
|
||||
return {
|
||||
customer,
|
||||
manager: first.customerManager || '',
|
||||
brand: first.brandLabel || '',
|
||||
department: first.departmentName || '',
|
||||
region: mapMacroRegion(first.province, first.city),
|
||||
city: first.city || '',
|
||||
...countByType(cvs),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/vehicles.ts
|
||||
git commit -m "feat: add dept-stats, region-stats, customer-stats API endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend — Extend `/list` with new filter params
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routes/vehicles.ts` (the `/list` endpoint)
|
||||
|
||||
- [ ] **Step 1: Add manager, customer, isColdChain, isTrailer filters**
|
||||
|
||||
In the `/list` endpoint, after the existing `category` filter block, add:
|
||||
|
||||
```typescript
|
||||
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer } = c.req.query();
|
||||
```
|
||||
|
||||
(Replace the existing destructure line.)
|
||||
|
||||
Then after the `if (category)` block, add:
|
||||
|
||||
```typescript
|
||||
if (manager) {
|
||||
filtered = filtered.filter((v) => v.customerManager === manager);
|
||||
}
|
||||
if (customer) {
|
||||
filtered = filtered.filter((v) => v.customerName === customer);
|
||||
}
|
||||
if (isColdChain !== undefined) {
|
||||
const wantCold = isColdChain === 'true';
|
||||
filtered = filtered.filter((v) => wantCold ? v.model.includes('冷链') : !v.model.includes('冷链'));
|
||||
}
|
||||
if (isTrailer !== undefined) {
|
||||
const wantTrailer = isTrailer === 'true';
|
||||
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles and build passes**
|
||||
|
||||
Run: `npx tsc --noEmit && npx vite build`
|
||||
Expected: no errors, successful build
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/vehicles.ts
|
||||
git commit -m "feat: extend /list endpoint with manager, customer, coldchain, trailer filters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Types and API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `src/api.ts`
|
||||
|
||||
- [ ] **Step 1: Add new interfaces to `src/types.ts`**
|
||||
|
||||
Append at end of file:
|
||||
|
||||
```typescript
|
||||
export interface ManagerStats {
|
||||
manager: string;
|
||||
department: string;
|
||||
t4_5: number;
|
||||
t4_5c: number;
|
||||
t18: number;
|
||||
t49: number;
|
||||
trailer: number;
|
||||
other: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DeptGroup {
|
||||
department: string;
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
idleCount: number;
|
||||
managers: ManagerStats[];
|
||||
}
|
||||
|
||||
export interface RegionGroup {
|
||||
region: string;
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
inventoryCount: number;
|
||||
customers: string[];
|
||||
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
|
||||
}
|
||||
|
||||
export interface CustomerStats {
|
||||
customer: string;
|
||||
manager: string;
|
||||
brand: string;
|
||||
department: string;
|
||||
region: string;
|
||||
city: string;
|
||||
t4_5: number;
|
||||
t4_5c: number;
|
||||
t18: number;
|
||||
t49: number;
|
||||
trailer: number;
|
||||
other: number;
|
||||
total: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add API functions to `src/api.ts`**
|
||||
|
||||
Add imports at top:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
SummaryData,
|
||||
TypeSummary,
|
||||
VehicleListItem,
|
||||
DeptGroup,
|
||||
RegionGroup,
|
||||
CustomerStats,
|
||||
} from './types';
|
||||
```
|
||||
|
||||
Add after `fetchVehicleList`:
|
||||
|
||||
```typescript
|
||||
export async function fetchDeptStats(): Promise<DeptGroup[]> {
|
||||
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`);
|
||||
}
|
||||
|
||||
export async function fetchRegionStats(): Promise<RegionGroup[]> {
|
||||
return fetchJson<RegionGroup[]>(`${BASE}/region-stats`);
|
||||
}
|
||||
|
||||
export async function fetchCustomerStats(): Promise<CustomerStats[]> {
|
||||
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
|
||||
}
|
||||
```
|
||||
|
||||
Also update `fetchVehicleList` params type to include new filters:
|
||||
|
||||
```typescript
|
||||
export async function fetchVehicleList(params: {
|
||||
batch?: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
isColdChain?: string;
|
||||
isTrailer?: string;
|
||||
}): Promise<VehicleListItem[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.batch) query.set('batch', params.batch);
|
||||
if (params.model) query.set('model', params.model);
|
||||
if (params.location) query.set('location', params.location);
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.category) query.set('category', params.category);
|
||||
if (params.vehicleType) query.set('vehicleType', params.vehicleType);
|
||||
if (params.manager) query.set('manager', params.manager);
|
||||
if (params.customer) query.set('customer', params.customer);
|
||||
if (params.isColdChain) query.set('isColdChain', params.isColdChain);
|
||||
if (params.isTrailer) query.set('isTrailer', params.isTrailer);
|
||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/types.ts src/api.ts
|
||||
git commit -m "feat: add frontend types and API client for dept/region/customer stats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Extend App.tsx state, data loading, imports, and showPlateNumbers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
Replace the existing import lines at top of `src/App.tsx`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Truck,
|
||||
Warehouse,
|
||||
Activity,
|
||||
PlusCircle,
|
||||
MinusCircle,
|
||||
History,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react';
|
||||
```
|
||||
|
||||
Update type imports:
|
||||
|
||||
```typescript
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add new state variables**
|
||||
|
||||
After the existing state declarations (after `const [modalLoading, setModalLoading] = useState(false);`), add:
|
||||
|
||||
```typescript
|
||||
// Dept/Region/Customer data
|
||||
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
||||
const [regionData, setRegionData] = useState<RegionGroup[]>([]);
|
||||
const [customerData, setCustomerData] = useState<CustomerStats[]>([]);
|
||||
|
||||
// Dept section state
|
||||
const [deptViewMode, setDeptViewMode] = useState<'department' | 'manager'>('department');
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
||||
const [expandedManagerDetails, setExpandedManagerDetails] = useState<Set<string>>(new Set());
|
||||
const [selectedManager, setSelectedManager] = useState<string>('All');
|
||||
|
||||
// Region section state
|
||||
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
|
||||
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
|
||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
||||
|
||||
// Customer section state
|
||||
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set());
|
||||
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
|
||||
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update loadData to fetch all 3 new endpoints**
|
||||
|
||||
Update the `loadData` callback:
|
||||
|
||||
```typescript
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [s, byType, dept, region, cust] = await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchByType(),
|
||||
fetchDeptStats(),
|
||||
fetchRegionStats(),
|
||||
fetchCustomerStats(),
|
||||
]);
|
||||
setSummary(s);
|
||||
setProcessedData(byType);
|
||||
setDeptData(dept);
|
||||
setRegionData(region);
|
||||
setCustomerData(cust);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend showPlateNumbers type**
|
||||
|
||||
Update the showPlateNumbers state type:
|
||||
|
||||
```typescript
|
||||
const [showPlateNumbers, setShowPlateNumbers] = useState<{
|
||||
batch: string;
|
||||
model: string;
|
||||
location: string;
|
||||
category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced' | 'Operating';
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
isColdChain?: boolean;
|
||||
isTrailer?: boolean;
|
||||
} | null>(null);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update modal loading to pass new filter params**
|
||||
|
||||
In the `useEffect` for modal loading, update the params block (the "Normal vehicle list" section):
|
||||
|
||||
```typescript
|
||||
// Normal vehicle list
|
||||
setModalWeeklyDetail([]);
|
||||
const params: Record<string, string> = {};
|
||||
if (showPlateNumbers.vehicleType) params.vehicleType = showPlateNumbers.vehicleType;
|
||||
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
||||
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
||||
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
||||
if (cat === 'Inventory') params.status = 'Inventory';
|
||||
if (cat === 'Operating') params.category = 'Operating';
|
||||
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
||||
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
||||
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
|
||||
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add toggle helpers and derived data**
|
||||
|
||||
After the existing `toggleModel` function, add:
|
||||
|
||||
```typescript
|
||||
const toggleDept = (dept: string) => {
|
||||
const newSet = new Set(expandedDepts);
|
||||
if (newSet.has(dept)) newSet.delete(dept);
|
||||
else newSet.add(dept);
|
||||
setExpandedDepts(newSet);
|
||||
};
|
||||
|
||||
const toggleManagerDetails = (manager: string) => {
|
||||
const newSet = new Set(expandedManagerDetails);
|
||||
if (newSet.has(manager)) newSet.delete(manager);
|
||||
else newSet.add(manager);
|
||||
setExpandedManagerDetails(newSet);
|
||||
};
|
||||
|
||||
const toggleRegion = (region: string) => {
|
||||
const newSet = new Set(expandedRegions);
|
||||
if (newSet.has(region)) newSet.delete(region);
|
||||
else newSet.add(region);
|
||||
setExpandedRegions(newSet);
|
||||
};
|
||||
|
||||
const toggleCustomer = (customer: string) => {
|
||||
const newSet = new Set(expandedCustomers);
|
||||
if (newSet.has(customer)) newSet.delete(customer);
|
||||
else newSet.add(customer);
|
||||
setExpandedCustomers(newSet);
|
||||
};
|
||||
|
||||
// Derived data for dept section
|
||||
const allManagersList = deptData.flatMap((d) => d.managers.map((m) => m.manager)).filter((v, i, a) => a.indexOf(v) === i).sort();
|
||||
const managerStats = deptData
|
||||
.flatMap((d) => d.managers)
|
||||
.filter((m) => selectedManager === 'All' || m.manager === selectedManager)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
// Derived data for customer section
|
||||
const filteredCustomerStats = customerData.filter((s) => {
|
||||
const mc = !customerFilters.customer || s.customer.toLowerCase().includes(customerFilters.customer.toLowerCase());
|
||||
const mb = !customerFilters.brand || s.brand === customerFilters.brand;
|
||||
const md = !customerFilters.department || s.department === customerFilters.department;
|
||||
const mm = !customerFilters.manager || s.manager.toLowerCase().includes(customerFilters.manager.toLowerCase());
|
||||
const mr = !customerFilters.region || s.region === customerFilters.region;
|
||||
return mc && mb && md && mm && mr;
|
||||
});
|
||||
const uniqueBrands = Array.from(new Set(customerData.map((s) => s.brand).filter(Boolean)));
|
||||
const uniqueDepts = Array.from(new Set(customerData.map((s) => s.department).filter(Boolean)));
|
||||
const uniqueRegions = Array.from(new Set(customerData.map((s) => s.region)));
|
||||
const uniqueCities = Array.from(new Set(customerData.map((s) => s.city).filter(Boolean)));
|
||||
|
||||
// Derived data for region section
|
||||
const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "feat: add state, data loading, and helpers for 3 new modules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — Department Operations UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx` (add section after the asset summary table's closing `</div>`, before the Plate Number Modal)
|
||||
|
||||
- [ ] **Step 1: Add the department operations section**
|
||||
|
||||
Insert the department operations section JSX. Reference: lnoneos lines 1362-1880. This section goes right after the closing `</div>` of the asset summary table (`bg-white rounded-sm border...`) and before `{/* Plate Number Modal */}`.
|
||||
|
||||
The section includes:
|
||||
1. Header with title "部门运营统计"
|
||||
2. Dark summary bar (总资产/运营中/闲置中 — skip 平均出勤)
|
||||
3. Toggle buttons (按部门 / 按业务员) + manager filter dropdown
|
||||
4. Desktop table view (`hidden lg:block`)
|
||||
- Department mode: department rows expandable to show manager cards with 6 vehicle type cells
|
||||
- Manager mode: flat manager rows expandable to show 6 vehicle type cells
|
||||
5. Mobile card view (`lg:hidden`)
|
||||
|
||||
Port the JSX from lnoneos lines 1362-1880, replacing:
|
||||
- `MOCK_DEPT_STATS` → `deptData`
|
||||
- `DEPT_TOTALS.total` → `deptData.reduce((s, d) => s + d.totalAssets, 0)`
|
||||
- `allManagersList` / `managerStats` / `deptViewMode` / `expandedDepts` / `expandedManagerDetails` / `selectedManager` → already defined in Task 5
|
||||
- `setShowPlateNumbers` calls: keep the same structure but remove `source` field (not needed in ln-bi)
|
||||
- Remove `ArrowRightLeft` icon usage in toggle buttons — replace with simple text button
|
||||
- All `rounded-2xl` → `rounded-sm` to match ln-bi style
|
||||
- All `shadow-sm` stay as is
|
||||
|
||||
The `setShowPlateNumbers` calls from lnoneos use `manager`, `type`, `isColdChain`, `isTrailer` fields which we added to the state type in Task 5.
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles and build passes**
|
||||
|
||||
Run: `npx tsc --noEmit && npx vite build`
|
||||
Expected: no errors, successful build
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "feat: add department operations statistics section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — Region Operations UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx` (add section after department section, before Plate Number Modal)
|
||||
|
||||
- [ ] **Step 1: Add the region operations section**
|
||||
|
||||
Reference: lnoneos lines 1882-2174. Insert after the department section.
|
||||
|
||||
The section includes:
|
||||
1. Slate-themed header with "区域运营统计" + filter button
|
||||
2. Filter popover (客户搜索 / 区域下拉 / 城市下拉)
|
||||
3. Desktop table: expandable region rows → vehicle type sub-rows
|
||||
4. Mobile cards: expandable region cards with type breakdown
|
||||
|
||||
Port the JSX from lnoneos, replacing:
|
||||
- `MOCK_CUSTOMER_STATS` region-based filtering → use `filteredRegionData` (from Task 5)
|
||||
- Region stats aggregation in lnoneos used mock data with `Math.floor(totalAssets * 0.8)` for operating — use real `r.operatingCount` and `r.inventoryCount`
|
||||
- Type breakdown: use `r.typeBreakdown` array from API
|
||||
- `uniqueRegions` / `uniqueCities` → already defined in Task 5
|
||||
- `setShowPlateNumbers` calls: use `vehicleType` field for type filtering instead of lnoneos's `type` field
|
||||
- `rounded-2xl` → `rounded-sm`
|
||||
- Filter popover for cities: derive from `regionData` (all unique cities from customers)
|
||||
|
||||
Note: The region filter's city dropdown needs city data. Add to Task 5's derived data if not already there. The regionData from API contains customer names but not cities. For the city filter, we can derive from customerData filtered by region.
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles and build passes**
|
||||
|
||||
Run: `npx tsc --noEmit && npx vite build`
|
||||
Expected: no errors, successful build
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "feat: add region operations statistics section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Frontend — Customer Operations UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx` (add section after region section, before Plate Number Modal)
|
||||
|
||||
- [ ] **Step 1: Add the customer operations section**
|
||||
|
||||
Reference: lnoneos lines 2176-2496. Insert after the region section.
|
||||
|
||||
The section includes:
|
||||
1. Emerald-themed header with "客户运营统计" + filter button
|
||||
2. Filter popover (客户名搜索 / 业务员搜索 / 品牌下拉 / 部门下拉 / 区域下拉)
|
||||
3. Desktop table: customer rows with 6 vehicle type columns + total, expandable detail cards
|
||||
4. Mobile cards: customer cards with expandable vehicle type grid
|
||||
|
||||
Port the JSX from lnoneos, replacing:
|
||||
- `MOCK_CUSTOMER_STATS` → `filteredCustomerStats` (from Task 5)
|
||||
- `DEPT_TOTALS.total` → `customerData.reduce((s, c) => s + c.total, 0)` for asset ratio
|
||||
- `setShowPlateNumbers` calls: use `vehicleType` + `customer` fields
|
||||
- `uniqueBrands` / `uniqueDepts` / `uniqueRegions` → already defined in Task 5
|
||||
- `rounded-2xl` → `rounded-sm`
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles and build passes**
|
||||
|
||||
Run: `npx tsc --noEmit && npx vite build`
|
||||
Expected: no errors, successful build
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "feat: add customer operations statistics section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Final verification and build
|
||||
|
||||
**Files:** All modified files
|
||||
|
||||
- [ ] **Step 1: Full TypeScript check**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 2: Production build**
|
||||
|
||||
Run: `npx vite build`
|
||||
Expected: successful build with no warnings
|
||||
|
||||
- [ ] **Step 3: Verify all sections render**
|
||||
|
||||
Run: `npm run dev` and check:
|
||||
- Department section loads with real data
|
||||
- Region section loads with real data
|
||||
- Customer section loads with real data
|
||||
- Filter popovers work
|
||||
- Expand/collapse works
|
||||
- Click-through to plate number modal works
|
||||
|
||||
- [ ] **Step 4: Final commit if any fixes needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: address any issues from final review"
|
||||
```
|
||||
926
docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md
Normal file
926
docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md
Normal file
@@ -0,0 +1,926 @@
|
||||
# Mileage Backend Refactor Implementation Plan
|
||||
|
||||
> **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:** Refactor `src/server/routes/mileage.ts` (569 lines) into well-typed, modular files with clear responsibilities, eliminating duplicate logic and `as any` casts.
|
||||
|
||||
**Architecture:** Split the monolithic route file into: shared types, a reusable vehicle-info query module, a monitoring cache module, and focused route handlers. The API contract (request/response shapes) stays identical — this is a pure internal refactor with zero frontend changes.
|
||||
|
||||
**Tech Stack:** Hono, mysql2/promise, TypeScript strict types
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `src/server/routes/mileage/types.ts` | All interfaces for mileage domain (cache, vehicles, filters, API responses) |
|
||||
| `src/server/routes/mileage/vehicle-info.ts` | Shared SQL + helper to build plate→info Map from `lingniu_prod` |
|
||||
| `src/server/routes/mileage/cache.ts` | Monitoring cache: refresh logic, data merging, filter precomputation, target mapping |
|
||||
| `src/server/routes/mileage/monitoring.ts` | `GET /monitoring` route handler |
|
||||
| `src/server/routes/mileage/targets.ts` | `GET /targets`, `GET /target/:id/vehicles` route handlers |
|
||||
| `src/server/routes/mileage/trend.ts` | `GET /trend` route handler |
|
||||
| `src/server/routes/mileage/index.ts` | Hono app assembly: imports routes, starts cache timer, exports app |
|
||||
|
||||
After refactor, delete: `src/server/routes/mileage.ts` (the old monolith).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Zero API changes** — all request params and response JSON shapes must remain identical
|
||||
- **Zero frontend changes** — `src/modules/mileage/api.ts` and `types.ts` stay untouched
|
||||
- **Preserve all existing behavior** including cache refresh interval, date queries, filter logic
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create shared types
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/types.ts`
|
||||
|
||||
- [ ] **Step 1: Create the types file**
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/types.ts
|
||||
|
||||
/** 缓存中的单辆车数据 */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 车牌前缀统计 */
|
||||
export interface PlatePrefix {
|
||||
prefix: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 筛选选项(前端下拉) */
|
||||
export interface MonitoringFilters {
|
||||
departments: string[];
|
||||
customers: string[];
|
||||
plates: string[];
|
||||
projects: string[];
|
||||
entities: string[];
|
||||
rentStatuses: string[];
|
||||
platePrefixes: PlatePrefix[];
|
||||
targetNames: string[];
|
||||
}
|
||||
|
||||
/** 监控缓存 */
|
||||
export interface MonitoringCache {
|
||||
vehicles: CachedVehicle[];
|
||||
stats: { totalToday: number; totalAll: number; vehicleCount: number };
|
||||
filters: MonitoringFilters;
|
||||
targetPlatesMap: Map<string, Set<string>>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** /monitoring 响应中的统计 */
|
||||
export interface MonitoringStats {
|
||||
totalToday: number;
|
||||
totalAll: number;
|
||||
vehicleCount: number;
|
||||
yesterdayTotal: number;
|
||||
}
|
||||
|
||||
/** /monitoring 完整响应 */
|
||||
export interface MonitoringResponse {
|
||||
vehicles: CachedVehicle[];
|
||||
stats: MonitoringStats;
|
||||
filters: MonitoringFilters;
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
|
||||
export interface VehicleInfoRow {
|
||||
plate: string;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
rent_status: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors (new file has no imports/consumers yet)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/types.ts
|
||||
git commit -m "refactor: extract mileage shared types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract vehicle-info query module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/vehicle-info.ts`
|
||||
|
||||
- [ ] **Step 1: Create the vehicle-info module**
|
||||
|
||||
This extracts the `VEHICLE_INFO_SQL` constant and a helper function to build the info Map. Both the cache builder and the `/target/:id/vehicles` route reuse this.
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/vehicle-info.ts
|
||||
import pool from '../../db.js';
|
||||
import type { VehicleInfoRow } from './types.js';
|
||||
|
||||
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */
|
||||
export 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`;
|
||||
|
||||
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
|
||||
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
|
||||
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown];
|
||||
const map = new Map<string, VehicleInfoRow>();
|
||||
for (const row of rows) {
|
||||
map.set(row.plate, row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 查询指定车牌的关联信息 */
|
||||
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
|
||||
if (plates.length === 0) return new Map();
|
||||
const [rows] = await pool.execute(
|
||||
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
|
||||
plates
|
||||
) as [VehicleInfoRow[], unknown];
|
||||
const map = new Map<string, VehicleInfoRow>();
|
||||
for (const row of rows) {
|
||||
map.set(row.plate, row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/vehicle-info.ts
|
||||
git commit -m "refactor: extract vehicle-info query module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extract monitoring cache module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/cache.ts`
|
||||
|
||||
- [ ] **Step 1: Create the cache module**
|
||||
|
||||
This contains the cache singleton, refresh logic, and the `queryDateMileage` function. Both used to live in the monolith.
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/cache.ts
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix } from './types.js';
|
||||
|
||||
let monitoringCache: MonitoringCache | null = null;
|
||||
|
||||
export function getCache(): MonitoringCache | null {
|
||||
return monitoringCache;
|
||||
}
|
||||
|
||||
/** 部门排序顺序 */
|
||||
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
|
||||
function sortDepartments(departments: string[]): string[] {
|
||||
return departments.sort((a, b) => {
|
||||
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
|
||||
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
/** 从车辆列表计算筛选选项 */
|
||||
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
|
||||
const departments = sortDepartments(
|
||||
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
|
||||
);
|
||||
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
|
||||
const plates = vehicles.map(v => v.plate);
|
||||
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
|
||||
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
|
||||
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
|
||||
|
||||
const prefixCount = new Map<string, number>();
|
||||
for (const v of vehicles) {
|
||||
const p = v.plate.charAt(0);
|
||||
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
|
||||
}
|
||||
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
|
||||
.map(([prefix, count]) => ({ prefix, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
|
||||
}
|
||||
|
||||
/** 将里程原始行 + 车辆信息合并为 CachedVehicle 列表 */
|
||||
function mergeVehicles(
|
||||
mileageRows: { plate: string; vin: string; daily_km: string; total_km: string | null; source: string }[],
|
||||
infoMap: Map<string, { customer: string | null; department: string | null; manager: string | null; rent_status: string | null; entity: string | null; project: string | null }>,
|
||||
yesterdayMap: Map<string, number>,
|
||||
): CachedVehicle[] {
|
||||
// 去重:同一 plate 取 daily_km 最大的
|
||||
const mileageMap = new Map<string, typeof mileageRows[0]>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(mileageMap.values()).map(m => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新监控缓存(从两个数据库并行查询) */
|
||||
export async function refreshMonitoringCache(): Promise<void> {
|
||||
try {
|
||||
console.log('[mileage] refreshing monitoring cache...');
|
||||
const start = Date.now();
|
||||
|
||||
const [mileageRows, yesterdayMap, infoMap, targetRows] = await Promise.all([
|
||||
// 最新日期的里程数据
|
||||
(async () => {
|
||||
const [dateRows] = await mileagePool.execute(
|
||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||||
) as [{ latest: string | null }[], unknown];
|
||||
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[], unknown];
|
||||
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[], unknown];
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const existing = map.get(r.plate) || 0;
|
||||
if (km > existing) map.set(r.plate, km);
|
||||
}
|
||||
return map;
|
||||
})(),
|
||||
// 车辆关联信息
|
||||
fetchVehicleInfoMap(),
|
||||
// 考核批次→车牌映射
|
||||
pool.execute(
|
||||
`SELECT t.id, t.target_name, v.plate_number
|
||||
FROM tab_mileage_assessment_target t
|
||||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||
WHERE t.is_deleted = 0`
|
||||
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
|
||||
]);
|
||||
|
||||
// 构建批次映射
|
||||
const targetPlatesMap = new Map<string, Set<string>>();
|
||||
for (const r of targetRows) {
|
||||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
||||
set.add(r.plate_number);
|
||||
targetPlatesMap.set(r.target_name, set);
|
||||
}
|
||||
const targetNames = Array.from(targetPlatesMap.keys());
|
||||
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap);
|
||||
|
||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||
|
||||
monitoringCache = {
|
||||
vehicles,
|
||||
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
|
||||
filters: buildFilters(vehicles, targetNames),
|
||||
targetPlatesMap,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
|
||||
} catch (e: unknown) {
|
||||
console.error('[mileage] cache refresh error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询指定日期的里程数据(不使用缓存) */
|
||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||
const [mileageRows, yesterdayRows, infoMap] = 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[]),
|
||||
fetchVehicleInfoMap(),
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return mergeVehicles(mileageRows, infoMap, yesterdayMap);
|
||||
}
|
||||
|
||||
/** 构建指定日期数据的筛选选项 */
|
||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/cache.ts
|
||||
git commit -m "refactor: extract monitoring cache module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create monitoring route handler
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/monitoring.ts`
|
||||
|
||||
- [ ] **Step 1: Create the monitoring route**
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/monitoring.ts
|
||||
import { Hono } from 'hono';
|
||||
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
|
||||
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
vehicles: [],
|
||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/** 应用筛选条件 */
|
||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
search: string; dept: string; customer: string; project: string;
|
||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||
targetName: string; mileageMin: string; mileageMax: string;
|
||||
}): CachedVehicle[] {
|
||||
let result = vehicles;
|
||||
|
||||
if (params.search) {
|
||||
const q = params.search.toLowerCase();
|
||||
result = result.filter(v =>
|
||||
v.plate.toLowerCase().includes(q) ||
|
||||
(v.customer || '').toLowerCase().includes(q) ||
|
||||
(v.project || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
|
||||
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
|
||||
if (params.project) result = result.filter(v => v.project === params.project);
|
||||
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||
if (params.plate) result = result.filter(v => v.plate === params.plate);
|
||||
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
||||
if (params.targetName) {
|
||||
const cache = getCache();
|
||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
|
||||
}
|
||||
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
||||
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
app.get('/', async (c) => {
|
||||
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 date = c.req.query('date') || '';
|
||||
|
||||
const filterParams = {
|
||||
search: c.req.query('search') || '',
|
||||
dept: c.req.query('dept') || '',
|
||||
customer: c.req.query('customer') || '',
|
||||
project: c.req.query('project') || '',
|
||||
entity: c.req.query('entity') || '',
|
||||
rentStatus: c.req.query('rentStatus') || '',
|
||||
plate: c.req.query('plate') || '',
|
||||
platePrefix: c.req.query('platePrefix') || '',
|
||||
targetName: c.req.query('targetName') || '',
|
||||
mileageMin: c.req.query('mileageMin') || '',
|
||||
mileageMax: c.req.query('mileageMax') || '',
|
||||
};
|
||||
|
||||
// 获取数据源
|
||||
let allVehicles: CachedVehicle[];
|
||||
let filters: MonitoringFilters;
|
||||
|
||||
if (date) {
|
||||
try {
|
||||
allVehicles = await queryDateMileage(date);
|
||||
filters = buildDateFilters(allVehicles);
|
||||
} catch (e: unknown) {
|
||||
console.error('monitoring date query error:', e);
|
||||
return c.json(EMPTY_RESPONSE, 500);
|
||||
}
|
||||
} else {
|
||||
const cache = getCache();
|
||||
if (!cache) return c.json(EMPTY_RESPONSE);
|
||||
allVehicles = cache.vehicles;
|
||||
filters = cache.filters;
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const filtered = applyFilters(allVehicles, filterParams);
|
||||
|
||||
// 统计
|
||||
const stats = {
|
||||
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
|
||||
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
||||
vehicleCount: filtered.length,
|
||||
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
||||
};
|
||||
|
||||
// 排序
|
||||
const sorted = [...filtered].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 = sorted.slice(offset, offset + limit);
|
||||
const total = filtered.length;
|
||||
|
||||
return c.json({
|
||||
vehicles: paged,
|
||||
stats,
|
||||
filters,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/monitoring.ts
|
||||
git commit -m "refactor: create monitoring route handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create targets route handler
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/targets.ts`
|
||||
|
||||
- [ ] **Step 1: Create the targets route**
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/targets.ts
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { getCache } from './cache.js';
|
||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// GET /targets — 考核项目列表 + 汇总
|
||||
app.get('/', async (c) => {
|
||||
try {
|
||||
const [targets] = await pool.execute(
|
||||
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
|
||||
) as [any[], unknown];
|
||||
|
||||
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[], unknown];
|
||||
|
||||
const statsMap = new Map<number, any>();
|
||||
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
||||
|
||||
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[], unknown];
|
||||
|
||||
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 cache = getCache();
|
||||
const cacheVehicleMap = new Map<string, number>();
|
||||
if (cache) {
|
||||
for (const v of cache.vehicles) {
|
||||
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
|
||||
}
|
||||
}
|
||||
|
||||
const [targetVehicleRows] = await pool.execute(
|
||||
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
||||
) as [{ target_id: number; plate_number: string }[], unknown];
|
||||
|
||||
const targetIdPlatesMap = new Map<number, string[]>();
|
||||
for (const r of targetVehicleRows) {
|
||||
const list = targetIdPlatesMap.get(r.target_id) || [];
|
||||
list.push(r.plate_number);
|
||||
targetIdPlatesMap.set(r.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: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 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: unknown) {
|
||||
console.error('targets error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /target/:id/vehicles — 某项目的车辆明细
|
||||
app.get('/:id/vehicles', async (c) => {
|
||||
const targetId = c.req.param('id');
|
||||
const date = c.req.query('date') || '';
|
||||
|
||||
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[], unknown];
|
||||
|
||||
const plates: string[] = rows.map((r: any) => r.plate_number);
|
||||
const infoMap = await fetchVehicleInfoByPlates(plates);
|
||||
|
||||
// 指定日期时,从里程库查该日里程
|
||||
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
|
||||
if (date && plates.length > 0) {
|
||||
const [mileageRows] = await mileagePool.execute(
|
||||
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
|
||||
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
|
||||
[date, ...plates]
|
||||
) as [any[], unknown];
|
||||
for (const m of mileageRows) {
|
||||
const existing = dateMileageMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
if (!existing || dailyKm > existing.dailyKm) {
|
||||
const source = m.source || 'NONE';
|
||||
dateMileageMap.set(m.plate, {
|
||||
dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = rows.map((r: any) => {
|
||||
const info = infoMap.get(r.plate_number);
|
||||
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
|
||||
return {
|
||||
plateNumber: r.plate_number,
|
||||
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
|
||||
totalMileage: dateMileage?.totalKm ?? (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,
|
||||
rentStatus: info?.rent_status || null,
|
||||
department: info?.department || null,
|
||||
customer: info?.customer || null,
|
||||
isOnline: dateMileage ? dateMileage.isOnline : true,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
} catch (e: unknown) {
|
||||
console.error('target vehicles error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/targets.ts
|
||||
git commit -m "refactor: create targets route handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create trend route handler
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/trend.ts`
|
||||
|
||||
- [ ] **Step 1: Create the trend route**
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/trend.ts
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', 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 [{ plate_number: string }[], unknown];
|
||||
plates = vehicleRows.map(r => 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: (string | number)[] = [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[], unknown];
|
||||
|
||||
return c.json(rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
mileage: Math.round(Number(r.mileage) || 0),
|
||||
})));
|
||||
} catch (e: unknown) {
|
||||
console.error('trend error:', e);
|
||||
return c.json([], 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/trend.ts
|
||||
git commit -m "refactor: create trend route handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Assemble new index and swap in
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/routes/mileage/index.ts`
|
||||
- Delete: `src/server/routes/mileage.ts`
|
||||
|
||||
- [ ] **Step 1: Create the new index**
|
||||
|
||||
```typescript
|
||||
// src/server/routes/mileage/index.ts
|
||||
import { Hono } from 'hono';
|
||||
import { refreshMonitoringCache } from './cache.js';
|
||||
import monitoringRouter from './monitoring.js';
|
||||
import targetsRouter from './targets.js';
|
||||
import trendRouter from './trend.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.route('/monitoring', monitoringRouter);
|
||||
app.route('/targets', targetsRouter);
|
||||
app.route('/target', targetsRouter);
|
||||
app.route('/trend', trendRouter);
|
||||
|
||||
// 启动时立即刷新缓存,之后每分钟刷新
|
||||
refreshMonitoringCache();
|
||||
setInterval(refreshMonitoringCache, 60 * 1000);
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the old monolith**
|
||||
|
||||
```bash
|
||||
rm src/server/routes/mileage.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Verify the server starts and API works**
|
||||
|
||||
Run: `npm run dev` and test:
|
||||
- `curl http://localhost:3001/api/mileage/monitoring?limit=2` — should return vehicles
|
||||
- `curl http://localhost:3001/api/mileage/targets` — should return target list
|
||||
- `curl http://localhost:3001/api/mileage/trend?days=7` — should return trend data
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/mileage/ && git add -u src/server/routes/mileage.ts
|
||||
git commit -m "refactor: replace mileage monolith with modular route files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Fix the stale comment and final cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routes/mileage/cache.ts`
|
||||
|
||||
- [ ] **Step 1: Verify no leftover references to old file**
|
||||
|
||||
Run: `grep -r "routes/mileage.js" src/` — should only find `src/server/index.ts` which imports `./routes/mileage.js`. Since we moved to `mileage/index.ts`, the import path `./routes/mileage.js` resolves to `./routes/mileage/index.js` automatically. No change needed.
|
||||
|
||||
- [ ] **Step 2: Verify full build**
|
||||
|
||||
Run: `npx tsc --noEmit && npm run build`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "refactor: mileage backend refactor complete — verified build"
|
||||
```
|
||||
1615
docs/superpowers/plans/2026-04-16-smart-scheduling.md
Normal file
1615
docs/superpowers/plans/2026-04-16-smart-scheduling.md
Normal file
File diff suppressed because it is too large
Load Diff
1242
docs/superpowers/plans/2026-04-28-energy-module.md
Normal file
1242
docs/superpowers/plans/2026-04-28-energy-module.md
Normal file
File diff suppressed because it is too large
Load Diff
153
docs/superpowers/specs/2026-03-27-three-modules-design.md
Normal file
153
docs/superpowers/specs/2026-03-27-three-modules-design.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 三大运营统计模块设计
|
||||
|
||||
从 lnoneos 原型迁移到 ln-bi 生产项目,使用真实 MySQL 数据。
|
||||
|
||||
## 架构决策
|
||||
|
||||
- **数据源**:复用现有 `getVehicles()` 缓存(~1000 辆,内存聚合无性能问题)
|
||||
- **跳过**:出勤率、日均里程(无数据源),QR Code
|
||||
- **新增图标**:`Search`, `Filter`, `ArrowRightLeft` (lucide-react,已安装)
|
||||
|
||||
## 模块 1:部门运营统计
|
||||
|
||||
### 后端 API
|
||||
|
||||
**`GET /api/vehicles/dept-stats`** — 返回 `DeptGroup[]`
|
||||
|
||||
聚合逻辑:按 `Vehicle.departmentName` 分组,每个部门下按 `Vehicle.customerManager` 分组。每个业务员统计车型分布:
|
||||
|
||||
| 车型类别 | 过滤条件 |
|
||||
|---|---|
|
||||
| t4_5 | type=4.5T 且 model 不含"冷链" |
|
||||
| t4_5c | type=4.5T 且 model 含"冷链" |
|
||||
| t18 | type=18T |
|
||||
| t49 | type=49T |
|
||||
| trailer | model 含"挂车" |
|
||||
| other | 以上都不是 |
|
||||
|
||||
部门级别额外字段:`totalAssets`(运营中的)、`operatingCount`(status=Operating)、`idleCount`(status=Inventory 或 Abnormal)。
|
||||
|
||||
### 后端:扩展 `/api/vehicles/list`
|
||||
|
||||
新增查询参数:
|
||||
- `manager` — 按客户经理筛选
|
||||
- `customer` — 按客户名称筛选
|
||||
- `isColdChain` — true/false,筛选冷链/非冷链
|
||||
- `isTrailer` — true/false,筛选挂车/非挂车
|
||||
|
||||
### 前端类型
|
||||
|
||||
```typescript
|
||||
interface ManagerStats {
|
||||
manager: string;
|
||||
department: string;
|
||||
t4_5: number;
|
||||
t4_5c: number;
|
||||
t18: number;
|
||||
t49: number;
|
||||
trailer: number;
|
||||
other: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface DeptGroup {
|
||||
department: string;
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
idleCount: number;
|
||||
managers: ManagerStats[];
|
||||
}
|
||||
```
|
||||
|
||||
### 前端 UI
|
||||
|
||||
参照 lnoneos 1362-1880 行:
|
||||
- 顶部深色汇总条(总资产/运营中/闲置中,跳过平均出勤)
|
||||
- 按部门/按业务员切换
|
||||
- 桌面表格 + 移动端卡片
|
||||
- 展开部门显示业务员卡片,展开业务员显示 6 个车型格子(可点击下钻到车牌列表)
|
||||
|
||||
## 模块 2:区域运营统计
|
||||
|
||||
### 后端 API
|
||||
|
||||
**`GET /api/vehicles/region-stats`** — 返回 `RegionGroup[]`
|
||||
|
||||
新增大区映射函数(province/city → 华东/华南/华北/华中/西南/西北/其他)。按大区分组,每个区域下统计:
|
||||
- 按车型(4.5T/18T/49T)的资产/运营/库存数
|
||||
- 列出区域内的客户列表
|
||||
|
||||
```typescript
|
||||
interface RegionGroup {
|
||||
region: string; // 华东、华南等
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
inventoryCount: number;
|
||||
customers: string[];
|
||||
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
|
||||
}
|
||||
```
|
||||
|
||||
### 前端 UI
|
||||
|
||||
参照 lnoneos 1882-2174 行:
|
||||
- 筛选弹出框(客户搜索/区域/城市下拉)
|
||||
- 可展开区域行,展开后显示车型子行
|
||||
- 桌面表格 + 移动端卡片
|
||||
|
||||
## 模块 3:客户运营统计
|
||||
|
||||
### 后端 API
|
||||
|
||||
**`GET /api/vehicles/customer-stats`** — 返回 `CustomerStats[]`
|
||||
|
||||
按 `Vehicle.customerName` 分组(只统计 status=Operating 的车辆),每个客户统计:
|
||||
- 关联业务员(customerManager)、品牌(brandLabel)、部门(departmentName)
|
||||
- 大区(从 province/city 映射)、城市
|
||||
- 6 个车型分列计数 + 合计
|
||||
|
||||
```typescript
|
||||
interface CustomerStats {
|
||||
customer: string;
|
||||
manager: string;
|
||||
brand: string;
|
||||
department: string;
|
||||
region: string;
|
||||
city: string;
|
||||
t4_5: number;
|
||||
t4_5c: number;
|
||||
t18: number;
|
||||
t49: number;
|
||||
trailer: number;
|
||||
other: number;
|
||||
total: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 前端 UI
|
||||
|
||||
参照 lnoneos 2176-2496 行:
|
||||
- 筛选弹出框(客户名/业务员搜索,品牌/部门/区域下拉)
|
||||
- 翡翠绿色主题表头
|
||||
- 客户表格,各车型列可点击下钻
|
||||
- 展开后显示 4 个详情卡片(客户详情/主要车型/运营状态/资产占比)
|
||||
- 桌面表格 + 移动端卡片
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `src/server/routes/vehicles.ts` | 新增 3 个 API 端点 + 扩展 `/list` 的过滤参数 + 大区映射函数 |
|
||||
| `src/types.ts` | 新增 `DeptGroup`, `ManagerStats`, `CustomerStats`, `RegionGroup` 接口 |
|
||||
| `src/server/types.ts` | 同步新增后端类型 |
|
||||
| `src/api.ts` | 新增 `fetchDeptStats`, `fetchRegionStats`, `fetchCustomerStats` |
|
||||
| `src/App.tsx` | 新增 3 个 section + 相关 state/toggle/filter 逻辑 + 扩展 showPlateNumbers 类型 |
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 后端:大区映射 + 3 个 API + 扩展 list 过滤
|
||||
2. 前端类型 + API 客户端
|
||||
3. 部门运营统计 UI
|
||||
4. 区域运营统计 UI
|
||||
5. 客户运营统计 UI
|
||||
6. 验证构建通过
|
||||
224
docs/superpowers/specs/2026-04-16-smart-scheduling-design.md
Normal file
224
docs/superpowers/specs/2026-04-16-smart-scheduling-design.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 智能调度模块设计
|
||||
|
||||
基于里程考核数据,通过贪心优先级匹配算法,生成车辆替换建议,帮助调度员优化车队里程分布,最大化达标车辆数。
|
||||
|
||||
## 业务背景
|
||||
|
||||
公司有多批次考核车辆(40台普货、190台冷藏车等),每批次有年度里程考核目标。车辆租赁给不同客户,客户实际使用强度差异大。考核的是**车辆本身的里程**,因此需要通过替换车辆来均衡里程:
|
||||
- 高里程客户的已达标车换下来,换上里程缺口大的车(让新车追赶)
|
||||
- 低里程客户的无望达标车换下来给高里程客户(抢救),给低里程客户换上已达标的车
|
||||
|
||||
## 核心算法
|
||||
|
||||
### 车辆分类
|
||||
|
||||
从 `tab_mileage_assessment_vehicle` 获取所有考核车辆,按客户聚合计算**客户日均里程**(客户下所有车辆近 30 天日均里程的平均值),然后对每辆车计算:
|
||||
|
||||
```
|
||||
预测年终里程 = 当前累计里程 + 客户日均里程 × 剩余天数
|
||||
达标概率 = 预测年终里程 / 年度目标里程
|
||||
```
|
||||
|
||||
分为三类:
|
||||
|
||||
| 类型 | 条件 | 含义 |
|
||||
|------|------|------|
|
||||
| qualified | `currentYearIsQualified = true` 或 达标概率 ≥ 120% | 已完成或铁定完成 |
|
||||
| hopeless | 达标概率 < 60% | 按当前客户使用强度,年底肯定完不成 |
|
||||
| normal | 60% ≤ 达标概率 < 120% | 有希望但不确定,暂不干预 |
|
||||
|
||||
### 替换建议生成
|
||||
|
||||
**场景 A:replace_qualified(高里程客户的已达标车辆)**
|
||||
- 目标:把已达标的车换下来,换上里程缺口大的库存车
|
||||
- 候选池:库存车(rent_status='在库')+ 同车型 + 同区域
|
||||
- 排序:优先选剩余缺口最大但换后仍可达标的车
|
||||
- 校验:`候选车当前累计 + 客户日均 × 剩余天数 ≥ 年度目标` 才推荐
|
||||
|
||||
**场景 B:rescue_hopeless(低里程客户的无望达标车辆)**
|
||||
- 目标:把无望车换给高里程客户抢救,给低里程客户换上已达标/库存车
|
||||
- 候选池:库存中已达标或将达标的同车型同区域车辆
|
||||
- 排序:优先选已达标且里程最高的车(对低里程客户无影响)
|
||||
|
||||
### 车型匹配规则
|
||||
|
||||
| 源车型 | 可替换为 | 说明 |
|
||||
|--------|---------|------|
|
||||
| 4.5T冷链 | 4.5T冷链、4.5T普货 | 冷链不开空调可当普货用 |
|
||||
| 4.5T普货 | 4.5T普货 | 不能反向替换冷链 |
|
||||
| 18T | 18T | 同型号互换 |
|
||||
| 49T | 49T | 同型号互换 |
|
||||
| 挂车 | 挂车 | 同型号互换 |
|
||||
|
||||
### 区域匹配规则
|
||||
|
||||
复用已有 `mapRegion()` 函数,将 province/city 映射到大区(嘉兴/广东/北京/新疆/其他)。同一大区内可替换,跨大区不推荐。
|
||||
|
||||
### 优先级排序
|
||||
|
||||
干预清单排序:
|
||||
1. **hopeless + 有可行替换方案** → priority: high(最紧急,还能抢救)
|
||||
2. **qualified + 高里程客户 + 有库存可换** → priority: medium(释放达标车,让新车追赶)
|
||||
|
||||
## 后端 API
|
||||
|
||||
### GET /api/scheduling/suggestions
|
||||
|
||||
获取调度建议列表。每次请求实时计算(不使用定时缓存),因为用户操作后需要立即看到最新结果。
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| targetId | number (可选) | 按批次筛选,不传则全部 |
|
||||
|
||||
**响应**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
summary: {
|
||||
qualifiedCount: number; // 已达标车辆数
|
||||
hopelessCount: number; // 无望达标车辆数
|
||||
suggestionCount: number; // 可干预建议数
|
||||
estimatedGain: number; // 预计干预后可新增达标数
|
||||
};
|
||||
suggestions: SchedulingSuggestion[];
|
||||
targets: { id: number; name: string; vehicleCount: number }[];
|
||||
}
|
||||
```
|
||||
|
||||
`SchedulingSuggestion` 结构:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string; // 建议唯一ID(如 "s-{plate}-{timestamp}")
|
||||
priority: 'high' | 'medium';
|
||||
type: 'replace_qualified' | 'rescue_hopeless';
|
||||
|
||||
currentVehicle: {
|
||||
plateNumber: string;
|
||||
targetId: number;
|
||||
targetName: string; // 所属批次
|
||||
vehicleType: string; // "4.5T冷链" / "18T" 等
|
||||
totalMileage: number;
|
||||
completionRate: number; // 0-1
|
||||
yearTarget: number; // 年度目标里程
|
||||
region: string; // 大区(嘉兴/广东等)
|
||||
province: string; // 原始省份
|
||||
customer: string;
|
||||
customerAvgDaily: number; // 客户日均里程
|
||||
predictedYearEnd: number; // 预测年终里程
|
||||
daysLeft: number;
|
||||
};
|
||||
|
||||
candidates: {
|
||||
plateNumber: string;
|
||||
targetId: number | null; // 库存车可能无批次
|
||||
targetName: string | null;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number | null;
|
||||
region: string;
|
||||
province: string;
|
||||
mileageGap: number; // 剩余缺口
|
||||
predictedAfterSwap: number; // 换到该客户后预测年终里程
|
||||
canQualifyAfterSwap: boolean;
|
||||
}[];
|
||||
|
||||
reason: string; // 建议原因文案
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/scheduling/notify
|
||||
|
||||
发送替换通知。成功后前端立即重新拉取 suggestions。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
suggestionId: string;
|
||||
currentPlate: string;
|
||||
candidatePlate: string;
|
||||
}
|
||||
```
|
||||
|
||||
操作人从 JWT auth 中获取。
|
||||
|
||||
**响应**:`{ success: boolean; message: string }`
|
||||
|
||||
**行为**:调用外部回调接口发送通知(具体回调 URL 后续配置)。成功后在本地记录已操作状态,后续 GET suggestions 时排除已操作的建议。
|
||||
|
||||
### 数据查询流程
|
||||
|
||||
后端一次请求聚合以下数据:
|
||||
1. 所有考核车辆 — `tab_mileage_assessment_vehicle`(里程进度、达标状态)
|
||||
2. 所有考核目标 — `tab_mileage_assessment_target`(批次名称、年度目标)
|
||||
3. 库存车辆 — `tab_truck WHERE truck_rent_status = 0`(在库)+ 同表获取车型
|
||||
4. 车辆实时位置 — `tab_truck_remote_sync_realtime_info`(province, city)
|
||||
5. 合同/客户信息 — 复用 `vehicle-info.ts` 已有的 JOIN 查询
|
||||
6. 客户日均里程 — 按客户聚合 `v_vehicle_daily_stats` 近 30 天均值
|
||||
|
||||
## 前端结构
|
||||
|
||||
### 文件组织
|
||||
|
||||
```
|
||||
src/modules/scheduling/
|
||||
├── SchedulingModule.tsx // 主入口,状态管理和数据加载
|
||||
├── SuggestionList.tsx // 干预建议清单列表
|
||||
├── SuggestionDetail.tsx // 单条建议展开详情(含替换车辆对比)
|
||||
├── api.ts // fetchSuggestions(), sendNotify()
|
||||
└── types.ts // SchedulingSuggestion 等类型定义
|
||||
```
|
||||
|
||||
后端:
|
||||
```
|
||||
src/server/routes/scheduling/
|
||||
├── index.ts // 路由注册
|
||||
├── suggestions.ts // GET /suggestions 算法核心
|
||||
└── notify.ts // POST /notify 回调通知
|
||||
```
|
||||
|
||||
### 页面层级
|
||||
|
||||
```
|
||||
智能调度 Tab
|
||||
├── 顶部:批次选择器(复用里程统计的批次 tabs,默认"全部")
|
||||
├── 统计卡片区(3 个)
|
||||
│ ├── 已达标车辆数(绿色)
|
||||
│ ├── 无望达标车辆数(红色)
|
||||
│ └── 可干预建议数 + 预计可新增达标数(蓝色)
|
||||
├── 干预建议清单(主列表,按优先级排序)
|
||||
│ ├── 每条:车牌、批次、客户、客户日均、完成率、区域、类型标签(已达标/无望)
|
||||
│ └── 点击 → 展开干预详情
|
||||
└── 干预详情(弹窗)
|
||||
├── 当前车辆信息卡片
|
||||
├── 推荐替换车辆列表(最多 5 辆)
|
||||
│ └── 每辆显示对比:替换前后的区域、车型、里程、预测达标
|
||||
├── 建议原因说明
|
||||
└── 「发送替换通知」按钮 → notify 接口 → 成功后刷新列表
|
||||
```
|
||||
|
||||
### UI 设计要求
|
||||
|
||||
- 以原型 `SmartSchedulingView` 组件为基础风格
|
||||
- 使用 ui-ux-pro-max 优化视觉质量
|
||||
- 适配移动端(竖屏卡片流)和 Web 端(landscape 横屏大表格)
|
||||
- 干预详情弹窗需截图友好:完整卡片布局、替换前后对比一屏可见、关键数据醒目
|
||||
- 统计卡片区保持与原型一致的三列 grid 布局
|
||||
- 批次选择器横向滚动 pill 按钮样式
|
||||
|
||||
### 技术栈
|
||||
|
||||
复用项目已有:React 19 + Tailwind CSS + motion/react(动画)+ recharts(图表)+ lucide-react(图标)
|
||||
|
||||
## 约束与边界
|
||||
|
||||
- 替换仅为建议,不直接操作数据库修改车辆归属
|
||||
- 不能推荐已租赁给其他客户的车辆,只从库存(在库)中推荐
|
||||
- 跨批次可替换,但车型必须匹配(含冷链→普货单向规则)
|
||||
- 同大区内替换,不跨大区
|
||||
- notify 操作后数据立即更新(不使用定时缓存)
|
||||
- 客户名称展示需使用已有的脱敏/Blur 组件
|
||||
264
docs/superpowers/specs/2026-04-28-energy-module-design.md
Normal file
264
docs/superpowers/specs/2026-04-28-energy-module-design.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 能源管理模块设计
|
||||
|
||||
在底部导航增加「能源管理」入口,集中展示加氢/充电的成本与用量数据。当前阶段**只做前端原型,数据全部走前端 mock**,后端接入留作下一阶段。
|
||||
|
||||
## 业务背景
|
||||
|
||||
参考 FineBI 既有的三张大屏:
|
||||
|
||||
| BI 链接 | 标题 | 内容 |
|
||||
|---------|------|------|
|
||||
| `link/0iqP` | 氢气管理(氢费统计) | 年/月/日加氢量与加氢费 KPI、加氢业务区域分布、Top5 加氢站、各区域占比 |
|
||||
| `link/GBSp` | 加氢站氢量每日汇总 | 每日加氢量与环比,下钻到站点级带单价 |
|
||||
| `link/TPqB` | 龙王路停车场充电站每日充电汇总 | 每日充电量(度) + 充电费用(元) |
|
||||
|
||||
这些大屏目前只在桌面浏览器里好看,移动端体验较差,且与现有应用风格割裂。新模块的目标是把核心信息按「移动优先 + 双端响应」重新组织进当前 BI App 的底部导航,让一线运营在手机上也能秒级抓取关键能耗指标。
|
||||
|
||||
## 用户故事
|
||||
|
||||
- 作为运营人员,进 App 底部 Tab 「能源管理」,第一屏立刻能看到本年/本月/今日的加氢量与加氢费。
|
||||
- 作为运营人员,能切换查看「氢能」和「电能」两类业务。
|
||||
- 作为氢能业务方,能看到每日加氢明细,并下钻到站点级别(站名 + 单价)。
|
||||
- 作为电能业务方,能看到龙王路充电站每日充电量与充电费。
|
||||
|
||||
## 范围
|
||||
|
||||
### 在范围内
|
||||
- 新模块 `src/modules/energy/`,遵循 mileage 模块的目录骨架
|
||||
- `EnergyModule` 顶级组件 + 二级 Tab(氢能 / 电能)
|
||||
- 氢能下:总览(KPI + Top5 横柱 + 区域占比环)+ 每日(明细表带下钻)
|
||||
- 电能下:mini KPI 头 + 每日明细表
|
||||
- 全前端 mock 数据,数据值从 BI 截图取真实样本,文件 `mock.ts`
|
||||
- 双端响应(mobile + web)
|
||||
- 在 `App.tsx` 的 `BASE_MODULES` 注册(登录即可见)
|
||||
|
||||
### 不在范围内
|
||||
- 后端接入、真实数据库表设计(下一期)
|
||||
- 中国地图(加氢业务区域分布)— 工作量与依赖(高德/echarts-geo)大,后续单独立项
|
||||
- iframe 嵌入 FineBI(已否决)
|
||||
- 角色权限门禁(暂不需要)
|
||||
- 数据导出 / CSV
|
||||
- 站点详情页 / 区域详情页
|
||||
|
||||
## 模块注册(App.tsx)
|
||||
|
||||
```ts
|
||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
|
||||
];
|
||||
```
|
||||
|
||||
并在 `Shell.tsx` 的 `PATH_MAP` 加 `'/energy': 'energy'`。`#energy` hash 由 Shell 已有逻辑兜底。
|
||||
|
||||
## 页面结构
|
||||
|
||||
```
|
||||
EnergyModule
|
||||
├── 顶部 sticky sub-nav: [氢能] [电能] ← motion layoutId 滑块动画
|
||||
│
|
||||
├── 氢能 view (HydrogenView)
|
||||
│ ├── 内层 sticky tab: [总览] [每日]
|
||||
│ ├── HydrogenOverview
|
||||
│ │ ├── 数据时间提示条:「数据自 2025-01-01 起,每 5 分钟更新」
|
||||
│ │ ├── KPI 网格(移动 2×2 / 桌面 1×4)
|
||||
│ │ ├── Top5 加氢站横柱图
|
||||
│ │ └── 各区域加氢占比环形图
|
||||
│ └── HydrogenDaily
|
||||
│ ├── 日期速选 6 选 1:当天 / 本周 / 本月 / 本季度 / 最近7天 / 最近30天
|
||||
│ ├── 客户类型 2 选 1:外部 / 羚牛
|
||||
│ ├── 合计行(pin 在表头下)
|
||||
│ └── 表格:日期 → 加氢量(Kg) → 环比%(日期行可展开为站点级)
|
||||
│
|
||||
└── 电能 view (ElectricView)
|
||||
├── 数据时间提示条:「龙王路停车场充电站,期初 2025-01-01,手工导入每日更新」
|
||||
├── 横向 mini KPI 头(3 列:累计 / 本月 / 今日)
|
||||
├── 客户类型 2 选 1:外部 / 羚牛
|
||||
└── 表格:月份/日期 → 充电电量(度) → 充电费用(元)(月份组可展开为日级)
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/energy/
|
||||
├── EnergyModule.tsx # 顶级容器 + 氢能/电能 切换
|
||||
├── HydrogenView.tsx # 含「总览/每日」二级 Tab
|
||||
├── HydrogenOverview.tsx # KPI + Top5 + 区域占比
|
||||
├── HydrogenDaily.tsx # 加氢量明细表
|
||||
├── ElectricView.tsx # 充电汇总表
|
||||
├── mock.ts # 所有 mock 数据
|
||||
└── types.ts # 共享类型
|
||||
```
|
||||
|
||||
参照 `src/modules/mileage/` 风格。**没有 api.ts**(后端先不接)。
|
||||
|
||||
## 数据形状(mock.ts / types.ts)
|
||||
|
||||
```ts
|
||||
// types.ts
|
||||
export type CustomerType = 'external' | 'lingniu'; // 外部 / 羚牛
|
||||
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
||||
|
||||
export interface HydrogenKpi {
|
||||
yearKg: number; // 年加氢量 Kg
|
||||
yearFee: number; // 年加氢费 元
|
||||
ourYearKg: number; // 我方年加氢量
|
||||
ourYearFee: number; // 我方年加氢费
|
||||
customerYearKg: number; // 客户产生年加氢量
|
||||
monthKg: number;
|
||||
monthFee: number;
|
||||
todayKg: number;
|
||||
todayFee: number;
|
||||
lingniuBornKg: number; // 累计羚牛承担量
|
||||
lingniuBornFee: number; // 累计羚牛承担费
|
||||
}
|
||||
|
||||
export interface HydrogenStation {
|
||||
name: string;
|
||||
kg: number;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
export interface HydrogenRegionShare {
|
||||
region: string;
|
||||
kg: number;
|
||||
share: number; // 0-1
|
||||
}
|
||||
|
||||
export interface HydrogenStationRow {
|
||||
name: string;
|
||||
pricePerKg: number; // 单价 元/Kg
|
||||
kg: number;
|
||||
chainPct: number; // 环比 -1..+1
|
||||
}
|
||||
|
||||
export interface HydrogenDailyRow {
|
||||
date: string; // 'YYYY-MM-DD'
|
||||
totalKg: number;
|
||||
chainPct: number;
|
||||
stations: HydrogenStationRow[];
|
||||
}
|
||||
|
||||
export interface ElectricKpi {
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
monthKwh: number;
|
||||
monthFee: number;
|
||||
todayKwh: number;
|
||||
todayFee: number;
|
||||
todayChainPct: number;
|
||||
}
|
||||
|
||||
export interface ElectricDailyRow {
|
||||
date: string; // 'YYYY-MM-DD'
|
||||
kwh: number;
|
||||
fee: number;
|
||||
chainPct: number; // 环比,用于趋势箭头
|
||||
}
|
||||
|
||||
export interface ElectricMonthGroup {
|
||||
month: string; // 'YYYY-MM'
|
||||
kwh: number;
|
||||
fee: number;
|
||||
rows: ElectricDailyRow[];
|
||||
}
|
||||
```
|
||||
|
||||
`mock.ts` 提供:
|
||||
- `hydrogenKpi`:取自 0iqP 的 362.43T / ¥1066.46 万 / 10.03 万 等真实样本
|
||||
- `hydrogenStationsTop5`:5 家站,名字取自 GBSp 截图
|
||||
- `hydrogenRegionShare`:约 8-12 个区域条目
|
||||
- `hydrogenDaily`:约 30 天数据,前 7 天每天 3-4 个站点;剩余天只有汇总 + 1-2 站点
|
||||
- `electricKpi`:取自 TPqB 合计 817,632.24 度 / ¥151,542.92
|
||||
- `electricMonthly`:以月为顶级 group,含每日明细,至少覆盖 2026-04 起若干天
|
||||
|
||||
数字均做轻微抖动,但保留量级与百分比,避免与 BI 大屏数字"完全一致"误导。
|
||||
|
||||
## 视觉规范
|
||||
|
||||
### 通用
|
||||
- 容器:`min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6` + `max-w-6xl mx-auto`
|
||||
- 横屏:保留 `landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden`
|
||||
- 卡片:`bg-white rounded-2xl border border-slate-100 shadow-sm`
|
||||
- sub-nav:`bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm` + sticky top 0
|
||||
- Tab active 用 `motion.div layoutId="..."` 下划线动画(沿用 mileage 模式)
|
||||
- 主色:`text-blue-600 / bg-blue-50`
|
||||
- 正向:`text-emerald-500 / bg-emerald-50`
|
||||
- 负向:`text-red-500 / bg-red-50`
|
||||
- 警示:`text-amber-500 / bg-amber-50`
|
||||
|
||||
### 氢能总览
|
||||
- KPI 卡:4 张主角卡,顺序固定
|
||||
1. 年加氢量(青蓝渐变 `from-cyan-50 to-blue-50`)+ 主数 `362.43T` + 分解两行(我方 / 客户产生)
|
||||
2. 年加氢费(蓝紫渐变 `from-blue-50 to-violet-50`)+ 主数 `¥1066.46万` + 分解(我方)
|
||||
3. 累计羚牛承担(蜜橙渐变 `from-amber-50 to-orange-50`)+ 主数 `¥10.03万` + 分解(量 / 费)
|
||||
4. 本月/今日合并卡(浅灰)+ 左半月度 + 右半今日,文字主数 `text-2xl md:text-3xl`
|
||||
- 卡左上 lucide icon:`Fuel` / `Wallet` / `Coins` / `CalendarClock`
|
||||
- KPI 主数移动端 `text-2xl font-bold`,桌面 `md:text-3xl`,分解行 `text-[11px] text-slate-500 font-bold`
|
||||
- Top5 加氢站:横向柱状(recharts BarChart `layout="vertical"`),柱子蓝→青渐变,柱左侧带数字徽章 1-5,柱右端贴 `XX,XXX Kg · XX%`,`<ResponsiveContainer width="100%" height={240}>`
|
||||
- 区域占比环形:recharts PieChart,外环切片,中心圆心放年合计 `362.43T`,下方两列图例(移动单列、桌面双列)
|
||||
|
||||
### 氢能每日表
|
||||
- 日期速选:6 个 pill 一行,可横向滚动,激活态 `bg-blue-50 text-blue-600 border-blue-200`
|
||||
- 客户类型:2 列等宽 segmented control(背景 `bg-slate-100 rounded-xl`,激活态白底+阴影)
|
||||
- 合计行:`bg-blue-50/50 text-blue-600` 粗体
|
||||
- 主行 = 日期:左侧 `▶` 折叠图标 + 日期;展开后子行内缩进 `pl-6`,子行内容 = 站名 - 单价 元/Kg
|
||||
- 环比 pill 双端共用样式:
|
||||
- 上 `↑` 绿底 `bg-emerald-50 text-emerald-600`
|
||||
- 下 `↓` 红底 `bg-red-50 text-red-600`
|
||||
- 持平 `–` 灰底 `bg-slate-100 text-slate-500`
|
||||
- 圆角 `rounded-full px-2 py-0.5 text-[11px] font-bold`
|
||||
- 移动 3 列(日期 / 量 / 环比),桌面 4 列加站价列(站点级行用网格而非缩进)
|
||||
|
||||
### 电能
|
||||
- mini KPI 头:3 卡横排(移动也保持横排,不堆叠),每卡上排 `¥金额` 主数(`text-xl md:text-2xl`),下排 `XXX 度` 副数 + 今日卡再加 pill 环比
|
||||
- 月份组:`▶` + `2026-04` + 该月合计 `度 / 元`;展开后是日级行
|
||||
- 月份组 active 时背景 `bg-blue-50/30`
|
||||
- 行的趋势图标 → 与氢能页用同一 pill 组件(保证视觉一致)
|
||||
|
||||
## 响应式行为
|
||||
|
||||
| 区域 | 移动 (<md) | 桌面 (≥md) |
|
||||
|------|-----------|-----------|
|
||||
| 顶部 sub-nav (氢/电) | sticky 满宽 | sticky 满宽,左对齐 |
|
||||
| 氢能内层 sub-tab | 紧贴 sub-nav 下 | 同 |
|
||||
| 氢能总览 KPI 网格 | `grid-cols-2` | `md:grid-cols-4` |
|
||||
| Top5 横柱 + 区域占比 | 上下叠 `grid-cols-1` | `md:grid-cols-2 gap-4` |
|
||||
| 每日氢能表 | 3 列:日期/量/环比 | 4 列:日期/站价/量/环比,站点级行同样 4 列 |
|
||||
| 电能 mini KPI 头 | 横排 3 卡(gap 紧凑) | 3 卡(gap 宽松) |
|
||||
| 电能表格 | 3 列 | 4-5 列(可加客户类型 / 趋势火花线,留作后期) |
|
||||
| 容器宽度上限 | 100% | `max-w-6xl mx-auto` |
|
||||
| recharts 图 | `<ResponsiveContainer width="100%">` | 同 |
|
||||
|
||||
## 组件复用
|
||||
|
||||
- 内层 sub-nav 抄 `MileageModule.tsx` 的 sticky tab 实现(含 motion 滑块)
|
||||
- segmented control(客户类型 / 日期速选)抄 mileage 已有的实现
|
||||
- 表格行 chevron 折叠抄 assets 模块里现有展开行的写法
|
||||
- 环比 pill 单独抽 `<TrendBadge value={number} />` 共用组件,放在 `src/modules/energy/HydrogenDaily.tsx` 顶端 export
|
||||
|
||||
## 边界与开放点
|
||||
|
||||
1. **`氢费`字面口径**:用户原始描述「每日氢费」,但 BI GBSp 实际只有加氢量+环比,单价在站点级。本期采纳 BI 的口径——表格只展示量+环比+单价(嵌入站名),费汇总放氢能总览的 KPI 卡。
|
||||
2. **数据时间**:mock 用 2026-04-28 为「今天」(`currentDate` 取自 user 自动 memory)。`今日加氢` 在 0Kg 时显示 `0` 而非 `--`,避免误判为缺失。
|
||||
3. **空态**:mock 数据已写满,UI 仍要支持 `rows.length === 0` 时显示「暂无数据」灰文 + 图标。
|
||||
4. **未来后端接入**:mock 文件命名 `mock.ts`,不放在 `api.ts`;后期添加 `api.ts` 时同名 export,UI 切到 `useEffect + fetch` 即可。
|
||||
5. **icon 选型**:`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`。
|
||||
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 底部 nav 多出「能源管理」Tab,icon Zap,登录后可见
|
||||
- [ ] 进入后默认在「氢能 → 总览」
|
||||
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
|
||||
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
|
||||
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
|
||||
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
|
||||
- [ ] 切到电能 Tab,3 张 mini KPI + 月份分组表,月份能展开日级行
|
||||
- [ ] 横屏不出现底部 nav 遮挡内容
|
||||
- [ ] `npm run lint` 通过
|
||||
- [ ] 不引入新依赖(recharts / lucide-react / motion 已有)
|
||||
1048
package-lock.json
generated
1048
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -23,10 +24,12 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tsx": "^4.21.0"
|
||||
"tsx": "^4.21.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
178
scripts-tmp/excel_plates.txt
Normal file
178
scripts-tmp/excel_plates.txt
Normal file
@@ -0,0 +1,178 @@
|
||||
沪A00113F
|
||||
沪A00220F
|
||||
沪A00333F
|
||||
沪A00607F
|
||||
沪A01056F
|
||||
沪A01311F
|
||||
沪A01775F
|
||||
沪A01813F
|
||||
沪A01855F
|
||||
沪A02303F
|
||||
沪A02311F
|
||||
沪A02326F
|
||||
沪A02361F
|
||||
沪A02720F
|
||||
沪A03086F
|
||||
沪A03397F
|
||||
沪A03565F
|
||||
沪A03620F
|
||||
沪A03659F
|
||||
沪A03801F
|
||||
沪A03870F
|
||||
沪A05035F
|
||||
沪A05113F
|
||||
沪A05223F
|
||||
沪A05501F
|
||||
沪A05675F
|
||||
沪A05697F
|
||||
沪A05830F
|
||||
沪A06335F
|
||||
沪A06599F
|
||||
沪A06695F
|
||||
沪A07006F
|
||||
沪A07153F
|
||||
沪A07806F
|
||||
沪A08037F
|
||||
沪A08150F
|
||||
沪A08315F
|
||||
沪A08598F
|
||||
沪A08786F
|
||||
沪A09100F
|
||||
沪A09251F
|
||||
沪A09276F
|
||||
沪A09303F
|
||||
沪A09313F
|
||||
沪A09322F
|
||||
沪A09689F
|
||||
沪A30010F
|
||||
沪A30399F
|
||||
沪A31031F
|
||||
沪A31211F
|
||||
沪A31281F
|
||||
沪A31308F
|
||||
沪A31381F
|
||||
沪A31613F
|
||||
沪A32269F
|
||||
沪A33216F
|
||||
沪A35236F
|
||||
沪A35798F
|
||||
沪A35879F
|
||||
沪A35898F
|
||||
沪A36133F
|
||||
沪A36169F
|
||||
沪A36569F
|
||||
沪A36980F
|
||||
沪A37785F
|
||||
沪A38795F
|
||||
沪A39287F
|
||||
沪A39289F
|
||||
沪A39585F
|
||||
沪A39608F
|
||||
沪A39626F
|
||||
沪A39815F
|
||||
沪A39835F
|
||||
沪A39912F
|
||||
沪A50026F
|
||||
沪A50069F
|
||||
沪A50309F
|
||||
沪A51580F
|
||||
沪A51612F
|
||||
沪A51677F
|
||||
沪A51893F
|
||||
沪A52331F
|
||||
沪A52511F
|
||||
沪A53309F
|
||||
沪A53322F
|
||||
沪A53506F
|
||||
沪A53960F
|
||||
沪A55179F
|
||||
沪A55297F
|
||||
沪A55339F
|
||||
沪A55666F
|
||||
沪A55695F
|
||||
沪A56122F
|
||||
沪A56701F
|
||||
沪A56959F
|
||||
沪A56988F
|
||||
沪A57139F
|
||||
沪A57167F
|
||||
沪A57198F
|
||||
沪A57838F
|
||||
沪A57850F
|
||||
沪A57895F
|
||||
沪A58087F
|
||||
沪A58159F
|
||||
沪A58185F
|
||||
沪A58307F
|
||||
沪A58533F
|
||||
沪A58538F
|
||||
沪A58593F
|
||||
沪A58922F
|
||||
沪A59095F
|
||||
沪A59510F
|
||||
沪A59613F
|
||||
沪A59682F
|
||||
沪A59799F
|
||||
沪A59932F
|
||||
沪A60339F
|
||||
沪A60691F
|
||||
沪A60820F
|
||||
沪A61187F
|
||||
沪A61193F
|
||||
沪A61312F
|
||||
沪A61559F
|
||||
沪A61600F
|
||||
沪A61711F
|
||||
沪A61738F
|
||||
沪A62322F
|
||||
沪A62772F
|
||||
沪A62928F
|
||||
沪A63013F
|
||||
沪A63305F
|
||||
沪A63522F
|
||||
沪A63660F
|
||||
沪A63697F
|
||||
沪A65036F
|
||||
沪A65181F
|
||||
沪A65522F
|
||||
沪A65995F
|
||||
沪A66216F
|
||||
沪A66256F
|
||||
沪A66329F
|
||||
沪A66593F
|
||||
沪A66710F
|
||||
沪A66921F
|
||||
沪A67018F
|
||||
沪A67033F
|
||||
沪A67872F
|
||||
沪A68115F
|
||||
沪A68139F
|
||||
沪A68332F
|
||||
沪A68613F
|
||||
沪A68658F
|
||||
沪A68752F
|
||||
沪A69311F
|
||||
沪A69826F
|
||||
沪A69997F
|
||||
沪A85021F
|
||||
沪A89315F
|
||||
沪A89385F
|
||||
沪A89662F
|
||||
浙F00885F
|
||||
浙F08889F
|
||||
浙F09898F
|
||||
粤A00255F
|
||||
粤A02683F
|
||||
粤A02956F
|
||||
粤A03502F
|
||||
粤A03532F
|
||||
粤A03569F
|
||||
粤A05106F
|
||||
粤A05391F
|
||||
粤A05428F
|
||||
粤A05839F
|
||||
粤A05985F
|
||||
粤A05995F
|
||||
粤A06569F
|
||||
粤A06931F
|
||||
粤A06932F
|
||||
60
scripts-tmp/find_extra.ts
Normal file
60
scripts-tmp/find_extra.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'LN#Passw0rd@2026',
|
||||
database: 'lingniu_prod',
|
||||
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const excelPlates = new Set(
|
||||
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
|
||||
);
|
||||
console.log('excel plates:', excelPlates.size);
|
||||
|
||||
// 按 dept-stats 逻辑查金可鹏 18T Operating
|
||||
const [rows] = await pool.query<any[]>(`
|
||||
SELECT truck.plate_number AS plate,
|
||||
dic_type.dic_name AS type_label,
|
||||
dic_status.dic_name AS status_label,
|
||||
cus.customer_name AS customer,
|
||||
org_truck.org_name AS subject_org
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.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_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_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
|
||||
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
|
||||
WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||
AND u.user_name='金可鹏'
|
||||
AND dic_type.dic_name LIKE '%18吨%'
|
||||
AND dic_status.dic_name IN ('租赁','自营','挂靠')
|
||||
ORDER BY truck.plate_number
|
||||
`);
|
||||
|
||||
console.log('DB 金可鹏 18T operating:', rows.length);
|
||||
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
|
||||
|
||||
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
|
||||
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
|
||||
|
||||
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
|
||||
console.log('数量:', extra.length);
|
||||
for (const p of extra) {
|
||||
const r = (rows as any[]).find((x) => x.plate === p);
|
||||
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
|
||||
}
|
||||
|
||||
console.log('\n=== Excel 有但 DB 没有 ===');
|
||||
console.log('数量:', missing.length);
|
||||
for (const p of missing) console.log(' ', p);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
54
src/App.tsx
54
src/App.tsx
@@ -1,18 +1,60 @@
|
||||
import { Truck, Route } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import { Shell, type ModuleConfig } from './components/Shell';
|
||||
import AssetsModule from './modules/assets/AssetsModule';
|
||||
import MileageModule from './modules/mileage/MileageModule';
|
||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
import EleImportPage from './modules/ele/EleImportPage';
|
||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
||||
|
||||
const MODULES: ModuleConfig[] = [
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
const ENERGY_MODULE: ModuleConfig = {
|
||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
||||
};
|
||||
|
||||
function getRouteKey(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
||||
return '';
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error } = useAuth();
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => setRouteKey(getRouteKey());
|
||||
window.addEventListener('hashchange', update);
|
||||
window.addEventListener('popstate', update);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', update);
|
||||
window.removeEventListener('popstate', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const modules = useMemo(() => {
|
||||
const result = [...BASE_MODULES];
|
||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [user?.roles]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -29,7 +71,11 @@ function AuthGate() {
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
return <Shell modules={MODULES} />;
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
||||
|
||||
return <Shell modules={modules} />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
|
||||
@@ -36,6 +36,23 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 检查 sessionStorage 中是否有 JWT
|
||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||
if (savedToken) {
|
||||
@@ -65,7 +82,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const jumpToken = params.get('jumpToken');
|
||||
|
||||
if (!jumpToken) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '未提供跳转令牌' });
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { createContext, useContext } from 'react';
|
||||
export interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: { userName: string; permissionLevel: string; depName: string } | null;
|
||||
user: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
permissionLevel: string;
|
||||
depName: string;
|
||||
roles?: string[];
|
||||
} | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
|
||||
17
src/components/Blur.tsx
Normal file
17
src/components/Blur.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
const DemoModeContext = createContext(false);
|
||||
|
||||
export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
||||
return <DemoModeContext.Provider value={enabled}>{children}</DemoModeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDemoMode() {
|
||||
return useContext(DemoModeContext);
|
||||
}
|
||||
|
||||
export default function Blur({ children }: { children: ReactNode }) {
|
||||
const demo = useContext(DemoModeContext);
|
||||
if (!demo) return <>{children}</>;
|
||||
return <span className="blur-[5px] select-none">{children}</span>;
|
||||
}
|
||||
511
src/components/FeedbackFab.tsx
Normal file
511
src/components/FeedbackFab.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
||||
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { canManageFeedback } from '../shared/auth/roles';
|
||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||
import RotatingFooterHint from './RotatingFooterHint';
|
||||
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
const MAX_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch('/api/feedback/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||
return json.url as string;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result || ''));
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
interface TypeOption {
|
||||
key: FeedbackType;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sub: string;
|
||||
iconBg: string;
|
||||
iconFg: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: TypeOption[] = [
|
||||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||
];
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
'': '通用',
|
||||
};
|
||||
|
||||
function detectModule(): string {
|
||||
const hash = (window.location.hash || '').slice(1);
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/ele/')) return 'ele';
|
||||
if (hash.includes('ele')) return 'ele';
|
||||
if (hash.startsWith('/')) return hash.split('/')[1] || '';
|
||||
return hash || '';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 显式覆盖当前模块(否则自动从 URL 检测) */
|
||||
module?: string;
|
||||
}
|
||||
|
||||
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = canManageFeedback(user?.roles);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
const [shots, setShots] = useState<UploadedImg[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||||
if (list.length === 0) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const f of list) {
|
||||
if (shots.length >= MAX_SCREENSHOTS) break;
|
||||
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
||||
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
||||
continue;
|
||||
}
|
||||
const thumbDataUrl = await readAsDataUrl(f);
|
||||
const url = await uploadImage(f);
|
||||
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
setMod(moduleProp ?? detectModule());
|
||||
}
|
||||
}, [open, step, moduleProp]);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [open]);
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
setType(null);
|
||||
setContent('');
|
||||
setShots([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!type || !content.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchJson('/api/feedback/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
module: mod || null,
|
||||
content: content.trim(),
|
||||
screenshots: shots.map(s => s.url),
|
||||
userAgent: navigator.userAgent.slice(0, 500),
|
||||
}),
|
||||
});
|
||||
setStep(3);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (step === 1) {
|
||||
if (!type) return;
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
if (step === 2) {
|
||||
if (!content.trim()) { taRef.current?.focus(); return; }
|
||||
submit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||||
|
||||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={() => setMenuOpen(m => !m)}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="反馈"
|
||||
title="提建议 / 我的反馈"
|
||||
>
|
||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||
>
|
||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||
>
|
||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="h-px bg-slate-100 my-0.5" />
|
||||
<a
|
||||
href="#/admin/feedback"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||
>
|
||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 点击外面关菜单 */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-1]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Header + progress */}
|
||||
<div className="px-4 pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||
{step === 3 ? '收到啦~' : '提个建议'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{step === 1 && '第一步 / 共 2 步'}
|
||||
{step === 2 && '第二步 / 共 2 步'}
|
||||
{step === 3 && '感谢你的反馈'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
||||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||
<RotatingFooterHint className="justify-start mb-4" />
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{TYPE_OPTIONS.map((opt, i) => {
|
||||
const Icon = opt.icon;
|
||||
const selected = type === opt.key;
|
||||
return (
|
||||
<motion.button
|
||||
key={opt.key}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||
whileHover={{ y: -1 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
onClick={() => { setType(opt.key); setStep(2); }}
|
||||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||
</div>
|
||||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imgs: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile();
|
||||
if (f) imgs.push(f);
|
||||
}
|
||||
}
|
||||
if (imgs.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(imgs);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder={
|
||||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
||||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
||||
: '随便聊聊你的想法'
|
||||
}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||
</div>
|
||||
|
||||
{/* 截图上传 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shots.map((s, i) => (
|
||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shots.length < MAX_SCREENSHOTS && (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||
>
|
||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setMod(k)}
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||
>
|
||||
<Check size={28} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step < 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={back}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一步
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={!canNext || submitting}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100">
|
||||
<button
|
||||
onClick={close}
|
||||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||
module: string | null;
|
||||
content: string;
|
||||
contact: string | null;
|
||||
screenshots: string[] | string | null;
|
||||
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||
reply_content: string | null;
|
||||
reply_user: string | null;
|
||||
reply_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
dimension: '💡 新维度',
|
||||
bug: '🐛 Bug',
|
||||
ux: '🎨 体验',
|
||||
other: '📝 其他',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: '待处理',
|
||||
in_progress: '处理中',
|
||||
done: '已完成',
|
||||
rejected: '已忽略',
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
open: 'bg-slate-100 text-slate-500',
|
||||
in_progress: 'bg-amber-100 text-amber-600',
|
||||
done: 'bg-emerald-100 text-emerald-600',
|
||||
rejected: 'bg-rose-100 text-rose-500',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s)) return s;
|
||||
try { return JSON.parse(String(s)); } catch { return []; }
|
||||
}
|
||||
|
||||
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
|
||||
const [items, setItems] = useState<FeedbackItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems(null);
|
||||
setError(null);
|
||||
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
|
||||
.then(d => setItems(d.items))
|
||||
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-lg md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||
<MailOpen size={14} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">我的反馈</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">查看进展与回复</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{error ? (
|
||||
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
|
||||
) : items === null ? (
|
||||
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
|
||||
<Loader2 size={14} className="animate-spin" /> 加载中…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-slate-300 text-[12px] font-bold">还没有提过反馈</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
return (
|
||||
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
|
||||
{STATUS_LABEL[it.status]}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 ml-auto">
|
||||
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{it.content}
|
||||
</div>
|
||||
{shots.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{shots.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
|
||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
|
||||
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
|
||||
{it.reply_user || '产品同学'} 回复
|
||||
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{it.reply_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
51
src/components/RotatingFooterHint.tsx
Normal file
51
src/components/RotatingFooterHint.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const FOOTER_HINTS = [
|
||||
'想看哪个角度的数据?告诉我们一下嘛',
|
||||
'更多统计维度接入中,欢迎您的建议 ~',
|
||||
'下一个图表,可能就是您建议的那个',
|
||||
'数据科学家正在深夜挖掘新维度…',
|
||||
'维度灵感正在路上,钉一下产品同学也行',
|
||||
'数字背后还有故事,等下一次上线揭晓',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
/** 自定义提示词集合,默认使用通用文案 */
|
||||
hints?: string[];
|
||||
/** 切换间隔,默认 4 秒 */
|
||||
intervalMs?: number;
|
||||
/** 额外类名 */
|
||||
className?: string;
|
||||
/** 点击时回调(一般用来打开反馈弹窗) */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
useEffect(() => {
|
||||
if (hints.length <= 1) return;
|
||||
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
}, [hints, intervalMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className || 'mt-1 justify-center'}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span
|
||||
key={idx}
|
||||
style={{ animation: 'rotatingHintFade 0.5s ease' }}
|
||||
>
|
||||
{hints[idx]}
|
||||
</span>
|
||||
<style>{`
|
||||
@keyframes rotatingHintFade {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
@@ -13,6 +15,8 @@ const PATH_MAP: Record<string, string> = {
|
||||
'/vehicle': 'assets',
|
||||
'/assets': 'assets',
|
||||
'/mileage': 'mileage',
|
||||
'/scheduling': 'scheduling',
|
||||
'/energy': 'energy',
|
||||
};
|
||||
|
||||
function getInitialModule(modules: ModuleConfig[]): string {
|
||||
@@ -44,14 +48,19 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [modules]);
|
||||
|
||||
useEffect(() => {
|
||||
// 同步 hash 到当前模块
|
||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
||||
if (window.location.hash.slice(1) !== activeModule) {
|
||||
window.location.hash = activeModule;
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||
}
|
||||
}, [activeModule]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
window.location.hash = id;
|
||||
if (window.location.hash.slice(1) === id) return;
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||
setActiveModule(id);
|
||||
};
|
||||
|
||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||
@@ -64,6 +73,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DemoModeProvider enabled={false}>
|
||||
<div className="flex min-h-screen">
|
||||
{/* 全局水印 */}
|
||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||
@@ -97,6 +107,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
{/* 内容区 */}
|
||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
<FeedbackFab module={activeModule} />
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
@@ -117,5 +128,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
341
src/modules/admin/FeedbackAdminPage.tsx
Normal file
341
src/modules/admin/FeedbackAdminPage.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||
module: string | null;
|
||||
content: string;
|
||||
contact: string | null;
|
||||
screenshots: string[] | string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||
reply_content: string | null;
|
||||
reply_user: string | null;
|
||||
reply_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
dimension: '💡 新维度',
|
||||
bug: '🐛 Bug',
|
||||
ux: '🎨 体验',
|
||||
other: '📝 其他',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
|
||||
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
|
||||
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
|
||||
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
|
||||
];
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
};
|
||||
|
||||
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s)) return s;
|
||||
try { return JSON.parse(String(s)); } catch { return []; }
|
||||
}
|
||||
|
||||
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch(`/api/feedback/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export default function FeedbackAdminPage() {
|
||||
const [items, setItems] = useState<FeedbackItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
|
||||
const [active, setActive] = useState<FeedbackItem | null>(null);
|
||||
const [replyDraft, setReplyDraft] = useState('');
|
||||
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('limit', '200');
|
||||
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
|
||||
setItems(d.items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const open = (it: FeedbackItem) => {
|
||||
setActive(it);
|
||||
setReplyDraft(it.reply_content || '');
|
||||
setReplyStatus(it.status === 'open' ? 'done' : it.status);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!active) return;
|
||||
setSaving(true);
|
||||
setHint(null);
|
||||
try {
|
||||
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
|
||||
setHint('已保存');
|
||||
setActive(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setHint(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setHint(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
|
||||
try {
|
||||
await patchItem(it.id, { status });
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setHint(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setHint(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const counters = items.reduce<Record<string, number>>((m, it) => {
|
||||
m[it.status] = (m[it.status] || 0) + 1;
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
|
||||
<Inbox size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
|
||||
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
|
||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 状态过滤 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>全部 {items.length}</button>
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>
|
||||
{o.label} {counters[o.key] ?? 0}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||
<AlertCircle size={14} /> {error}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{hint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
|
||||
>
|
||||
<CheckCircle2 size={14} /> {hint}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="space-y-2">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
||||
) : items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
|
||||
onClick={() => open(it)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
|
||||
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
|
||||
{statusOpt?.label || it.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 ml-auto">
|
||||
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
|
||||
{(shots.length > 0 || it.contact) && (
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||
{it.contact && <span>📞 {it.contact}</span>}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
|
||||
回复: {it.reply_content}
|
||||
</div>
|
||||
)}
|
||||
{it.status === 'open' && (
|
||||
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100">标记处理中</button>
|
||||
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100">忽略</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情 / 回复弹窗 */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-xl md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">反馈详情 #{active.id}</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setActive(null)} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
|
||||
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
|
||||
{active.module && <span className="text-slate-400">板块: {MODULE_LABELS[active.module] || active.module}</span>}
|
||||
{active.contact && <span className="text-slate-400">联系: {active.contact}</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
|
||||
{parseScreenshots(active.screenshots).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{parseScreenshots(active.screenshots).map((u, i) => (
|
||||
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
|
||||
<img src={u} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">状态</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setReplyStatus(o.key)}
|
||||
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">回复内容</p>
|
||||
<textarea
|
||||
value={replyDraft}
|
||||
onChange={(e) => setReplyDraft(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{replyDraft.length} / 2000</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setActive(null)}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
|
||||
>取消</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,10 +30,12 @@ import {
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
|
||||
// --- Constants ---
|
||||
@@ -57,6 +59,13 @@ export default function AssetsModule() {
|
||||
}
|
||||
}, [activeTab]);
|
||||
const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft');
|
||||
|
||||
// 所属公司(归属主体)筛选 —— 影响全页聚合
|
||||
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
|
||||
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
|
||||
const [subjectSearch, setSubjectSearch] = useState('');
|
||||
const subjectDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set());
|
||||
const [showPlateNumbers, setShowPlateNumbers] = useState<{
|
||||
@@ -140,12 +149,12 @@ export default function AssetsModule() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [s, byType, dept, region, cust, inv] = await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchByType(),
|
||||
fetchDeptStats(),
|
||||
fetchRegionStats(),
|
||||
fetchCustomerStats(),
|
||||
fetchInventoryStats(),
|
||||
fetchSummary(selectedSubject),
|
||||
fetchByType(selectedSubject),
|
||||
fetchDeptStats(selectedSubject),
|
||||
fetchRegionStats(undefined, selectedSubject),
|
||||
fetchCustomerStats(selectedSubject),
|
||||
fetchInventoryStats(selectedSubject),
|
||||
]);
|
||||
setSummary(s);
|
||||
setProcessedData(byType);
|
||||
@@ -159,7 +168,7 @@ export default function AssetsModule() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [selectedSubject]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -167,22 +176,43 @@ export default function AssetsModule() {
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
// 归属公司列表(仅首次加载,公司集合相对稳定)
|
||||
useEffect(() => {
|
||||
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭归属公司下拉
|
||||
useEffect(() => {
|
||||
if (!subjectDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (subjectDropdownRef.current && !subjectDropdownRef.current.contains(e.target as Node)) {
|
||||
setSubjectDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [subjectDropdownOpen]);
|
||||
|
||||
// Re-fetch region data when filters change
|
||||
useEffect(() => {
|
||||
const hasFilter = regionFilters.customer || regionFilters.city || regionFilters.region;
|
||||
if (hasFilter) {
|
||||
fetchRegionStats({ customer: regionFilters.customer || undefined, city: regionFilters.city || undefined, region: regionFilters.region || undefined })
|
||||
.then(setRegionData).catch(() => {});
|
||||
fetchRegionStats(
|
||||
{ customer: regionFilters.customer || undefined, city: regionFilters.city || undefined, region: regionFilters.region || undefined },
|
||||
selectedSubject,
|
||||
).then(setRegionData).catch(() => {});
|
||||
} else {
|
||||
// No filters: use data from the main loadData cycle
|
||||
fetchRegionStats().then(setRegionData).catch(() => {});
|
||||
fetchRegionStats(undefined, selectedSubject).then(setRegionData).catch(() => {});
|
||||
}
|
||||
}, [regionFilters]);
|
||||
}, [regionFilters, selectedSubject]);
|
||||
|
||||
// Fetch region chart data when view changes
|
||||
useEffect(() => {
|
||||
fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
|
||||
}, [regionChartView]);
|
||||
fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8, 'realtime', selectedSubject)
|
||||
.then(setRegionChartData)
|
||||
.catch(() => setRegionChartData([]));
|
||||
}, [regionChartView, selectedSubject]);
|
||||
|
||||
// Load modal vehicles
|
||||
useEffect(() => {
|
||||
@@ -194,11 +224,18 @@ export default function AssetsModule() {
|
||||
setModalLoading(true);
|
||||
const cat = showPlateNumbers.category;
|
||||
|
||||
// Weekly categories use the dedicated weekly-detail endpoint
|
||||
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced', Pending: 'pending' };
|
||||
// Weekly categories use the dedicated weekly-detail endpoint.
|
||||
// Pending 不属于 weekly:weekly-detail 不支持 model/batch/location 过滤,
|
||||
// 走下面的 /list 路径才能按型号/区域等维度过滤。
|
||||
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced' };
|
||||
if (cat && weeklyTypes[cat]) {
|
||||
setModalVehicles([]);
|
||||
fetchWeeklyDetail(weeklyTypes[cat])
|
||||
fetchWeeklyDetail(weeklyTypes[cat], {
|
||||
model: showPlateNumbers.model,
|
||||
batch: showPlateNumbers.batch,
|
||||
location: showPlateNumbers.location,
|
||||
source: showPlateNumbers.source,
|
||||
})
|
||||
.then(setModalWeeklyDetail)
|
||||
.catch(() => setModalWeeklyDetail([]))
|
||||
.finally(() => setModalLoading(false));
|
||||
@@ -212,8 +249,10 @@ export default function AssetsModule() {
|
||||
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
||||
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
||||
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
||||
if (showPlateNumbers.source) params.source = showPlateNumbers.source;
|
||||
if (cat === 'Inventory') params.category = 'Inventory';
|
||||
if (cat === 'Operating') params.category = 'Operating';
|
||||
if (cat === 'Pending') params.category = 'Pending';
|
||||
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
||||
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
||||
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
|
||||
@@ -235,11 +274,11 @@ export default function AssetsModule() {
|
||||
else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他';
|
||||
}
|
||||
}
|
||||
fetchVehicleList(params)
|
||||
fetchVehicleList({ ...params, subject: selectedSubject })
|
||||
.then(setModalVehicles)
|
||||
.catch(() => setModalVehicles([]))
|
||||
.finally(() => setModalLoading(false));
|
||||
}, [showPlateNumbers]);
|
||||
}, [showPlateNumbers, selectedSubject]);
|
||||
|
||||
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
|
||||
|
||||
@@ -439,9 +478,9 @@ export default function AssetsModule() {
|
||||
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
|
||||
useEffect(() => {
|
||||
if (customerChartView === 'province') {
|
||||
fetchRegionChart('province', 5, 'vehicle').then(setCustomerProvinceData).catch(() => setCustomerProvinceData([]));
|
||||
fetchRegionChart('province', 5, 'vehicle', selectedSubject).then(setCustomerProvinceData).catch(() => setCustomerProvinceData([]));
|
||||
}
|
||||
}, [customerChartView]);
|
||||
}, [customerChartView, selectedSubject]);
|
||||
|
||||
const customerPieData = useMemo(() => {
|
||||
if (customerChartView === 'region') {
|
||||
@@ -512,6 +551,115 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 归属公司作用域筛选 (Scope Chip) */}
|
||||
<div className="flex items-center justify-center px-4 pt-1">
|
||||
<div className="relative" ref={subjectDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSubjectDropdownOpen((o) => !o);
|
||||
setSubjectSearch('');
|
||||
}}
|
||||
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
|
||||
selectedSubject
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
title={selectedSubject || '全部公司'}
|
||||
>
|
||||
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
|
||||
<span className="max-w-[180px] truncate">
|
||||
所属公司:{selectedSubject || '全部公司'}
|
||||
</span>
|
||||
{selectedSubject ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedSubject(null);
|
||||
}}
|
||||
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
|
||||
aria-label="清除归属公司筛选"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown size={11} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{subjectDropdownOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
autoFocus
|
||||
value={subjectSearch}
|
||||
onChange={(e) => setSubjectSearch(e.target.value)}
|
||||
placeholder="搜索公司名"
|
||||
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSubject(null);
|
||||
setSubjectDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||
全部公司
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{subjects.reduce((s, x) => s + x.total, 0)} 台
|
||||
</span>
|
||||
</button>
|
||||
<div className="my-1 mx-3 border-t border-gray-100" />
|
||||
{subjects
|
||||
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
|
||||
.map((s) => {
|
||||
const active = selectedSubject === s.name;
|
||||
return (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSubject(s.name);
|
||||
setSubjectDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
|
||||
}`}
|
||||
title={s.name}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||
{s.total} 台
|
||||
<span className="mx-1 text-gray-200">·</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab row */}
|
||||
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||||
{TABS.map(tab => (
|
||||
@@ -1488,7 +1636,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="font-bold text-gray-700 text-xs">{m.manager}</span>
|
||||
<span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -1570,7 +1718,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1">
|
||||
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
|
||||
{m.manager}
|
||||
<Blur>{m.manager}</Blur>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
|
||||
<td
|
||||
@@ -1690,7 +1838,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
|
||||
<span className="text-[11px] font-bold text-gray-700">{m.manager}</span>
|
||||
<span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -1769,7 +1917,7 @@ export default function AssetsModule() {
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3>
|
||||
<h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3>
|
||||
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
|
||||
<div
|
||||
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
|
||||
@@ -2042,7 +2190,7 @@ export default function AssetsModule() {
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
|
||||
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
|
||||
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 库存` }); }}>{tb.inventory}</td>
|
||||
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}</td>
|
||||
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -2104,9 +2252,9 @@ export default function AssetsModule() {
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-orange-600 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 库存` })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })}
|
||||
>
|
||||
待:{tb.inventory}
|
||||
待:{tb.pending}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2293,12 +2441,12 @@ export default function AssetsModule() {
|
||||
>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
|
||||
{cust.customer}
|
||||
<Blur>{cust.customer}</Blur>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{cust.manager}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td>
|
||||
@@ -2313,7 +2461,7 @@ export default function AssetsModule() {
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
|
||||
<div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
@@ -2323,7 +2471,7 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">业务经理</div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.manager}</div>
|
||||
<div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">资产占比</div>
|
||||
@@ -2357,7 +2505,7 @@ export default function AssetsModule() {
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span>
|
||||
<span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span>
|
||||
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}区域</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2375,7 +2523,7 @@ export default function AssetsModule() {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div>
|
||||
<div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
@@ -2385,7 +2533,7 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">业务经理</div>
|
||||
<div className="text-xs font-bold text-gray-700">{cust.manager}</div>
|
||||
<div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">资产占比</div>
|
||||
@@ -2580,8 +2728,8 @@ export default function AssetsModule() {
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalWeeklyDetail.map((v, i) => (
|
||||
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center">{v.plate_number}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -2628,12 +2776,12 @@ export default function AssetsModule() {
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700">{v.customerManager || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800">{v.customerName || '—'}</td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||
@@ -2646,13 +2794,13 @@ export default function AssetsModule() {
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||
<td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td>
|
||||
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center">{v.customerName || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
)}
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||
@@ -2691,7 +2839,7 @@ export default function AssetsModule() {
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,29 @@ import { fetchJson } from '../../auth/api-client';
|
||||
|
||||
const BASE = '/api/vehicles';
|
||||
|
||||
export async function fetchSummary(): Promise<SummaryData> {
|
||||
return fetchJson<SummaryData>(`${BASE}/summary`);
|
||||
export interface SubjectOption {
|
||||
name: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
operating: number;
|
||||
}
|
||||
|
||||
export async function fetchByType(): Promise<TypeSummary[]> {
|
||||
return fetchJson<TypeSummary[]>(`${BASE}/by-type`);
|
||||
function withSubject(path: string, subject?: string | null): string {
|
||||
if (!subject) return path;
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
return `${path}${sep}subject=${encodeURIComponent(subject)}`;
|
||||
}
|
||||
|
||||
export async function fetchSubjects(): Promise<SubjectOption[]> {
|
||||
return fetchJson<SubjectOption[]>(`${BASE}/subjects`);
|
||||
}
|
||||
|
||||
export async function fetchSummary(subject?: string | null): Promise<SummaryData> {
|
||||
return fetchJson<SummaryData>(withSubject(`${BASE}/summary`, subject));
|
||||
}
|
||||
|
||||
export async function fetchByType(subject?: string | null): Promise<TypeSummary[]> {
|
||||
return fetchJson<TypeSummary[]>(withSubject(`${BASE}/by-type`, subject));
|
||||
}
|
||||
|
||||
export async function fetchVehicleList(params: {
|
||||
@@ -32,6 +49,8 @@ export async function fetchVehicleList(params: {
|
||||
isTrailer?: string;
|
||||
department?: string;
|
||||
attendance?: string;
|
||||
subject?: string | null;
|
||||
source?: string;
|
||||
}): Promise<VehicleListItem[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.batch) query.set('batch', params.batch);
|
||||
@@ -46,6 +65,8 @@ export async function fetchVehicleList(params: {
|
||||
if (params.isTrailer) query.set('isTrailer', params.isTrailer);
|
||||
if (params.department) query.set('department', params.department);
|
||||
if (params.attendance) query.set('attendance', params.attendance);
|
||||
if (params.subject) query.set('subject', params.subject);
|
||||
if (params.source) query.set('source', params.source);
|
||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -57,31 +78,50 @@ export interface WeeklyDetailItem {
|
||||
customer_name: string | null;
|
||||
}
|
||||
|
||||
export async function fetchDeptStats(): Promise<DeptGroup[]> {
|
||||
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`);
|
||||
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||
}
|
||||
|
||||
export async function fetchRegionStats(params?: { customer?: string; city?: string; region?: string }): Promise<RegionGroup[]> {
|
||||
export async function fetchRegionStats(
|
||||
params?: { customer?: string; city?: string; region?: string },
|
||||
subject?: string | null,
|
||||
): Promise<RegionGroup[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.customer) query.set('customer', params.customer);
|
||||
if (params?.city) query.set('city', params.city);
|
||||
if (params?.region) query.set('region', params.region);
|
||||
if (subject) query.set('subject', subject);
|
||||
const qs = query.toString();
|
||||
return fetchJson<RegionGroup[]>(`${BASE}/region-stats${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function fetchCustomerStats(): Promise<CustomerStats[]> {
|
||||
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
|
||||
export async function fetchCustomerStats(subject?: string | null): Promise<CustomerStats[]> {
|
||||
return fetchJson<CustomerStats[]>(withSubject(`${BASE}/customer-stats`, subject));
|
||||
}
|
||||
|
||||
export async function fetchInventoryStats(): Promise<RegionalInventoryStats[]> {
|
||||
return fetchJson<RegionalInventoryStats[]>(`${BASE}/inventory-stats`);
|
||||
export async function fetchInventoryStats(subject?: string | null): Promise<RegionalInventoryStats[]> {
|
||||
return fetchJson<RegionalInventoryStats[]>(withSubject(`${BASE}/inventory-stats`, subject));
|
||||
}
|
||||
|
||||
export async function fetchRegionChart(groupBy: string, top = 8, source = 'realtime'): Promise<{ name: string; value: number }[]> {
|
||||
return fetchJson<{ name: string; value: number }[]>(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`);
|
||||
export async function fetchRegionChart(
|
||||
groupBy: string,
|
||||
top = 8,
|
||||
source = 'realtime',
|
||||
subject?: string | null,
|
||||
): Promise<{ name: string; value: number }[]> {
|
||||
return fetchJson<{ name: string; value: number }[]>(
|
||||
withSubject(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`, subject),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
||||
export async function fetchWeeklyDetail(
|
||||
type: string,
|
||||
filters?: { model?: string; batch?: string; location?: string; source?: string },
|
||||
): Promise<WeeklyDetailItem[]> {
|
||||
const params = new URLSearchParams({ type });
|
||||
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
|
||||
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
|
||||
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
|
||||
if (filters?.source) params.set('source', filters.source);
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface RegionTypeBreakdown {
|
||||
total: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
pending: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
|
||||
392
src/modules/ele/EleImportPage.tsx
Normal file
392
src/modules/ele/EleImportPage.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import FeedbackFab from '../../components/FeedbackFab';
|
||||
|
||||
function getJwt(): string | null {
|
||||
return sessionStorage.getItem('bi_jwt');
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
ok: boolean;
|
||||
filename: string;
|
||||
batchId: string;
|
||||
parsed: number;
|
||||
fileDuplicates: number;
|
||||
inserted: number;
|
||||
dbDuplicates: number;
|
||||
breakdown: { internal: number; external: number };
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
station_name: string | null;
|
||||
terminal_name: string | null;
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_min: number | null;
|
||||
kwh: number | null;
|
||||
fee: number | null;
|
||||
e_fee: number | null;
|
||||
service_fee: number | null;
|
||||
plate: string | null;
|
||||
judged_plate: string | null;
|
||||
customer_name: string | null;
|
||||
vehicle_kind: 'internal' | 'external' | 'unknown';
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
}
|
||||
|
||||
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
||||
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
internal: '内部',
|
||||
external: '外部',
|
||||
};
|
||||
const KIND_STYLE: Record<string, string> = {
|
||||
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||
};
|
||||
|
||||
async function uploadFile(file: File): Promise<UploadResult> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = getJwt();
|
||||
const res = await fetch('/api/ele/import', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||
return json as UploadResult;
|
||||
}
|
||||
|
||||
export default function EleImportPage() {
|
||||
const { user } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<UploadResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<ListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: '1', limit: '50' });
|
||||
if (filter) params.set('kind', filter);
|
||||
if (batchFilter) params.set('batchId', batchFilter);
|
||||
if (search) params.set('search', search);
|
||||
const [list, agg, b] = await Promise.all([
|
||||
fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`),
|
||||
fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`),
|
||||
fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`),
|
||||
]);
|
||||
setItems(list.items);
|
||||
setTotal(list.total);
|
||||
setOverall(agg.overall);
|
||||
setBatches(b.items);
|
||||
}, [filter, batchFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
reload().catch(e => console.error(e));
|
||||
}, [reload]);
|
||||
|
||||
const handleUpload = async (f: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await uploadFile(f);
|
||||
setResult(r);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPick = (f: File | null) => {
|
||||
setFile(f);
|
||||
if (f) handleUpload(f);
|
||||
};
|
||||
|
||||
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
|
||||
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
|
||||
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
|
||||
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<Zap size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
||||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
||||
</header>
|
||||
|
||||
{/* 上传区 */}
|
||||
<section
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 上传结果提示 */}
|
||||
<AnimatePresence>
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 聚合卡 */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||
</section>
|
||||
|
||||
{/* 批次 */}
|
||||
{batches.length > 0 && (
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||
{batchFilter && (
|
||||
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">导入时间</th>
|
||||
<th className="px-3 py-2 text-right">总数</th>
|
||||
<th className="px-3 py-2 text-right">内部</th>
|
||||
<th className="px-3 py-2 text-right">外部</th>
|
||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right">批次</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map(b => (
|
||||
<tr
|
||||
key={b.batch_id}
|
||||
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
|
||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 列表 */}
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||
<div className="flex-1" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
placeholder="搜索订单/车牌/电站"
|
||||
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||
/>
|
||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k as typeof filter)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(it => (
|
||||
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
||||
{KIND_LABEL[it.vehicle_kind]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||
尚无记录,先上传一份 xlsx 试试
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
<FeedbackFab module="ele" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||
const accentMap: Record<string, string> = {
|
||||
slate: 'text-slate-700',
|
||||
blue: 'text-blue-600',
|
||||
amber: 'text-amber-600',
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||
<span className={accentMap[accent]}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/modules/energy/ETCView.tsx
Normal file
79
src/modules/energy/ETCView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Construction, Hammer } from 'lucide-react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
|
||||
export default function ETCView() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<motion.div
|
||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
||||
>
|
||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
||||
>
|
||||
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
||||
通行费明细、按车按月统计、运营成本拆分
|
||||
<br />
|
||||
这些数据都在路上啦
|
||||
</div>
|
||||
|
||||
{/* 简单的里程碑进度感 */}
|
||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
||||
{[
|
||||
{ label: '需求评审', done: true },
|
||||
{ label: '数据对接', done: true },
|
||||
{ label: '页面开发', done: false, current: true },
|
||||
{ label: '正式上线', done: false },
|
||||
].map((m, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
||||
className="flex items-center gap-2.5 text-[11px]"
|
||||
>
|
||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
m.done ? 'bg-emerald-400'
|
||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
||||
: 'bg-slate-200'
|
||||
}`} />
|
||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/modules/energy/ElectricDaily.tsx
Normal file
171
src/modules/energy/ElectricDaily.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Plug } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchElectricMonthly(customer, pick)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
setMonths(m);
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(m) ? next.delete(m) : next.add(m);
|
||||
return next;
|
||||
});
|
||||
|
||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 客户类型 */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 外部车辆 数据未就绪 */}
|
||||
{showExternalEmpty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||
新系统的外部车辆充电数据还在准备中
|
||||
<br />
|
||||
上线后此处将展示完整明细
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 月份分组表 */}
|
||||
{!showExternalEmpty && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>月份 / 日期</span>
|
||||
<span className="text-right">充电量 (度)</span>
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||||
) : months === null ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||||
) : months.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||||
) : months.map(m => {
|
||||
const open = openMonths.has(m.month);
|
||||
return (
|
||||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||||
<button
|
||||
onClick={() => toggleMonth(m.month)}
|
||||
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
|
||||
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
{m.month}
|
||||
</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span />
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{m.rows.map(d => {
|
||||
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
|
||||
const abnormalBg = isAbnormal
|
||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: 'bg-slate-50/50';
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
||||
>
|
||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/modules/energy/ElectricOverview.tsx
Normal file
105
src/modules/energy/ElectricOverview.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
function fmtYuan(yuan: number) {
|
||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
function fmtKwh(kwh: number) {
|
||||
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
|
||||
}
|
||||
|
||||
export default function ElectricOverview() {
|
||||
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchElectricOverview()
|
||||
.then(d => { if (!cancelled) setData(d); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const trendData = data.trend;
|
||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${trendMonthLabel} 每日充电`
|
||||
: '本月每日充电';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||
<Wallet size={11} className="text-blue-600" />累计
|
||||
</div>
|
||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
|
||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||
<CalendarClock size={11} className="text-blue-600" />本月
|
||||
</div>
|
||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本月每日充电柱图 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={8}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/modules/energy/ElectricView.tsx
Normal file
12
src/modules/energy/ElectricView.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import ElectricOverview from './ElectricOverview';
|
||||
import ElectricDaily from './ElectricDaily';
|
||||
|
||||
export type ElectricSubTab = 'daily' | 'overview';
|
||||
|
||||
interface Props {
|
||||
sub: ElectricSubTab;
|
||||
}
|
||||
|
||||
export default function ElectricView({ sub }: Props) {
|
||||
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />;
|
||||
}
|
||||
86
src/modules/energy/EnergyModule.tsx
Normal file
86
src/modules/energy/EnergyModule.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||
import ETCView from './ETCView';
|
||||
|
||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
||||
|
||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
||||
];
|
||||
|
||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
export default function EnergyModule() {
|
||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
||||
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 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||
|
||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
||||
{TABS.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const active = activeTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
||||
{active && (
|
||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
||||
{showSubTabs && (
|
||||
<div className="p-1 flex gap-1">
|
||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
||||
const active = currentSub === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSub(id)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
||||
{activeTab === 'etc' && <ETCView />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/modules/energy/HydrogenDaily.tsx
Normal file
228
src/modules/energy/HydrogenDaily.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Plug } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchHydrogenDaily(pick, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||
|
||||
const toggle = (date: string) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(date) ? next.delete(date) : next.add(date);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 客户类型 segmented */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||
新系统的外部车辆加氢数据还在准备中
|
||||
<br />
|
||||
上线后此处将展示完整明细
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={8}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>日期 / 加氢站</span>
|
||||
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
||||
<span className="text-right">加氢量 (Kg)</span>
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{/* 合计行 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
||||
<span>合计</span>
|
||||
<span className="hidden md:block" />
|
||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||
<span />
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{error ? (
|
||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||||
) : rows === null ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||||
) : rows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
const abnormalBg = isAbnormal
|
||||
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: '';
|
||||
return (
|
||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
||||
<button
|
||||
onClick={() => toggle(r.date)}
|
||||
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
{r.date}
|
||||
</span>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden bg-slate-50/50"
|
||||
>
|
||||
{r.stations.map(s => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||
{s.name}
|
||||
</div>
|
||||
{s.pricePerKg > 0 && (
|
||||
<div className="md:hidden mt-1">
|
||||
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
|
||||
单价 {s.pricePerKg} 元/Kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
641
src/modules/energy/HydrogenOverview.tsx
Normal file
641
src/modules/energy/HydrogenOverview.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} from 'recharts';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 数字格式化 ----------
|
||||
function fmtKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||||
const abs = Math.abs(yuan);
|
||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
if (abs >= 10_000) {
|
||||
const w = yuan / 10_000;
|
||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
||||
}
|
||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
||||
}
|
||||
|
||||
// ---------- KPI 卡 ----------
|
||||
interface KpiCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export default function HydrogenOverview() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<number | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
||||
const refreshSeq = useRef(0);
|
||||
|
||||
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
|
||||
const seq = ++refreshSeq.current;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
||||
if (seq !== refreshSeq.current) return; // outdated
|
||||
setData(d);
|
||||
setError(null);
|
||||
setLastRefreshAt(Date.now());
|
||||
} catch (e) {
|
||||
if (seq !== refreshSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (seq === refreshSeq.current) setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载 + 年份切换:用 force=false 命中热缓存
|
||||
useEffect(() => { void load(year, false); }, [year, load]);
|
||||
|
||||
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||||
return () => clearInterval(t);
|
||||
}, [year, load]);
|
||||
|
||||
if (error && !data) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <HydrogenOverviewSkeleton />;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const top5 = data.top5;
|
||||
const regions = data.regions;
|
||||
const monthly = data.monthly;
|
||||
const customers = data.customers;
|
||||
const stations = data.stations;
|
||||
const availableYears = data.availableYears;
|
||||
const activeYear = data.year;
|
||||
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||
const monthKgFmt = fmtKg(k.monthKg);
|
||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||
const todayKgFmt = fmtKg(k.todayKg);
|
||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
// 月度收支组合数据(推算"年内每月"图)
|
||||
const monthlyDual = monthly.map(m => ({
|
||||
...m,
|
||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load(year, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 5 卡 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-cyan-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢量"
|
||||
hero={yearKgFmt}
|
||||
rows={[
|
||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-blue-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢费"
|
||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||
rows={[
|
||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-emerald-50"
|
||||
accentClass={profitColor}
|
||||
label="时享加氢获利"
|
||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||
rows={[
|
||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-amber-50"
|
||||
accentClass="text-amber-600"
|
||||
label="本月加氢"
|
||||
hero={monthKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-violet-50"
|
||||
accentClass="text-violet-600"
|
||||
label="本日加氢"
|
||||
hero={todayKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 月度趋势:年内每月加氢量 */}
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||
{monthlyDual.map((_, i) => (
|
||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 月度收支对比 */}
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top5 + 区域占比 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Top5 加氢站 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 区域占比 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加氢站加氢汇总(全量) */}
|
||||
{stations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">加氢站</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 客户账单汇总 Top */}
|
||||
{customers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">客户</th>
|
||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c2, i) => {
|
||||
const kgFmt = fmtKg(c2.kg);
|
||||
const costFmt = fmtYuan(c2.cost);
|
||||
const revFmt = fmtYuan(c2.revenue);
|
||||
return (
|
||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RotatingFooterHint />
|
||||
|
||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||||
<AnimatePresence>
|
||||
{refreshing && data && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (s < 5) return '刚刚';
|
||||
if (s < 60) return `${s} 秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
{/* 5 卡占位 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 月度柱图占位 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-[120px]">
|
||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
正在加载氢能总览…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/modules/energy/HydrogenView.tsx
Normal file
12
src/modules/energy/HydrogenView.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import HydrogenOverview from './HydrogenOverview';
|
||||
import HydrogenDaily from './HydrogenDaily';
|
||||
|
||||
export type HydrogenSubTab = 'daily' | 'overview';
|
||||
|
||||
interface Props {
|
||||
sub: HydrogenSubTab;
|
||||
}
|
||||
|
||||
export default function HydrogenView({ sub }: Props) {
|
||||
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
|
||||
}
|
||||
24
src/modules/energy/TrendBadge.tsx
Normal file
24
src/modules/energy/TrendBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
|
||||
|
||||
export interface TrendBadgeProps {
|
||||
value: number; // -1..+1, 0 表示持平
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TrendBadge({ value, className = '' }: TrendBadgeProps) {
|
||||
const isUp = value > 0.0001;
|
||||
const isDown = value < -0.0001;
|
||||
const cls = isUp
|
||||
? 'bg-emerald-50 text-emerald-600'
|
||||
: isDown
|
||||
? 'bg-red-50 text-red-600'
|
||||
: 'bg-slate-100 text-slate-500';
|
||||
const Icon = isUp ? ArrowUp : isDown ? ArrowDown : Minus;
|
||||
const sign = isUp ? '+' : '';
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-bold ${cls} ${className}`}>
|
||||
<Icon size={11} />
|
||||
{sign}{(value * 100).toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
47
src/modules/energy/api.ts
Normal file
47
src/modules/energy/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
CustomerType, DateQuickPick,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
|
||||
export interface HydrogenOverviewResponse {
|
||||
kpi: HydrogenKpi;
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
customers: HydrogenCustomerRow[];
|
||||
stations: HydrogenStationFull[];
|
||||
availableYears: number[];
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.set('year', String(year));
|
||||
if (force) params.set('force', '1');
|
||||
const q = params.toString();
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ range, customer });
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
export interface ElectricOverviewResponse {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
}
|
||||
|
||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer, range });
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
99
src/modules/energy/types.ts
Normal file
99
src/modules/energy/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type CustomerType = 'external' | 'lingniu';
|
||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
|
||||
export interface HydrogenKpi {
|
||||
yearKg: number;
|
||||
yearFee: number;
|
||||
yearRevenue: number;
|
||||
yearProfit: number;
|
||||
ourYearKg: number;
|
||||
ourYearFee: number;
|
||||
customerYearKg: number;
|
||||
monthKg: number;
|
||||
monthFee: number;
|
||||
monthRevenue: number;
|
||||
monthProfit: number;
|
||||
todayKg: number;
|
||||
todayFee: number;
|
||||
todayRevenue: number;
|
||||
todayProfit: number;
|
||||
lingniuBornKg: number;
|
||||
lingniuBornFee: number;
|
||||
}
|
||||
|
||||
export interface HydrogenStationTop {
|
||||
rank: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
fee: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface HydrogenRegionShare {
|
||||
region: string;
|
||||
kg: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface HydrogenMonthlyPoint {
|
||||
month: string; // YYYY-MM
|
||||
kg: number;
|
||||
fee: number;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
export interface HydrogenCustomerRow {
|
||||
name: string;
|
||||
payer: 'lingniu' | 'customer';
|
||||
kg: number;
|
||||
cost: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface HydrogenStationFull {
|
||||
name: string;
|
||||
kg: number;
|
||||
revenue: number;
|
||||
share: number; // 加氢量占比
|
||||
revenueShare: number;// 收入占比
|
||||
}
|
||||
|
||||
export interface HydrogenStationRow {
|
||||
name: string;
|
||||
pricePerKg: number;
|
||||
kg: number;
|
||||
chainPct: number;
|
||||
}
|
||||
|
||||
export interface HydrogenDailyRow {
|
||||
date: string;
|
||||
totalKg: number;
|
||||
chainPct: number;
|
||||
customerType: CustomerType;
|
||||
stations: HydrogenStationRow[];
|
||||
}
|
||||
|
||||
export interface ElectricKpi {
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
monthKwh: number;
|
||||
monthFee: number;
|
||||
todayKwh: number;
|
||||
todayFee: number;
|
||||
todayChainPct: number;
|
||||
}
|
||||
|
||||
export interface ElectricDailyRow {
|
||||
date: string;
|
||||
kwh: number;
|
||||
fee: number;
|
||||
chainPct: number;
|
||||
}
|
||||
|
||||
export interface ElectricMonthGroup {
|
||||
month: string;
|
||||
kwh: number;
|
||||
fee: number;
|
||||
rows: ElectricDailyRow[];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { motion } from 'motion/react';
|
||||
import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
export default function MileageModule() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||
@@ -52,6 +53,7 @@ export default function MileageModule() {
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Truck, Search, Filter, ChevronDown,
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download,
|
||||
} from 'lucide-react';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
import PlateMultiSelect from './PlateMultiSelect';
|
||||
import { exportMileageXlsx } from './xlsx-export';
|
||||
import VehicleDetailModal from './VehicleDetailModal';
|
||||
|
||||
const SearchableSelect = ({
|
||||
options,
|
||||
@@ -101,15 +105,18 @@ export default function MonitoringView() {
|
||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||
|
||||
// New filters from image
|
||||
const [filterPlate, setFilterPlate] = useState('All');
|
||||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||
const [filterProject, setFilterProject] = useState('All');
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||
const [filterRegion, setFilterRegion] = useState('All');
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||
const [filterDate, setFilterDate] = useState(() => {
|
||||
const now = new Date();
|
||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||
@@ -118,7 +125,7 @@ export default function MonitoringView() {
|
||||
|
||||
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: [], rentStatuses: [], platePrefixes: [], targetNames: [] });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -146,7 +153,8 @@ export default function MonitoringView() {
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
@@ -158,7 +166,7 @@ export default function MonitoringView() {
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -178,7 +186,8 @@ export default function MonitoringView() {
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
@@ -187,13 +196,53 @@ export default function MonitoringView() {
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
useEffect(() => {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||||
useEffect(() => {
|
||||
if (filterPlates.length === 0) return;
|
||||
const valid = new Set(filterOptions.plates);
|
||||
const next = filterPlates.filter(p => valid.has(p));
|
||||
if (next.length !== filterPlates.length) setFilterPlates(next);
|
||||
}, [filterOptions.plates, filterPlates]);
|
||||
|
||||
// 下载当前筛选结果为 xlsx
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const d = await fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: 9999,
|
||||
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,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
});
|
||||
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
|
||||
} catch (err) {
|
||||
console.error('export failed', err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||
@@ -259,14 +308,15 @@ export default function MonitoringView() {
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
date: filterDate || undefined,
|
||||
}).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]);
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
@@ -278,6 +328,20 @@ export default function MonitoringView() {
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isFullscreen]);
|
||||
|
||||
// 检测是否在小程序 webview 中(微信/抖音/支付宝等),且当前是竖屏
|
||||
// 小程序 webview 无法调用系统旋转 API,只能用 CSS rotate 强制横屏
|
||||
const forceLandscape = useMemo(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const ua = navigator.userAgent || '';
|
||||
const isMiniProgram =
|
||||
/miniProgram/i.test(ua) ||
|
||||
/toutiaomicroapp/i.test(ua) ||
|
||||
/AlipayClient/i.test(ua) ||
|
||||
(window as any).__wxjs_environment === 'miniprogram';
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
return isMiniProgram && isPortrait;
|
||||
}, [isFullscreen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
|
||||
@@ -290,7 +354,20 @@ export default function MonitoringView() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||
style={
|
||||
forceLandscape
|
||||
? {
|
||||
// 小程序 webview 无法真横屏,强制 CSS 旋转 90 度模拟横屏
|
||||
top: 0,
|
||||
left: '100vw',
|
||||
width: '100vh',
|
||||
height: '100vw',
|
||||
transform: 'rotate(90deg)',
|
||||
transformOrigin: 'top left',
|
||||
}
|
||||
: { top: 0, left: 0, right: 0, bottom: 0 }
|
||||
}
|
||||
>
|
||||
{/* Top bar: compact inline KPI */}
|
||||
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
||||
@@ -363,14 +440,9 @@ export default function MonitoringView() {
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>车牌号</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterPlate}
|
||||
onChange={(e) => setFilterPlate(e.target.value)}
|
||||
>
|
||||
<option value="All">全部车牌</option>
|
||||
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<span className="text-[9px] text-slate-500 font-normal">
|
||||
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
@@ -458,8 +530,8 @@ export default function MonitoringView() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : v.isDataSynced ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs font-bold text-white">{v.plate}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.customer || '-'}</td>
|
||||
<td className="px-3 py-2 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
@@ -498,6 +570,14 @@ export default function MonitoringView() {
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={exporting}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
|
||||
title="下载当前筛选结果"
|
||||
>
|
||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download 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>
|
||||
@@ -531,32 +611,32 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
|
||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<SearchableSelect
|
||||
options={['__EMPTY__', ...departments]}
|
||||
value={filterDept}
|
||||
onChange={setFilterDept}
|
||||
placeholder="按部门"
|
||||
options={filterOptions.targetNames}
|
||||
value={filterTargetName}
|
||||
onChange={setFilterTargetName}
|
||||
placeholder="批次型号"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={['__EMPTY__', ...filterOptions.customers]}
|
||||
value={filterCustomer}
|
||||
onChange={setFilterCustomer}
|
||||
placeholder="按客户"
|
||||
options={filterOptions.regions}
|
||||
value={filterRegion}
|
||||
onChange={setFilterRegion}
|
||||
placeholder="运营区域"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={plateNumbers}
|
||||
value={filterPlate}
|
||||
onChange={setFilterPlate}
|
||||
<PlateMultiSelect
|
||||
allPlates={plateNumbers}
|
||||
selected={filterPlates}
|
||||
onChange={setFilterPlates}
|
||||
placeholder="按车牌"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || 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>
|
||||
@@ -584,6 +664,37 @@ export default function MonitoringView() {
|
||||
/>
|
||||
</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>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Customer */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按客户</label>
|
||||
<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={filterCustomer}
|
||||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Project */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">项目</label>
|
||||
@@ -597,21 +708,6 @@ export default function MonitoringView() {
|
||||
</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>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rent Status */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">租赁状态</label>
|
||||
@@ -641,19 +737,6 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Name */}
|
||||
<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={filterTargetName}
|
||||
onChange={(e) => setFilterTargetName(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.targetNames.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Plate Prefix */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">车牌区域</label>
|
||||
@@ -696,11 +779,13 @@ export default function MonitoringView() {
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setFilterDept('All');
|
||||
setFilterPlate('All');
|
||||
setFilterPlates([]);
|
||||
setFilterCustomer('All');
|
||||
setFilterProject('All');
|
||||
setFilterEntity('All');
|
||||
setFilterPlatePrefix('All');
|
||||
setFilterTargetName('All');
|
||||
setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
}}
|
||||
@@ -726,22 +811,23 @@ export default function MonitoringView() {
|
||||
{/* Active Filter Tags */}
|
||||
{(() => {
|
||||
const tags: { label: string; onClear: () => void }[] = [];
|
||||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||||
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
|
||||
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
|
||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : 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 (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||||
if (filterPlatePrefix !== 'All') tags.push({ label: `区域: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
|
||||
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
|
||||
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||||
if (tags.length === 0) return null;
|
||||
const clearAll = () => {
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
|
||||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterDate('');
|
||||
};
|
||||
@@ -815,10 +901,8 @@ export default function MonitoringView() {
|
||||
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);
|
||||
}}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
|
||||
onClick={() => setDetailVehicle(v)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="relative flex-shrink-0">
|
||||
@@ -829,14 +913,14 @@ export default function MonitoringView() {
|
||||
</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-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{v.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate">{v.customer || '-'}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -882,6 +966,8 @@ export default function MonitoringView() {
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<AnimatePresence>
|
||||
{showBackToTop && (
|
||||
|
||||
197
src/modules/mileage/PlateMultiSelect.tsx
Normal file
197
src/modules/mileage/PlateMultiSelect.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { ChevronDown, X, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
allPlates: string[];
|
||||
selected: string[];
|
||||
onChange: (plates: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function parseInput(text: string): string[] {
|
||||
return text
|
||||
.split(/[\s,;,;、]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function PlateMultiSelect({ allPlates, selected, onChange, placeholder = '按车牌(可多选/粘贴)' }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [unmatched, setUnmatched] = useState<string[]>([]);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allSet = useMemo(() => new Set(allPlates), [allPlates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return allPlates.slice(0, 200);
|
||||
const q = search.toLowerCase();
|
||||
return allPlates.filter(p => p.toLowerCase().includes(q)).slice(0, 200);
|
||||
}, [allPlates, search]);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
const apply = (input: string) => {
|
||||
const tokens = parseInput(input);
|
||||
if (tokens.length === 0) return;
|
||||
const matched: string[] = [];
|
||||
const missed: string[] = [];
|
||||
const seen = new Set(selected);
|
||||
for (const t of tokens) {
|
||||
if (allSet.has(t)) {
|
||||
if (!seen.has(t)) {
|
||||
matched.push(t);
|
||||
seen.add(t);
|
||||
}
|
||||
} else {
|
||||
missed.push(t);
|
||||
}
|
||||
}
|
||||
if (matched.length > 0) onChange([...selected, ...matched]);
|
||||
setUnmatched(missed);
|
||||
setText('');
|
||||
};
|
||||
|
||||
const togglePlate = (plate: string) => {
|
||||
if (selectedSet.has(plate)) {
|
||||
onChange(selected.filter(p => p !== plate));
|
||||
} else {
|
||||
onChange([...selected, plate]);
|
||||
}
|
||||
};
|
||||
|
||||
const removePlate = (plate: string) => {
|
||||
onChange(selected.filter(p => p !== plate));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onChange([]);
|
||||
setUnmatched([]);
|
||||
setText('');
|
||||
};
|
||||
|
||||
const display = selected.length === 0
|
||||
? placeholder
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `${selected[0]} 等 ${selected.length} 个车牌`;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<div
|
||||
onClick={() => setIsOpen(o => !o)}
|
||||
className={`w-full bg-slate-50 rounded-lg py-1.5 px-2 text-[10px] font-bold cursor-pointer flex items-center justify-between gap-1 ${selected.length > 0 ? 'text-blue-600 ring-1 ring-blue-200' : 'text-slate-600'}`}
|
||||
>
|
||||
<span className="truncate">{display}</span>
|
||||
<ChevronDown size={10} className="text-slate-400 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute z-50 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl"
|
||||
style={{ width: 'min(280px, calc(100vw - 24px))', minWidth: '100%' }}
|
||||
>
|
||||
<div className="p-2 space-y-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="粘贴或输入车牌 支持换行/逗号/空格分隔,回车或点添加确认"
|
||||
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
apply(text);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-slate-400">已选 <span className="font-bold text-blue-600">{selected.length}</span> 个</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => apply(text)}
|
||||
disabled={!text.trim()}
|
||||
className="px-2 py-1 bg-blue-600 text-white rounded-md text-[10px] font-bold disabled:bg-slate-200 disabled:text-slate-400"
|
||||
>添加</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="px-2 py-1 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold"
|
||||
>清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unmatched.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 space-y-1">
|
||||
<div className="flex items-center gap-1 text-amber-700">
|
||||
<AlertTriangle size={10} />
|
||||
<span className="text-[10px] font-bold">{unmatched.length} 个车牌未匹配</span>
|
||||
<button
|
||||
onClick={() => setUnmatched([])}
|
||||
className="ml-auto text-amber-500 hover:text-amber-700"
|
||||
><X size={10} /></button>
|
||||
</div>
|
||||
<div className="text-[10px] text-amber-600 break-all max-h-16 overflow-y-auto leading-relaxed">
|
||||
{unmatched.join(',')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto p-1 bg-slate-50 rounded-lg">
|
||||
{selected.map(p => (
|
||||
<span key={p} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white border border-blue-100 text-blue-600 rounded text-[10px] font-bold">
|
||||
{p}
|
||||
<button onClick={() => removePlate(p)} className="text-blue-400 hover:text-blue-700"><X size={9} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-100 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索车牌"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
/>
|
||||
<div className="mt-1 max-h-40 overflow-y-auto">
|
||||
{filtered.map(p => (
|
||||
<div
|
||||
key={p}
|
||||
onClick={() => togglePlate(p)}
|
||||
className={`px-2 py-1 text-[10px] font-bold cursor-pointer flex items-center gap-1.5 rounded ${selectedSet.has(p) ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<span className={`w-3 h-3 rounded border ${selectedSet.has(p) ? 'bg-blue-600 border-blue-600' : 'border-slate-300'} flex items-center justify-center`}>
|
||||
{selectedSet.has(p) && <span className="text-white text-[8px] leading-none">✓</span>}
|
||||
</span>
|
||||
{p}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-1 text-[10px] text-slate-300 italic">无匹配项</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
function getDefaultDate(): string {
|
||||
const now = new Date();
|
||||
@@ -344,7 +345,7 @@ export default function StatisticsView() {
|
||||
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
|
||||
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono font-bold text-slate-700">{tv.plateNumber}</span>
|
||||
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
|
||||
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
|
||||
在线
|
||||
</span>
|
||||
@@ -561,7 +562,7 @@ export default function StatisticsView() {
|
||||
</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">{tv.plateNumber}</span>
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{tv.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
|
||||
342
src/modules/mileage/VehicleDetailModal.tsx
Normal file
342
src/modules/mileage/VehicleDetailModal.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||
import { X, Truck } from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||
} from 'recharts';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
vehicle: MonitoringVehicle | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||
|
||||
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
||||
{ key: 'last15', label: '近 15 天' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'quarter', label: '本季度' },
|
||||
];
|
||||
|
||||
function fmtYmd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const end = fmtYmd(today);
|
||||
if (key === 'last15') {
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - 14);
|
||||
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
|
||||
}
|
||||
if (key === 'month') {
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { start: fmtYmd(start), end, rangeLabel: '本月' };
|
||||
}
|
||||
const q = Math.floor(today.getMonth() / 3);
|
||||
const start = new Date(today.getFullYear(), q * 3, 1);
|
||||
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
||||
}
|
||||
|
||||
function isToday(date: string): boolean {
|
||||
return date === fmtYmd(new Date());
|
||||
}
|
||||
|
||||
function formatLabel(date: string, key: RangeKey): string {
|
||||
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||
void key;
|
||||
return date.slice(5);
|
||||
}
|
||||
|
||||
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [range, setRange] = useState<RangeKey>('last15');
|
||||
const dragControls = useDragControls();
|
||||
|
||||
// 切换车辆时重置区间为默认
|
||||
useEffect(() => {
|
||||
if (vehicle) setRange('last15');
|
||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 拉取数据(车辆或区间变化)
|
||||
useEffect(() => {
|
||||
if (!vehicle) return;
|
||||
const { start, end } = rangeFor(range);
|
||||
setLoading(true);
|
||||
setDays([]);
|
||||
let cancelled = false;
|
||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
||||
.then(d => { if (!cancelled) setDays(d.days); })
|
||||
.catch(() => { if (!cancelled) setDays([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 锁滚动
|
||||
useEffect(() => {
|
||||
if (!vehicle) return;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [vehicle]);
|
||||
|
||||
// 排除"今日"列(数据未到位时易引起误读)
|
||||
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
||||
const stats = useMemo(() => {
|
||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||
const avg = synced > 0 ? totalKm / synced : 0;
|
||||
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
|
||||
return { totalKm, synced, avg, max, totalDays: historyDays.length };
|
||||
}, [historyDays]);
|
||||
|
||||
// 骨架天数:根据区间预估
|
||||
const skeletonCount = useMemo(() => {
|
||||
if (range === 'last15') return 15;
|
||||
const { start, end } = rangeFor(range);
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
||||
}, [range]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{vehicle && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{
|
||||
y: { type: 'spring', damping: 32, stiffness: 320 },
|
||||
opacity: { duration: 0.18 },
|
||||
}}
|
||||
drag="y"
|
||||
dragControls={dragControls}
|
||||
dragListener={false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 600) onClose();
|
||||
}}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
||||
<div
|
||||
className="flex justify-center pt-2.5 pb-1.5 cursor-grab active:cursor-grabbing select-none"
|
||||
onPointerDown={(e) => dragControls.start(e)}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pb-2 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
|
||||
<Truck size={16} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
{vehicle.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 truncate">
|
||||
{vehicle.rentStatus || ''}
|
||||
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
|
||||
{vehicle.customer ? ` · ` : ''}
|
||||
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 时间范围切换 */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
||||
{RANGE_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setRange(tab.key)}
|
||||
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
{range === tab.key && (
|
||||
<motion.div
|
||||
layoutId="rangeTabBg"
|
||||
className="absolute inset-0 bg-white shadow-sm rounded-md"
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar chart */}
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold text-slate-500">行驶里程</span>
|
||||
<span className="text-[9px] font-bold text-slate-300">单位 km</span>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-50">
|
||||
<div className="h-[140px]">
|
||||
{loading ? (
|
||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(d) => formatLabel(d, range)}
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={6}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
|
||||
{historyDays.map((d, i) => (
|
||||
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日明细 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||
{loading ? (
|
||||
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
||||
) : (
|
||||
<motion.div
|
||||
key={range}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-1"
|
||||
>
|
||||
{historyDays.slice().reverse().map((d, i) => (
|
||||
<motion.div
|
||||
key={d.date}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
|
||||
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
|
||||
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
||||
{d.isDataSynced
|
||||
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
||||
: <span className="text-[9px]">未对接</span>}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonBars({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-full px-2 pb-2 pt-2">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-slate-100 rounded-t animate-pulse"
|
||||
style={{
|
||||
height: `${30 + Math.sin(i * 0.7) * 25 + Math.cos(i * 0.4) * 15 + 30}%`,
|
||||
animationDelay: `${i * 40}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonList({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-1.5 px-2 animate-pulse" style={{ animationDelay: `${i * 30}ms` }}>
|
||||
<div className="h-3 w-20 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export async function fetchMonitoring(params?: {
|
||||
rentStatus?: string;
|
||||
platePrefix?: string;
|
||||
targetName?: string;
|
||||
region?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
@@ -34,6 +35,7 @@ export async function fetchMonitoring(params?: {
|
||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||
if (params?.targetName) query.set('targetName', params.targetName);
|
||||
if (params?.region) query.set('region', params.region);
|
||||
if (params?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
@@ -59,3 +61,29 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
|
||||
params.set('days', String(days));
|
||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
||||
}
|
||||
|
||||
export interface VehicleRecentDay {
|
||||
date: string;
|
||||
dailyKm: number;
|
||||
isDataSynced: boolean;
|
||||
}
|
||||
|
||||
export interface VehicleRecentResponse {
|
||||
plate: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
days: VehicleRecentDay[];
|
||||
}
|
||||
|
||||
export async function fetchVehicleRecent(
|
||||
plate: string,
|
||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
||||
): Promise<VehicleRecentResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (range.start) params.set('start', range.start);
|
||||
if (range.end) params.set('end', range.end);
|
||||
if (range.days != null) params.set('days', String(range.days));
|
||||
return fetchJson<VehicleRecentResponse>(
|
||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface MonitoringVehicle {
|
||||
rentStatus: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringStats {
|
||||
@@ -30,6 +31,7 @@ export interface MonitoringFilters {
|
||||
rentStatuses: string[];
|
||||
platePrefixes: { prefix: string; count: number }[];
|
||||
targetNames: string[];
|
||||
regions: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
|
||||
81
src/modules/mileage/xlsx-export.ts
Normal file
81
src/modules/mileage/xlsx-export.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
interface ExportContext {
|
||||
date: string;
|
||||
sortBy: 'today' | 'total';
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'运营区域', '今日里程(km)', '累计里程(km)',
|
||||
] as const;
|
||||
|
||||
function statusLabel(v: MonitoringVehicle): string {
|
||||
if (!v.isDataSynced) return '未对接';
|
||||
return v.isOnline ? '在线' : '离线';
|
||||
}
|
||||
|
||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||
if (!v.isDataSynced) return '未对接';
|
||||
if (kind === 'today') return Math.max(0, Math.round(v.dailyKm || 0));
|
||||
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
||||
}
|
||||
|
||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||
const data: (string | number)[][] = [
|
||||
[...HEADERS],
|
||||
...vehicles.map(v => [
|
||||
statusLabel(v),
|
||||
v.plate,
|
||||
v.customer || '',
|
||||
v.department || '',
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
mileageCell(v, 'today'),
|
||||
mileageCell(v, 'total'),
|
||||
]),
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||
|
||||
ws['!cols'] = [
|
||||
{ wch: 8 }, // 状态
|
||||
{ wch: 12 }, // 车牌号
|
||||
{ wch: 28 }, // 客户
|
||||
{ wch: 14 }, // 业务部门
|
||||
{ wch: 16 }, // 项目
|
||||
{ wch: 10 }, // 租赁状态
|
||||
{ wch: 12 }, // 运营区域
|
||||
{ wch: 14 }, // 今日里程
|
||||
{ wch: 14 }, // 累计里程
|
||||
];
|
||||
|
||||
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
||||
|
||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
||||
for (let c = 0; c < HEADERS.length; c++) {
|
||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||
if (ws[ref]) {
|
||||
(ws[ref] as { s?: unknown }).s = {
|
||||
font: { bold: true, color: { rgb: 'FFFFFF' } },
|
||||
fill: { fgColor: { rgb: '2563EB' } },
|
||||
alignment: { horizontal: 'center', vertical: 'center' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
|
||||
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
|
||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
342
src/modules/scheduling/NotificationHistory.tsx
Normal file
342
src/modules/scheduling/NotificationHistory.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchNotifications, updateNotification } from './api';
|
||||
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
import SwapPreview from './SwapPreview';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onChange?: () => void;
|
||||
/** When true, pre-filter to the last 7 days (excluding cancelled). */
|
||||
recentOnly?: boolean;
|
||||
/** Current suggestions used to enrich records with customer/dept/manager and enable drill-down. */
|
||||
suggestions?: SchedulingSuggestion[];
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function shortDept(dept: string | null | undefined): string {
|
||||
return (dept || '').replace('业务', '');
|
||||
}
|
||||
|
||||
type StatusTab = 'all' | NotificationStatus;
|
||||
|
||||
const STATUS_TABS: { key: StatusTab; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'sent', label: '待执行' },
|
||||
{ key: 'executed', label: '已执行' },
|
||||
{ key: 'cancelled', label: '已取消' },
|
||||
];
|
||||
|
||||
function statusBadge(status: NotificationStatus) {
|
||||
if (status === 'sent') return { text: '待执行', icon: <Send size={9} />, cls: 'text-amber-700 bg-amber-50' };
|
||||
if (status === 'executed') return { text: '已执行', icon: <CheckCircle2 size={9} />, cls: 'text-emerald-700 bg-emerald-50' };
|
||||
return { text: '已取消', icon: <XCircle size={9} />, cls: 'text-slate-500 bg-slate-100' };
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${day} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
export default function NotificationHistory({ onClose, onChange, recentOnly = false, suggestions }: Props) {
|
||||
const [records, setRecords] = useState<NotificationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [recent7d, setRecent7d] = useState(recentOnly);
|
||||
const [mutatingId, setMutatingId] = useState<number | null>(null);
|
||||
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
|
||||
const [afterMileageInput, setAfterMileageInput] = useState('');
|
||||
const [notesInput, setNotesInput] = useState('');
|
||||
const [drillTarget, setDrillTarget] = useState<{ suggestion: SchedulingSuggestion; candidate: CandidateVehicle } | null>(null);
|
||||
|
||||
const suggestionById = useMemo(() => {
|
||||
const map = new Map<string, SchedulingSuggestion>();
|
||||
for (const s of suggestions ?? []) map.set(s.id, s);
|
||||
return map;
|
||||
}, [suggestions]);
|
||||
|
||||
const visibleRecords = recent7d
|
||||
? records.filter(r => {
|
||||
const t = Date.parse(r.createdAt);
|
||||
return Number.isFinite(t) && Date.now() - t <= SEVEN_DAYS_MS;
|
||||
})
|
||||
: records;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetchNotifications(tab === 'all' ? undefined : tab);
|
||||
setRecords(resp.records);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExecuteClick = (rec: NotificationRecord) => {
|
||||
setExecuteTarget(rec);
|
||||
setAfterMileageInput('');
|
||||
setNotesInput('');
|
||||
};
|
||||
|
||||
const handleExecuteConfirm = async () => {
|
||||
if (!executeTarget) return;
|
||||
setMutatingId(executeTarget.id);
|
||||
try {
|
||||
const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' };
|
||||
if (notesInput.trim()) body.notes = notesInput.trim();
|
||||
const parsed = Number(afterMileageInput);
|
||||
if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed;
|
||||
await updateNotification(executeTarget.id, body);
|
||||
setExecuteTarget(null);
|
||||
await load();
|
||||
onChange?.();
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (rec: NotificationRecord) => {
|
||||
if (!confirm(`确定取消 ${rec.currentPlate} → ${rec.candidatePlate} 的干预?`)) return;
|
||||
setMutatingId(rec.id);
|
||||
try {
|
||||
await updateNotification(rec.id, { status: 'cancelled' });
|
||||
await load();
|
||||
onChange?.();
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-white" />
|
||||
<span className="text-white font-bold text-sm">调度记录</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} disabled={loading} className="text-slate-300 hover:text-white p-1 cursor-pointer">
|
||||
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-slate-300 hover:text-white p-1 cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0 flex-wrap items-center">
|
||||
{STATUS_TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
|
||||
tab === t.key ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => setRecent7d(v => !v)}
|
||||
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
|
||||
recent7d ? 'bg-emerald-600 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
title="仅看最近 7 天"
|
||||
>
|
||||
近 7 天
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||
</div>
|
||||
) : visibleRecords.length === 0 ? (
|
||||
<div className="py-16 text-center text-slate-400">
|
||||
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
|
||||
<p className="text-sm">{recent7d ? '最近 7 天暂无干预记录' : '暂无记录'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{visibleRecords.map(rec => {
|
||||
const badge = statusBadge(rec.status);
|
||||
const busy = mutatingId === rec.id;
|
||||
const suggestion = suggestionById.get(rec.suggestionId);
|
||||
const candidate = suggestion?.candidates.find(c => c.plateNumber === rec.candidatePlate) ?? null;
|
||||
const canDrill = !!suggestion && !!candidate;
|
||||
const v = suggestion?.currentVehicle;
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (canDrill && suggestion && candidate) setDrillTarget({ suggestion, candidate });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rec.id}
|
||||
onClick={handleRowClick}
|
||||
className={`px-4 py-3 transition-colors ${canDrill ? 'cursor-pointer hover:bg-slate-50/60 active:bg-slate-100' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-1.5 text-xs min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
|
||||
{badge.icon} {badge.text}
|
||||
</span>
|
||||
{canDrill && <ChevronRight size={12} className="text-slate-300" />}
|
||||
</div>
|
||||
</div>
|
||||
{v && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 mb-0.5 truncate">
|
||||
{v.department && <span className="font-medium">{shortDept(v.department)}</span>}
|
||||
{v.manager && <span>{v.manager}</span>}
|
||||
<span className="text-slate-400 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-400">
|
||||
{rec.operatorName && <span>操作人 {rec.operatorName}</span>}
|
||||
<span>{fmtDateTime(rec.createdAt)}</span>
|
||||
{rec.status === 'executed' && rec.executedAt && (
|
||||
<span className="text-emerald-500">执行 {fmtDateTime(rec.executedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{rec.notes && (
|
||||
<div className="mt-1 text-[10px] text-slate-500 bg-slate-50 rounded px-2 py-1">{rec.notes}</div>
|
||||
)}
|
||||
{rec.status === 'sent' && (
|
||||
<div className="mt-2 flex items-center gap-2" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleExecuteClick(rec)}
|
||||
disabled={busy}
|
||||
className="text-[10px] font-bold text-white bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-2.5 py-1 rounded cursor-pointer transition-colors flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 size={10} /> 标记已执行
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancel(rec)}
|
||||
disabled={busy}
|
||||
className="text-[10px] font-medium text-slate-500 hover:text-rose-600 disabled:opacity-50 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Execute confirmation modal */}
|
||||
<AnimatePresence>
|
||||
{executeTarget && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[80] flex items-end sm:items-center justify-center"
|
||||
onClick={() => mutatingId === null && setExecuteTarget(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 40, opacity: 0 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4"
|
||||
>
|
||||
<div className="bg-emerald-600 px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-white font-bold text-sm">确认已执行</span>
|
||||
<button
|
||||
onClick={() => mutatingId === null && setExecuteTarget(null)}
|
||||
disabled={mutatingId !== null}
|
||||
className="text-emerald-100 hover:text-white p-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div className="text-xs text-slate-500">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{executeTarget.currentPlate}</Blur></span>
|
||||
<span className="mx-1.5">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{executeTarget.candidatePlate}</Blur></span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1">执行后里程 (km, 可选)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={afterMileageInput}
|
||||
onChange={e => setAfterMileageInput(e.target.value)}
|
||||
placeholder="例如 45230"
|
||||
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1">备注 (可选)</label>
|
||||
<textarea
|
||||
value={notesInput}
|
||||
onChange={e => setNotesInput(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="例如:司机已到位,交接完成"
|
||||
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => setExecuteTarget(null)}
|
||||
disabled={mutatingId !== null}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteConfirm}
|
||||
disabled={mutatingId !== null}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{mutatingId !== null ? '保存中...' : '确认'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Drill-down: replacement plan */}
|
||||
{drillTarget && (
|
||||
<SwapPreview
|
||||
suggestion={drillTarget.suggestion}
|
||||
candidate={drillTarget.candidate}
|
||||
onClose={() => setDrillTarget(null)}
|
||||
onSuccess={() => { load(); onChange?.(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
639
src/modules/scheduling/SchedulingModule.tsx
Normal file
639
src/modules/scheduling/SchedulingModule.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import SuggestionList from './SuggestionList';
|
||||
import SuggestionDetail from './SuggestionDetail';
|
||||
import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
interface AdvancedFilters {
|
||||
plateSearch: string;
|
||||
region: string;
|
||||
vehicleType: string;
|
||||
customer: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
const count = match[1];
|
||||
let desc = match[2];
|
||||
desc = desc.replace('4.5T普货', '普货');
|
||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
|
||||
function hasActiveFilters(f: AdvancedFilters): boolean {
|
||||
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
||||
}
|
||||
|
||||
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="space-y-1">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold">{label}</label>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2 text-xs text-left cursor-pointer hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span className={value ? 'text-slate-800 font-medium' : 'text-slate-400'}>{value || placeholder}</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10 relative">
|
||||
{options.length > 5 && (
|
||||
<div className="p-1.5 border-b border-slate-100">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索..." autoFocus
|
||||
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-y-auto max-h-36">
|
||||
<button onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${!value ? 'text-blue-600 font-bold' : 'text-slate-400'}`}>全部</button>
|
||||
{filtered.map(opt => (
|
||||
<button key={opt} onClick={() => { onChange(opt); setOpen(false); setSearch(''); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${value === opt ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700'}`}>{opt}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton pulse block */
|
||||
function Sk({ className }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
function SkeletonPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
|
||||
<Sk className="h-3 w-16" />
|
||||
<Sk className="h-7 w-12" />
|
||||
<Sk className="h-2.5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List card skeleton */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Sk className="h-4 w-28" />
|
||||
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||||
<Sk className="w-1 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sk className="h-3.5 w-20" />
|
||||
<Sk className="h-3 w-10 rounded-full" />
|
||||
<Sk className="h-3 w-14" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Sk className="h-2.5 w-28" />
|
||||
<Sk className="h-2.5 w-16" />
|
||||
<Sk className="h-2.5 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<Sk className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||
// Business rule: at most one active intervention per suggestion. If ANY
|
||||
// candidate is already intervened, skip the whole suggestion in batch flow.
|
||||
const hasActive = s.candidates.some(
|
||||
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
|
||||
);
|
||||
if (hasActive) return null;
|
||||
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
|
||||
}
|
||||
|
||||
export default function SchedulingModule() {
|
||||
const [data, setData] = useState<SchedulingResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
||||
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
|
||||
const [batchInFlight, setBatchInFlight] = useState(false);
|
||||
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchSuggestions(selectedTargetId)); } finally { setLoading(false); }
|
||||
}, [selectedTargetId]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Keep selectedSuggestion synced with latest data so candidate notification
|
||||
// status changes (登记 / 取消干预) propagate into the open detail modal.
|
||||
useEffect(() => {
|
||||
if (!selectedSuggestion || !data) return;
|
||||
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
|
||||
if (!fresh) setSelectedSuggestion(null);
|
||||
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
|
||||
}, [data, selectedSuggestion]);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exitSelectMode = useCallback(() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
setShowBatchConfirm(false);
|
||||
}, []);
|
||||
|
||||
const batchItems = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...selectedIds]
|
||||
.map(id => data.suggestions.find(s => s.id === id))
|
||||
.filter((s): s is SchedulingSuggestion => !!s)
|
||||
.map(s => {
|
||||
const candidate = pickBestCandidate(s);
|
||||
if (!candidate) return null;
|
||||
return { suggestion: s, candidate };
|
||||
})
|
||||
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
|
||||
}, [data, selectedIds]);
|
||||
|
||||
const handleBatchSubmit = useCallback(async () => {
|
||||
if (batchItems.length === 0) return;
|
||||
setBatchInFlight(true);
|
||||
try {
|
||||
const resp = await sendNotifyBatch({
|
||||
items: batchItems.map(i => ({
|
||||
suggestionId: i.suggestion.id,
|
||||
currentPlate: i.suggestion.currentVehicle.plateNumber,
|
||||
candidatePlate: i.candidate.plateNumber,
|
||||
})),
|
||||
});
|
||||
setBatchResultMsg(resp.message);
|
||||
await loadData();
|
||||
exitSelectMode();
|
||||
} catch (e) {
|
||||
console.error('batch notify failed:', e);
|
||||
setBatchResultMsg('批量干预失败,请重试');
|
||||
} finally {
|
||||
setBatchInFlight(false);
|
||||
}
|
||||
}, [batchItems, loadData, exitSelectMode]);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||
for (const s of data.suggestions) {
|
||||
const v = s.currentVehicle;
|
||||
if (v.region) r.add(v.region);
|
||||
if (v.vehicleType) t.add(v.vehicleType);
|
||||
if (v.customer) c.add(v.customer);
|
||||
if (v.department) d.add(v.department);
|
||||
if (v.manager) m.add(v.manager);
|
||||
}
|
||||
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
|
||||
}, [data]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let list = data.suggestions;
|
||||
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
||||
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
||||
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
|
||||
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
|
||||
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
|
||||
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
|
||||
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
|
||||
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
|
||||
return list;
|
||||
}, [data, typeFilter, filters]);
|
||||
|
||||
const summary = data?.summary;
|
||||
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
|
||||
|
||||
// Initial load — full page skeleton
|
||||
if (loading && !data) return <SkeletonPage />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||
|
||||
{/* ===== Summary Cards ===== */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||
{/* 里程高·换下 — warm orange */}
|
||||
<button
|
||||
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
||||
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||||
typeFilter === 'qualified'
|
||||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||||
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
|
||||
已完成考核目标
|
||||
</div>
|
||||
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
|
||||
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
|
||||
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>台</span>
|
||||
</div>
|
||||
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
|
||||
换下,腾位给待达标车
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 里程低·换走 — cool blue */}
|
||||
<button
|
||||
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
|
||||
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||||
typeFilter === 'hopeless'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
|
||||
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
|
||||
预估无法达标
|
||||
</div>
|
||||
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
|
||||
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
|
||||
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>台</span>
|
||||
</div>
|
||||
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
|
||||
换走,换上快达标的车
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 替换建议 — neutral dark */}
|
||||
<button
|
||||
onClick={() => setTypeFilter('all')}
|
||||
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||||
typeFilter === 'all'
|
||||
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
|
||||
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||
替换建议
|
||||
</div>
|
||||
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
||||
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
||||
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>条</span>
|
||||
</div>
|
||||
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
|
||||
执行后预计 +{summary?.estimatedGain ?? 0} 台达标
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 近期已干预 — emerald */}
|
||||
<button
|
||||
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
||||
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
|
||||
>
|
||||
<div className="text-[10px] font-bold mb-1 text-emerald-600">
|
||||
近期已干预
|
||||
</div>
|
||||
<div className="text-2xl font-black text-emerald-700">
|
||||
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
|
||||
<span className="text-[10px] font-normal ml-1 text-emerald-400">条</span>
|
||||
</div>
|
||||
<div className="text-[9px] mt-0.5 text-emerald-400">
|
||||
最近 7 天 · 点击查看
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ===== List Card ===== */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={loadData} disabled={loading}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
|
||||
disabled={filteredSuggestions.length === 0}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="导出 CSV"
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||
title="调度记录"
|
||||
>
|
||||
<Clock size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectMode) exitSelectMode();
|
||||
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||||
}}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectMode ? '退出多选' : '多选模式'}
|
||||
>
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Filter size={15} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
</button>
|
||||
{data?.targets.map(t => (
|
||||
<button key={t.id}
|
||||
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(t.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<AnimatePresence>
|
||||
{showFilter && (
|
||||
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden border-b border-slate-100">
|
||||
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
||||
{hasActiveFilters(tempFilters) && (
|
||||
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">重置</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold">车牌号</label>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" value={tempFilters.plateSearch} onChange={e => setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))}
|
||||
placeholder="搜索车牌号..." className="w-full pl-8 pr-3 py-2 bg-white rounded-lg text-xs border border-slate-200 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FilterSelect label="运营区域" options={filterOptions.regions} value={tempFilters.region} onChange={v => setTempFilters(prev => ({ ...prev, region: v }))} placeholder="全部区域" />
|
||||
<FilterSelect label="车辆类型" options={filterOptions.vehicleTypes} value={tempFilters.vehicleType} onChange={v => setTempFilters(prev => ({ ...prev, vehicleType: v }))} placeholder="全部类型" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FilterSelect label="业务部门" options={filterOptions.departments} value={tempFilters.department} onChange={v => setTempFilters(prev => ({ ...prev, department: v }))} placeholder="全部部门" />
|
||||
<FilterSelect label="业务负责人" options={filterOptions.managers} value={tempFilters.manager} onChange={v => setTempFilters(prev => ({ ...prev, manager: v }))} placeholder="全部负责人" />
|
||||
</div>
|
||||
<FilterSelect label="客户" options={filterOptions.customers} value={tempFilters.customer} onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))} placeholder="全部客户" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={() => setShowFilter(false)} className="flex-1 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">取消</button>
|
||||
<button onClick={() => { setFilters(tempFilters); setShowFilter(false); }} className="flex-1 py-2 text-xs font-bold text-white bg-slate-800 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors shadow-sm">确认筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Active filter tags */}
|
||||
{activeFilterCount > 0 && !showFilter && (
|
||||
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-slate-400">筛选:</span>
|
||||
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">车牌 "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
|
||||
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
|
||||
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
|
||||
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
|
||||
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
|
||||
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
|
||||
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">清除</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
||||
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400">共 {filteredSuggestions.length} 条结果</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
/* List skeleton while refreshing */
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||||
<Sk className="w-1 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sk className="h-3.5 w-20" />
|
||||
<Sk className="h-3 w-10 rounded-full" />
|
||||
<Sk className="h-3 w-14" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Sk className="h-2.5 w-28" />
|
||||
<Sk className="h-2.5 w-16" />
|
||||
<Sk className="h-2.5 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<Sk className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SuggestionList
|
||||
suggestions={filteredSuggestions}
|
||||
onSelect={setSelectedSuggestion}
|
||||
selectMode={selectMode}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSuggestion && (
|
||||
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
||||
)}
|
||||
|
||||
{showHistory && (
|
||||
<NotificationHistory
|
||||
onClose={() => setShowHistory(false)}
|
||||
onChange={loadData}
|
||||
recentOnly={historyRecentOnly}
|
||||
suggestions={data?.suggestions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch action bar */}
|
||||
<AnimatePresence>
|
||||
{selectMode && (
|
||||
<motion.div
|
||||
initial={{ y: 80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 80, opacity: 0 }}
|
||||
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">已选</span>
|
||||
<span className="text-lg font-black">{selectedIds.size}</span>
|
||||
<span className="text-xs text-slate-400">条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={exitSelectMode}
|
||||
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchConfirm(true)}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={12} /> 批量干预
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Batch confirmation modal */}
|
||||
{showBatchConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
|
||||
>
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-white font-bold text-sm">确认批量干预</span>
|
||||
<button
|
||||
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
将登记 <span className="font-bold text-slate-800">{batchItems.length}</span> 条干预,已排除无可用候选车的建议。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{batchItems.map(({ suggestion, candidate }) => (
|
||||
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||||
</div>
|
||||
{candidate.canQualifyAfterSwap ? (
|
||||
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||||
) : (
|
||||
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0">需关注</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{batchResultMsg && (
|
||||
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchSubmit}
|
||||
disabled={batchInFlight || batchItems.length === 0}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length} 条`}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
378
src/modules/scheduling/SuggestionDetail.tsx
Normal file
378
src/modules/scheduling/SuggestionDetail.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, Lock,
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
import SwapPreview from './SwapPreview';
|
||||
|
||||
type SortKey = 'predicted' | 'current';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface Props {
|
||||
suggestion: SchedulingSuggestion;
|
||||
onClose: () => void;
|
||||
onNotifySuccess: () => void;
|
||||
}
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtRate(rate: number): string {
|
||||
return (rate * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
const CUSTOMER_REASON_LABELS = new Set(['客户日均']);
|
||||
|
||||
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
||||
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
|
||||
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
||||
const [batchFilter, setBatchFilter] = useState<Set<string>>(new Set());
|
||||
const [sortKey, setSortKey] = useState<SortKey>('predicted');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const v = s.currentVehicle;
|
||||
const isRescue = s.type === 'rescue_hopeless';
|
||||
|
||||
// Business rule: a current vehicle can have AT MOST ONE active intervention.
|
||||
// Find the active candidate (if any) — other candidates are blocked until
|
||||
// this one is cancelled.
|
||||
const activeIntervention = s.candidates.find(
|
||||
cc => cc.notificationStatus === 'sent' || cc.notificationStatus === 'executed',
|
||||
);
|
||||
|
||||
// Batch options from candidates
|
||||
const batchOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const c of s.candidates) if (c.targetName) set.add(c.targetName);
|
||||
return [...set].sort();
|
||||
}, [s.candidates]);
|
||||
|
||||
// Filtered + sorted candidates, grouped by region
|
||||
const { sameRegion, crossRegion } = useMemo(() => {
|
||||
let list = s.candidates;
|
||||
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
|
||||
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
|
||||
return sortDir === 'desc' ? vb - va : va - vb;
|
||||
});
|
||||
return {
|
||||
sameRegion: sorted.filter(c => c.isSameRegion),
|
||||
crossRegion: sorted.filter(c => !c.isSameRegion),
|
||||
};
|
||||
}, [s.candidates, batchFilter, sortKey, sortDir]);
|
||||
|
||||
const displayCount = sameRegion.length + crossRegion.length;
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
||||
else { setSortKey(key); setSortDir('desc'); }
|
||||
};
|
||||
|
||||
const renderCandidate = (c: CandidateVehicle) => {
|
||||
const sent =
|
||||
sentPlates.has(c.plateNumber) ||
|
||||
c.notificationStatus === 'sent' ||
|
||||
c.notificationStatus === 'executed';
|
||||
const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber;
|
||||
return (
|
||||
<div key={c.plateNumber} className={`rounded-xl border overflow-hidden bg-white ${blockedByOther ? 'border-slate-200 opacity-60' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
|
||||
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
|
||||
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
|
||||
<span className="text-[9px] text-slate-400">剩余{c.daysLeft}天</span>
|
||||
</div>
|
||||
{c.canQualifyAfterSwap ? (
|
||||
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
<CheckCircle size={10} /> 可达标
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
<AlertTriangle size={10} /> 需关注
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
|
||||
<div className="flex-1 py-1.5 px-2 text-center">
|
||||
<div className="text-slate-400">当前</div>
|
||||
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
|
||||
</div>
|
||||
<div className="flex-1 py-1.5 px-2 text-center">
|
||||
<div className="text-slate-400">替换后预计</div>
|
||||
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
|
||||
</div>
|
||||
<div className="flex-1 py-1.5 px-2 text-center">
|
||||
<div className="text-blue-400">考核</div>
|
||||
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2.5">
|
||||
{blockedByOther ? (
|
||||
<div className="w-full flex items-center justify-center gap-1.5 text-[11px] font-medium py-2 rounded-lg bg-slate-50 text-slate-400 cursor-not-allowed">
|
||||
<Lock size={11} /> 该车已有其他干预,请先解除
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setPreviewCandidate(c)}
|
||||
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
||||
sent
|
||||
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{sent ? <><CheckCircle size={12} /> 已干预 · 查看 <ArrowRight size={12} /></> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-lg overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[85vh] sm:mx-4"
|
||||
>
|
||||
{/* Header — unified dark slate */}
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{isRescue
|
||||
? <ArrowDown size={14} className="text-blue-300" />
|
||||
: <ArrowUp size={14} className="text-amber-300" />
|
||||
}
|
||||
<span className="text-white font-bold text-sm">
|
||||
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 no-scrollbar">
|
||||
|
||||
{/* Current Vehicle — same format as candidate cards */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||
{/* Header — same style as candidate header */}
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span>
|
||||
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{v.region}</span>
|
||||
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
||||
<span className="text-[9px] text-slate-300">{v.targetName}</span>
|
||||
<span className="text-[9px] text-slate-400">剩余{v.daysLeft}天</span>
|
||||
</div>
|
||||
<span className={`text-sm font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>
|
||||
{fmtRate(v.completionRate)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Customer + dept/manager info */}
|
||||
<div className="px-3 pb-1.5 flex items-center gap-2 text-[10px] text-slate-500 flex-wrap">
|
||||
{v.department && <span><b className="text-slate-700">{v.department}</b></span>}
|
||||
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
|
||||
{(v.department || v.manager) && <span className="text-slate-200">|</span>}
|
||||
<span>客户 <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
|
||||
<span>
|
||||
30日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
|
||||
</span>
|
||||
</div>
|
||||
{/* Metrics */}
|
||||
<div className="px-3 pb-2">
|
||||
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
|
||||
<div className="flex-1 py-1.5 px-2 text-center">
|
||||
<div className="text-slate-400">当前</div>
|
||||
<div className="font-bold text-slate-700">{fmtKm(v.currentYearMileage)}</div>
|
||||
</div>
|
||||
<div className="flex-1 py-1.5 px-2 text-center">
|
||||
<div className="text-slate-400">考核期结束预估</div>
|
||||
<div className={`font-bold ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>{fmtKm(v.currentYearMileage + v.customerAvgDaily * v.daysLeft)}</div>
|
||||
</div>
|
||||
<div className="flex-1 py-1.5 px-2 text-center">
|
||||
<div className="text-blue-400">考核</div>
|
||||
<div className="font-bold text-blue-700">{fmtKm(v.yearTarget)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason — customer vs vehicle columns */}
|
||||
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
|
||||
<div className="grid grid-cols-2 gap-x-5">
|
||||
{(() => {
|
||||
const customerLines = s.reason.lines.filter(l => CUSTOMER_REASON_LABELS.has(l.label));
|
||||
const vehicleLines = s.reason.lines.filter(l => !CUSTOMER_REASON_LABELS.has(l.label));
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1">客户</div>
|
||||
<div className="space-y-1">
|
||||
{customerLines.map((line, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-500">{line.label}</span>
|
||||
<span className="text-slate-700 font-medium">{line.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1">车辆</div>
|
||||
<div className="space-y-1">
|
||||
{vehicleLines.map((line, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-500">{line.label}</span>
|
||||
<span className="text-slate-700 font-medium">{line.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-slate-200">
|
||||
<span className="text-xs font-bold text-rose-600">{s.reason.conclusion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Candidates */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-bold text-slate-700">可替换在库车辆</span>
|
||||
<span className="text-[10px] text-slate-400">{displayCount}/{s.candidates.length} 辆</span>
|
||||
</div>
|
||||
|
||||
{activeIntervention && (
|
||||
<div className="mb-2.5 flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-800">
|
||||
<Lock size={12} className="mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
此车已干预替换为 <b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b>。如需更换方案,请先在该候选车处解除干预。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter + Sort controls */}
|
||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||
{/* Batch multi-select pills */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setBatchFilter(new Set())}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
|
||||
batchFilter.size === 0 ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{batchOptions.map(b => {
|
||||
const active = batchFilter.has(b);
|
||||
return (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBatchFilter(prev => {
|
||||
const next = new Set(prev);
|
||||
if (active) next.delete(b); else next.add(b);
|
||||
return next;
|
||||
})}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
|
||||
active ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sort buttons */}
|
||||
<button
|
||||
onClick={() => toggleSort('predicted')}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||||
sortKey === 'predicted' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
替换后预计
|
||||
{sortKey === 'predicted' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||||
{sortKey !== 'predicted' && <ArrowUpDown size={10} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleSort('current')}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||||
sortKey === 'current' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
当前里程
|
||||
{sortKey === 'current' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||||
{sortKey !== 'current' && <ArrowUpDown size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sameRegion.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sameRegion.map(c => renderCandidate(c))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossRegion.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 my-3">
|
||||
<div className="flex-1 h-px bg-slate-200" />
|
||||
<span className="text-[10px] text-slate-400 font-medium">跨区候选 · {crossRegion.length} 辆</span>
|
||||
<div className="flex-1 h-px bg-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{crossRegion.map(c => renderCandidate(c))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayCount === 0 && (
|
||||
<div className="py-8 text-center text-xs text-slate-400">当前筛选条件下无可替换车辆</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-slate-100 px-4 py-2.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Full-screen swap preview */}
|
||||
{previewCandidate && (
|
||||
<SwapPreview
|
||||
suggestion={s}
|
||||
candidate={previewCandidate}
|
||||
onClose={() => setPreviewCandidate(null)}
|
||||
onSuccess={() => {
|
||||
setSentPlates(prev => new Set(prev).add(previewCandidate.plateNumber));
|
||||
setPreviewCandidate(null);
|
||||
onNotifySuccess();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/modules/scheduling/SuggestionList.tsx
Normal file
172
src/modules/scheduling/SuggestionList.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
suggestions: SchedulingSuggestion[];
|
||||
onSelect: (s: SchedulingSuggestion) => void;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
function hasActiveNotification(s: SchedulingSuggestion): boolean {
|
||||
return s.candidates.some(c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
|
||||
}
|
||||
|
||||
function fmtRate(rate: number): string {
|
||||
return (rate * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
type SortKey = 'default' | 'avgDaily' | 'completion';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export default function SuggestionList({ suggestions, onSelect, selectMode = false, selectedIds, onToggleSelect }: Props) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('default');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
||||
else { setSortKey(key); setSortDir('desc'); }
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (sortKey === 'default') return suggestions;
|
||||
return [...suggestions].sort((a, b) => {
|
||||
const va = sortKey === 'avgDaily' ? a.currentVehicle.customerAvgDaily : a.currentVehicle.completionRate;
|
||||
const vb = sortKey === 'avgDaily' ? b.currentVehicle.customerAvgDaily : b.currentVehicle.completionRate;
|
||||
return sortDir === 'desc' ? vb - va : va - vb;
|
||||
});
|
||||
}, [suggestions, sortKey, sortDir]);
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<ArrowRightLeft className="w-8 h-8 text-slate-200 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-400">暂无调度建议</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sort controls */}
|
||||
<div className="px-4 py-2 border-b border-slate-50 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleSort('avgDaily')}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||||
sortKey === 'avgDaily' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
客户日均
|
||||
{sortKey === 'avgDaily' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||||
{sortKey !== 'avgDaily' && <ArrowUpDown size={10} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleSort('completion')}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||||
sortKey === 'completion' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
年度达标
|
||||
{sortKey === 'completion' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||||
{sortKey !== 'completion' && <ArrowUpDown size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-50">
|
||||
{sorted.map((s, idx) => {
|
||||
const isRescue = s.type === 'rescue_hopeless';
|
||||
const v = s.currentVehicle;
|
||||
const notified = hasActiveNotification(s);
|
||||
const isSelected = selectedIds?.has(s.id) ?? false;
|
||||
const canSelect = selectMode && !notified;
|
||||
|
||||
const handleClick = () => {
|
||||
if (selectMode) {
|
||||
if (canSelect) onToggleSelect?.(s.id);
|
||||
} else {
|
||||
onSelect(s);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: Math.min(idx * 0.02, 0.3) }}
|
||||
className={`px-4 py-3 hover:bg-slate-50/60 transition-colors flex items-center gap-3 ${
|
||||
canSelect || !selectMode ? 'cursor-pointer active:bg-slate-100' : 'cursor-default opacity-60'
|
||||
} ${isSelected ? 'bg-blue-50/60' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Checkbox (select mode) */}
|
||||
{selectMode && (
|
||||
<div
|
||||
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: notified
|
||||
? 'bg-slate-100 border-slate-200'
|
||||
: 'bg-white border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check size={12} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color bar */}
|
||||
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-black text-slate-900 font-mono">
|
||||
<Blur>{v.plateNumber}</Blur>
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
||||
<span className="text-[9px] text-slate-300">·</span>
|
||||
<span className="text-[9px] text-slate-400">{v.region}</span>
|
||||
{notified && (
|
||||
<span className="text-[9px] font-bold text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded flex items-center gap-0.5">
|
||||
<CheckCircle size={9} /> 已干预
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] flex-shrink-0">
|
||||
<span className="text-slate-500">年度考核 </span>
|
||||
<span className={`font-medium ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5 text-[10px] overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 text-slate-400 truncate">
|
||||
{v.department && <span className="text-slate-500 font-medium">{v.department.replace('业务', '')}</span>}
|
||||
{v.manager && <span className="text-slate-500">{v.manager}</span>}
|
||||
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<span className="text-slate-500">
|
||||
客户日均 <span className="text-slate-700 font-medium">{Math.round(v.customerAvgDaily)}</span> km
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
{!selectMode && (
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-[9px] text-slate-400">干预</span>
|
||||
<ChevronRight size={14} className="text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/modules/scheduling/SwapPreview.tsx
Normal file
169
src/modules/scheduling/SwapPreview.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
|
||||
import { sendNotify, updateNotification } from './api';
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
suggestion: SchedulingSuggestion;
|
||||
candidate: CandidateVehicle;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtRate(rate: number): string {
|
||||
return (rate * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const v = s.currentVehicle;
|
||||
|
||||
const alreadyIntervened =
|
||||
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
|
||||
const isExecuted = c.notificationStatus === 'executed';
|
||||
|
||||
const handleSend = async () => {
|
||||
if (sending || sent || alreadyIntervened) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
|
||||
if (result.success) { setSent(true); onSuccess(); } else { alert(result.message || '发送失败'); }
|
||||
} catch { alert('网络错误'); } finally { setSending(false); }
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!c.notificationId || cancelling) return;
|
||||
if (isExecuted) {
|
||||
if (!confirm('此干预已标记为执行。确定要取消吗?')) return;
|
||||
} else {
|
||||
if (!confirm(`确定取消 ${v.plateNumber} → ${c.plateNumber} 的干预?`)) return;
|
||||
}
|
||||
setCancelling(true);
|
||||
try {
|
||||
const result = await updateNotification(c.notificationId, { status: 'cancelled' });
|
||||
if (result.success) { onSuccess(); onClose(); }
|
||||
else { alert(result.message || '取消失败'); }
|
||||
} catch { alert('网络错误'); } finally { setCancelling(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-white border-b border-slate-200 flex-shrink-0">
|
||||
<span className="text-sm font-bold text-slate-800">车辆替换方案</span>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600 cursor-pointer"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-5 py-5">
|
||||
<div className="max-w-sm mx-auto space-y-4">
|
||||
|
||||
{/* === Swap Cards === */}
|
||||
<div className="relative">
|
||||
{/* Current vehicle */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
|
||||
<div className="text-[10px] text-slate-400">考核 {fmtKm(v.yearTarget)} km</div>
|
||||
<div className="text-[10px] text-slate-400">年度考核剩余 <b className="text-slate-700">{v.daysLeft}</b> 天</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
||||
<span><Blur>{v.customer || '-'}</Blur></span>
|
||||
<span>日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b></span>
|
||||
<span>完成 <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow bridge */}
|
||||
<div className="flex justify-center -my-3 relative z-10">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
|
||||
<ArrowDownUp size={16} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replacement vehicle */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-base font-black text-slate-800">{fmtKm(c.totalMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
|
||||
<div className="text-[10px] text-slate-400">考核 {c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
||||
<span>缺口 <b className="text-rose-500">{fmtKm(c.mileageGap)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Result === */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-3">替换后预测</div>
|
||||
<div className="flex items-end gap-6">
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-400 mb-0.5">预测年终里程</div>
|
||||
<div className="text-xl font-black text-slate-800">{fmtKm(c.predictedAfterSwap)} <span className="text-[10px] font-normal text-slate-400">km</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-400 mb-0.5">考核目标</div>
|
||||
<div className="text-xl font-black text-slate-800">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} <span className="text-[10px] font-normal text-slate-400">km</span></div>
|
||||
</div>
|
||||
<div className={`text-sm font-black px-3 py-1 rounded-lg ${c.canQualifyAfterSwap ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
|
||||
<div className="max-w-sm mx-auto space-y-2">
|
||||
{alreadyIntervened && (
|
||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700 flex items-center gap-2">
|
||||
<CheckCircle size={13} />
|
||||
<span>此车已{isExecuted ? '执行干预' : '登记干预'},如需重新干预请先取消。</span>
|
||||
</div>
|
||||
)}
|
||||
{alreadyIntervened ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold bg-white text-rose-600 border border-rose-200 hover:bg-rose-50 active:scale-[0.98] transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Ban size={16} /> {cancelling ? '取消中...' : '取消干预'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending || sent}
|
||||
className={`w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold transition-all cursor-pointer ${
|
||||
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{sent ? <><CheckCircle size={16} /> 已登记</> : <><Send size={16} /> 登记干预</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/modules/scheduling/api.ts
Normal file
61
src/modules/scheduling/api.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
SchedulingResponse,
|
||||
NotifyRequest,
|
||||
NotifyBatchRequest,
|
||||
NotifyBatchResult,
|
||||
NotificationRecord,
|
||||
NotificationStatus,
|
||||
UpdateNotificationRequest,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/scheduling';
|
||||
|
||||
export async function fetchSuggestions(targetId?: number): Promise<SchedulingResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (targetId !== undefined) params.set('targetId', String(targetId));
|
||||
const qs = params.toString();
|
||||
return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function sendNotify(
|
||||
body: NotifyRequest,
|
||||
): Promise<{ success: boolean; message: string; record?: NotificationRecord }> {
|
||||
return fetchJson(`${BASE}/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendNotifyBatch(
|
||||
body: NotifyBatchRequest,
|
||||
): Promise<{ success: boolean; message: string; result: NotifyBatchResult }> {
|
||||
return fetchJson(`${BASE}/notify/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchNotifications(
|
||||
status?: NotificationStatus,
|
||||
limit?: number,
|
||||
): Promise<{ records: NotificationRecord[] }> {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
return fetchJson(`${BASE}/notify${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function updateNotification(
|
||||
id: number,
|
||||
body: UpdateNotificationRequest,
|
||||
): Promise<{ success: boolean; record?: NotificationRecord; message?: string }> {
|
||||
return fetchJson(`${BASE}/notify/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
103
src/modules/scheduling/csv-export.ts
Normal file
103
src/modules/scheduling/csv-export.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
|
||||
function csvCell(v: string | number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = typeof v === 'number' ? String(v) : v;
|
||||
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function pickTopCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||
if (s.candidates.length === 0) return null;
|
||||
const sameRegion = s.candidates.filter(c => c.isSameRegion);
|
||||
const pool = sameRegion.length > 0 ? sameRegion : s.candidates;
|
||||
return pool.find(c => c.canQualifyAfterSwap) ?? pool[0];
|
||||
}
|
||||
|
||||
function pctString(rate: number): string {
|
||||
return (rate * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function typeLabel(s: SchedulingSuggestion): string {
|
||||
return s.type === 'replace_qualified' ? '里程高·换下' : '里程低·换走';
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
'车牌号',
|
||||
'业务部门',
|
||||
'业务负责人',
|
||||
'客户',
|
||||
'车型',
|
||||
'运营区域',
|
||||
'调度类型',
|
||||
'当前年里程(km)',
|
||||
'年度考核(km)',
|
||||
'年度完成率',
|
||||
'客户30日均(km)',
|
||||
'客户7日均(km)',
|
||||
'剩余天数',
|
||||
'最优候选车牌',
|
||||
'候选当前里程(km)',
|
||||
'候选替换后预估(km)',
|
||||
'候选可达标',
|
||||
'候选区域',
|
||||
'干预状态',
|
||||
] as const;
|
||||
|
||||
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {
|
||||
const rows: string[] = [HEADERS.map(csvCell).join(',')];
|
||||
for (const s of suggestions) {
|
||||
const v = s.currentVehicle;
|
||||
const top = pickTopCandidate(s);
|
||||
const notifStatus =
|
||||
s.candidates.find(c => c.notificationStatus === 'executed') ? '已执行'
|
||||
: s.candidates.find(c => c.notificationStatus === 'sent') ? '待执行'
|
||||
: '';
|
||||
rows.push([
|
||||
csvCell(v.plateNumber),
|
||||
csvCell(v.department ?? ''),
|
||||
csvCell(v.manager ?? ''),
|
||||
csvCell(v.customer ?? ''),
|
||||
csvCell(v.vehicleType),
|
||||
csvCell(v.region),
|
||||
csvCell(typeLabel(s)),
|
||||
csvCell(Math.round(v.currentYearMileage)),
|
||||
csvCell(Math.round(v.yearTarget)),
|
||||
csvCell(pctString(v.completionRate)),
|
||||
csvCell(Math.round(v.customerAvgDaily)),
|
||||
csvCell(Math.round(v.customerAvgDaily7d)),
|
||||
csvCell(v.daysLeft),
|
||||
csvCell(top?.plateNumber ?? ''),
|
||||
csvCell(top ? Math.round(top.totalMileage) : ''),
|
||||
csvCell(top ? Math.round(top.predictedAfterSwap) : ''),
|
||||
csvCell(top ? (top.canQualifyAfterSwap ? '是' : '否') : ''),
|
||||
csvCell(top?.region ?? ''),
|
||||
csvCell(notifStatus),
|
||||
].join(','));
|
||||
}
|
||||
return rows.join('\r\n');
|
||||
}
|
||||
|
||||
export function downloadCsv(filename: string, csv: string): void {
|
||||
// UTF-8 BOM so Excel opens Chinese characters correctly
|
||||
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function exportSuggestionsCsv(suggestions: SchedulingSuggestion[], prefix = '调度建议'): void {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const csv = buildSuggestionsCsv(suggestions);
|
||||
downloadCsv(`${prefix}_${y}${m}${d}_${hh}${mm}.csv`, csv);
|
||||
}
|
||||
16
src/modules/scheduling/types.ts
Normal file
16
src/modules/scheduling/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
SchedulingVehicleInfo,
|
||||
CandidateVehicle,
|
||||
SchedulingSuggestion,
|
||||
SchedulingSummary,
|
||||
SchedulingTargetOption,
|
||||
SchedulingResponse,
|
||||
NotifyRequest,
|
||||
NotifyBatchRequest,
|
||||
NotifyBatchResult,
|
||||
NotificationStatus,
|
||||
NotificationRecord,
|
||||
UpdateNotificationRequest,
|
||||
ReasonLine,
|
||||
ReasonBlock,
|
||||
} from '../../shared/scheduling/types';
|
||||
@@ -66,6 +66,7 @@ app.get('/exchange', async (c) => {
|
||||
depCode: userInfo.depCode,
|
||||
depName,
|
||||
permissionLevel,
|
||||
roles: roleNames,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
||||
|
||||
@@ -4,16 +4,38 @@ import type { JwtPayload, AuthUser } from './types.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||
|
||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||
const BYPASS_AUTH = false;
|
||||
|
||||
export async function authMiddleware(c: Context, next: Next) {
|
||||
const path = c.req.path;
|
||||
|
||||
if (BYPASS_AUTH) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
|
||||
if (process.env.DEV_BYPASS_AUTH === '1') {
|
||||
const devUser: AuthUser = {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
loginName: 'dev-local',
|
||||
depCode: '',
|
||||
depName: '',
|
||||
permissionLevel: 'full',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
};
|
||||
c.set('user', devUser);
|
||||
return next();
|
||||
}
|
||||
|
||||
// 跳过不需要认证的路径
|
||||
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
@@ -28,6 +50,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
depCode: payload.depCode,
|
||||
depName: payload.depName,
|
||||
permissionLevel: payload.permissionLevel,
|
||||
roles: payload.roles ?? [],
|
||||
};
|
||||
c.set('user', user);
|
||||
return next();
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AuthUser {
|
||||
depCode: string;
|
||||
depName: string;
|
||||
permissionLevel: PermissionLevel;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
@@ -16,12 +17,20 @@ export interface JwtPayload {
|
||||
depCode: string;
|
||||
depName: string;
|
||||
permissionLevel: PermissionLevel;
|
||||
roles: string[];
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/** 全量权限角色名 */
|
||||
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
|
||||
|
||||
/** 部门级权限角色名 */
|
||||
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
||||
// Re-export role constants and helpers from the shared module so existing
|
||||
// server imports (`from './types.js'`) keep working.
|
||||
export {
|
||||
FULL_ACCESS_ROLES,
|
||||
DEPT_ACCESS_ROLES,
|
||||
SCHEDULING_ACCESS_ROLES,
|
||||
FEEDBACK_ADMIN_ROLES,
|
||||
ENERGY_ACCESS_ROLES,
|
||||
canAccessScheduling,
|
||||
canManageFeedback,
|
||||
canAccessEnergy,
|
||||
} from '../../shared/auth/roles.js';
|
||||
|
||||
@@ -5,6 +5,11 @@ import { cors } from 'hono/cors';
|
||||
import dotenv from 'dotenv';
|
||||
import vehiclesRouter from './routes/vehicles.js';
|
||||
import mileageRouter from './routes/mileage/index.js';
|
||||
import schedulingRouter from './routes/scheduling/index.js';
|
||||
import energyRouter from './routes/energy/index.js';
|
||||
import eleRouter from './routes/ele/index.js';
|
||||
import feedbackRouter from './routes/feedback/index.js';
|
||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
|
||||
@@ -22,6 +27,10 @@ app.use('/api/*', authMiddleware);
|
||||
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
app.route('/api/scheduling', schedulingRouter);
|
||||
app.route('/api/energy', energyRouter);
|
||||
app.route('/api/ele', eleRouter);
|
||||
app.route('/api/feedback', feedbackRouter);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||
|
||||
@@ -32,6 +41,7 @@ app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
||||
const port = Number(process.env.SERVER_PORT) || 3001;
|
||||
|
||||
console.log(`Server starting on port ${port}...`);
|
||||
ensureSchedulingTables().catch(e => console.error('scheduling bootstrap error:', e));
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Server running at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
355
src/server/routes/ele/index.ts
Normal file
355
src/server/routes/ele/index.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
|
||||
import * as XLSX from 'xlsx';
|
||||
import pool from '../../db.js';
|
||||
import { ensureChargeRecordTable } from './migration.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 与 xlsx 列名对齐
|
||||
const COL = {
|
||||
orderNo: '订单编号',
|
||||
stationNo: '电站编号',
|
||||
stationName: '电站名称',
|
||||
terminalName: '终端名称',
|
||||
region: '所属大区',
|
||||
city: '所属城市',
|
||||
district: '市区名称',
|
||||
operatingCompany:'运营公司',
|
||||
stationType: '电站类型',
|
||||
orderStatus: '订单状态',
|
||||
chargeForm: '充电形式',
|
||||
startTime: '充电开始时间',
|
||||
endTime: '充电结束时间',
|
||||
duration: '充电时长(分钟)',
|
||||
kwh: '充电电量(度)',
|
||||
eFee: '充电电费(元)',
|
||||
serviceFee: '充电服务费(元)',
|
||||
fee: '充电费用(元)',
|
||||
plate: '车牌号',
|
||||
judgedPlate: '判定车牌号',
|
||||
vin: '车架号',
|
||||
customerName: '真实姓名',
|
||||
customerPhone: '手机号',
|
||||
enterpriseName: '企业名称',
|
||||
} as const;
|
||||
|
||||
function safeStr(v: unknown, max = 250): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
if (!s) return null;
|
||||
return s.slice(0, max);
|
||||
}
|
||||
|
||||
function safeNum(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeDt(v: unknown): string | null {
|
||||
const s = safeStr(v);
|
||||
if (!s) return null;
|
||||
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
|
||||
// 简单校验
|
||||
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
|
||||
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
|
||||
}
|
||||
|
||||
function normalizePlate(p: unknown): string | null {
|
||||
const s = safeStr(p, 32);
|
||||
if (!s) return null;
|
||||
// 去掉所有空白字符
|
||||
const trimmed = s.replace(/\s+/g, '').toUpperCase();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
|
||||
// 寻找含"订单编号"和"车牌号"的那一行
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!Array.isArray(row)) continue;
|
||||
const cells = row.map(c => (c == null ? '' : String(c)));
|
||||
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
|
||||
return { headerIdx: i, header: cells };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
orderNo: string;
|
||||
raw: Record<string, unknown>;
|
||||
values: {
|
||||
stationNo: string | null; stationName: string | null; terminalName: string | null;
|
||||
region: string | null; city: string | null; district: string | null;
|
||||
operatingCompany: string | null; stationType: string | null;
|
||||
orderStatus: string | null; chargeForm: string | null;
|
||||
startTime: string | null; endTime: string | null;
|
||||
duration: number | null; kwh: number | null;
|
||||
eFee: number | null; serviceFee: number | null; fee: number | null;
|
||||
plate: string | null; judgedPlate: string | null; vin: string | null;
|
||||
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
|
||||
const wb = XLSX.read(buf, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
if (!ws) return [];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
|
||||
const found = findHeaderRow(rows as unknown[][]);
|
||||
if (!found) return [];
|
||||
const { headerIdx, header } = found;
|
||||
const idx = (label: string) => header.indexOf(label);
|
||||
const result: ParsedRow[] = [];
|
||||
for (let r = headerIdx + 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!Array.isArray(row)) continue;
|
||||
const orderNo = safeStr(row[idx(COL.orderNo)]);
|
||||
if (!orderNo) continue;
|
||||
const raw: Record<string, unknown> = {};
|
||||
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
|
||||
result.push({
|
||||
orderNo,
|
||||
raw,
|
||||
values: {
|
||||
stationNo: safeStr(row[idx(COL.stationNo)]),
|
||||
stationName: safeStr(row[idx(COL.stationName)]),
|
||||
terminalName: safeStr(row[idx(COL.terminalName)]),
|
||||
region: safeStr(row[idx(COL.region)]),
|
||||
city: safeStr(row[idx(COL.city)]),
|
||||
district: safeStr(row[idx(COL.district)]),
|
||||
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
|
||||
stationType: safeStr(row[idx(COL.stationType)]),
|
||||
orderStatus: safeStr(row[idx(COL.orderStatus)]),
|
||||
chargeForm: safeStr(row[idx(COL.chargeForm)]),
|
||||
startTime: safeDt(row[idx(COL.startTime)]),
|
||||
endTime: safeDt(row[idx(COL.endTime)]),
|
||||
duration: safeNum(row[idx(COL.duration)]),
|
||||
kwh: safeNum(row[idx(COL.kwh)]),
|
||||
eFee: safeNum(row[idx(COL.eFee)]),
|
||||
serviceFee: safeNum(row[idx(COL.serviceFee)]),
|
||||
fee: safeNum(row[idx(COL.fee)]),
|
||||
plate: normalizePlate(row[idx(COL.plate)]),
|
||||
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
|
||||
vin: safeStr(row[idx(COL.vin)]),
|
||||
customerName: safeStr(row[idx(COL.customerName)]),
|
||||
customerPhone: safeStr(row[idx(COL.customerPhone)]),
|
||||
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
|
||||
if (plates.size === 0) return new Map();
|
||||
const arr = Array.from(plates);
|
||||
const placeholders = arr.map(() => '?').join(',');
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
||||
FROM tab_truck
|
||||
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
|
||||
arr,
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const r of rows) {
|
||||
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// POST /api/ele/import — 上传 xlsx 文件
|
||||
// =========================================================
|
||||
app.post('/import', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
const form = await c.req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||
}
|
||||
const filename = file.name || 'unnamed.xlsx';
|
||||
const buf = await file.arrayBuffer();
|
||||
let parsed: ParsedRow[];
|
||||
try {
|
||||
parsed = parseSheet(buf);
|
||||
} catch (e) {
|
||||
console.error('parseSheet error:', e);
|
||||
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
|
||||
}
|
||||
|
||||
// 文件内去重
|
||||
const dedupMap = new Map<string, ParsedRow>();
|
||||
for (const p of parsed) dedupMap.set(p.orderNo, p);
|
||||
const records = Array.from(dedupMap.values());
|
||||
const fileDuplicates = parsed.length - records.length;
|
||||
|
||||
// 系统车辆匹配
|
||||
const allPlates = new Set<string>();
|
||||
for (const r of records) {
|
||||
if (r.values.plate) allPlates.add(r.values.plate);
|
||||
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
|
||||
}
|
||||
const plateMap = await buildPlateLookup(allPlates);
|
||||
|
||||
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const importedAt = new Date();
|
||||
|
||||
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
|
||||
const sql = `INSERT IGNORE INTO bi_ele_charge_record
|
||||
(order_no, station_no, station_name, terminal_name, region, city, district,
|
||||
operating_company, station_type, order_status, charge_form,
|
||||
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
|
||||
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
|
||||
matched_truck_id, matched_plate, vehicle_kind, raw_json,
|
||||
batch_id, imported_at)
|
||||
VALUES ?`;
|
||||
|
||||
const values = records.map(r => {
|
||||
const plate = r.values.plate || r.values.judgedPlate;
|
||||
const matchedId = plate ? plateMap.get(plate) || null : null;
|
||||
// 命中系统车辆=internal;其余(含车牌为空)一律 external
|
||||
const kind = matchedId ? 'internal' : 'external';
|
||||
return [
|
||||
r.orderNo,
|
||||
r.values.stationNo, r.values.stationName, r.values.terminalName,
|
||||
r.values.region, r.values.city, r.values.district,
|
||||
r.values.operatingCompany, r.values.stationType,
|
||||
r.values.orderStatus, r.values.chargeForm,
|
||||
r.values.startTime, r.values.endTime, r.values.duration,
|
||||
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
|
||||
r.values.plate, r.values.judgedPlate, r.values.vin,
|
||||
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
|
||||
matchedId, matchedId ? plate : null, kind,
|
||||
JSON.stringify(r.raw),
|
||||
batchId, importedAt,
|
||||
];
|
||||
});
|
||||
|
||||
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
|
||||
const inserted = result.affectedRows;
|
||||
const dbDuplicates = records.length - inserted;
|
||||
|
||||
// 统计内/外(无车牌也算外部)
|
||||
let internal = 0, external = 0;
|
||||
for (const r of records) {
|
||||
const plate = r.values.plate || r.values.judgedPlate;
|
||||
if (plate && plateMap.has(plate)) internal++;
|
||||
else external++;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
filename,
|
||||
batchId,
|
||||
parsed: parsed.length,
|
||||
fileDuplicates,
|
||||
inserted,
|
||||
dbDuplicates,
|
||||
breakdown: { internal, external },
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// GET /api/ele/list — 分页列表(最新优先)
|
||||
// =========================================================
|
||||
app.get('/list', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
const page = Math.max(1, Number(c.req.query('page')) || 1);
|
||||
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||
const kind = c.req.query('kind') || '';
|
||||
const batchId = c.req.query('batchId') || '';
|
||||
const search = c.req.query('search') || '';
|
||||
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (kind === 'internal' || kind === 'external') {
|
||||
where.push('vehicle_kind = ?');
|
||||
params.push(kind);
|
||||
}
|
||||
if (batchId) {
|
||||
where.push('batch_id = ?');
|
||||
params.push(batchId);
|
||||
}
|
||||
if (search) {
|
||||
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
|
||||
const q = `%${search}%`;
|
||||
params.push(q, q, q);
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, order_no, station_name, terminal_name, region, city,
|
||||
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
|
||||
plate, judged_plate, customer_name, vehicle_kind,
|
||||
batch_id, imported_at
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY start_time DESC, id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
);
|
||||
const [countRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
const total = Number(countRows[0]?.total || 0);
|
||||
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// GET /api/ele/batches — 批次列表
|
||||
// =========================================================
|
||||
app.get('/batches', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT batch_id,
|
||||
MIN(imported_at) AS imported_at,
|
||||
COUNT(*) AS records,
|
||||
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
|
||||
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
|
||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||
ROUND(SUM(fee), 2) AS total_fee
|
||||
FROM bi_ele_charge_record
|
||||
GROUP BY batch_id
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 50`,
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// GET /api/ele/aggregate — 聚合统计
|
||||
// =========================================================
|
||||
app.get('/aggregate', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
// 全量分类汇总
|
||||
const [overallRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT vehicle_kind,
|
||||
COUNT(*) AS records,
|
||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||
ROUND(SUM(fee), 2) AS total_fee
|
||||
FROM bi_ele_charge_record
|
||||
GROUP BY vehicle_kind`,
|
||||
);
|
||||
// 近 30 日按日
|
||||
const [dailyRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
vehicle_kind,
|
||||
COUNT(*) AS records,
|
||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||
ROUND(SUM(fee), 2) AS total_fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
|
||||
ORDER BY date DESC`,
|
||||
);
|
||||
return c.json({ overall: overallRows, daily: dailyRows });
|
||||
});
|
||||
|
||||
export default app;
|
||||
49
src/server/routes/ele/migration.ts
Normal file
49
src/server/routes/ele/migration.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import pool from '../../db.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
station_no VARCHAR(64) NULL,
|
||||
station_name VARCHAR(128) NULL,
|
||||
terminal_name VARCHAR(64) NULL,
|
||||
region VARCHAR(64) NULL,
|
||||
city VARCHAR(64) NULL,
|
||||
district VARCHAR(64) NULL,
|
||||
operating_company VARCHAR(128) NULL,
|
||||
station_type VARCHAR(32) NULL,
|
||||
order_status VARCHAR(32) NULL,
|
||||
charge_form VARCHAR(32) NULL,
|
||||
start_time DATETIME NULL,
|
||||
end_time DATETIME NULL,
|
||||
duration_min INT NULL,
|
||||
kwh DECIMAL(10,3) NULL,
|
||||
e_fee DECIMAL(10,2) NULL,
|
||||
service_fee DECIMAL(10,2) NULL,
|
||||
fee DECIMAL(10,2) NULL,
|
||||
plate VARCHAR(32) NULL,
|
||||
judged_plate VARCHAR(32) NULL,
|
||||
vin VARCHAR(64) NULL,
|
||||
customer_name VARCHAR(128) NULL,
|
||||
customer_phone VARCHAR(32) NULL,
|
||||
enterprise_name VARCHAR(128) NULL,
|
||||
matched_truck_id VARCHAR(32) NULL,
|
||||
matched_plate VARCHAR(32) NULL,
|
||||
vehicle_kind ENUM('internal','external','unknown') NOT NULL DEFAULT 'unknown',
|
||||
raw_json JSON NULL,
|
||||
batch_id VARCHAR(64) NOT NULL,
|
||||
imported_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uk_order_no (order_no),
|
||||
KEY idx_start_time (start_time),
|
||||
KEY idx_batch (batch_id),
|
||||
KEY idx_kind (vehicle_kind),
|
||||
KEY idx_plate (plate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
export async function ensureChargeRecordTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
ensured = true;
|
||||
}
|
||||
135
src/server/routes/energy/cache.ts
Normal file
135
src/server/routes/energy/cache.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||
*
|
||||
* 工作机制:
|
||||
* - 首次请求:阻塞等待 loader(cold start,3-4s 不可避免)
|
||||
* - 之后:每个 key 自调度刷新(TTL 到期前 5s),用户永远命中热缓存
|
||||
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
|
||||
* - 同一 key 并发请求只触发一次 loader
|
||||
* - force=true:手动强制刷新,绕过缓存(但仍参与 inflight 复用)
|
||||
*/
|
||||
|
||||
interface Entry<T> {
|
||||
value: T;
|
||||
freshAt: number;
|
||||
expiresAt: number;
|
||||
loader: () => Promise<T>;
|
||||
lastAccess: number;
|
||||
timer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const TTL_MS = 60 * 1000;
|
||||
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
|
||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
|
||||
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
|
||||
|
||||
const cache = new Map<string, Entry<unknown>>();
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
function scheduleRefresh<T>(key: string, entry: Entry<T>) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
|
||||
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
|
||||
entry.timer.unref?.();
|
||||
}
|
||||
|
||||
async function runRefresh(key: string) {
|
||||
const entry = cache.get(key) as Entry<unknown> | undefined;
|
||||
if (!entry) return;
|
||||
// 闲置超时:停止调度
|
||||
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
return;
|
||||
}
|
||||
if (inflight.has(key)) return;
|
||||
const p = entry.loader()
|
||||
.then(value => {
|
||||
const now = Date.now();
|
||||
const next: Entry<unknown> = {
|
||||
value,
|
||||
freshAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
loader: entry.loader,
|
||||
lastAccess: entry.lastAccess,
|
||||
};
|
||||
cache.set(key, next);
|
||||
scheduleRefresh(key, next);
|
||||
return value;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
|
||||
// 保留旧值,10s 后重试
|
||||
const retry: Entry<unknown> = { ...entry };
|
||||
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
|
||||
retry.timer.unref?.();
|
||||
cache.set(key, retry);
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
}
|
||||
|
||||
export interface CachedOpts {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key) as Entry<T> | undefined;
|
||||
if (hit) {
|
||||
hit.lastAccess = now;
|
||||
hit.loader = loader;
|
||||
}
|
||||
|
||||
// 强制刷新:等待 loader 完成
|
||||
if (opts.force) {
|
||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (ongoing) return ongoing;
|
||||
const p = loader()
|
||||
.then(value => {
|
||||
const t = Date.now();
|
||||
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||||
cache.set(key, next);
|
||||
scheduleRefresh(key, next);
|
||||
return value;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p as Promise<unknown>);
|
||||
return p;
|
||||
}
|
||||
|
||||
// 命中且未过期 → 立即返回
|
||||
if (hit && hit.expiresAt > now) {
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// 命中但过期 → 返回 stale,后台刷新
|
||||
if (hit) {
|
||||
if (!inflight.has(key)) void runRefresh(key);
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// 完全未命中 → 阻塞等待
|
||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (ongoing) return ongoing;
|
||||
|
||||
const p = loader()
|
||||
.then(value => {
|
||||
const t = Date.now();
|
||||
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||||
cache.set(key, entry);
|
||||
scheduleRefresh(key, entry);
|
||||
return value;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p as Promise<unknown>);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 仅用于测试或调试:清空所有缓存与定时器 */
|
||||
export function _clearEnergyCache() {
|
||||
for (const e of cache.values()) {
|
||||
if (e.timer) clearTimeout(e.timer);
|
||||
}
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
613
src/server/routes/energy/index.ts
Normal file
613
src/server/routes/energy/index.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import { cached } from './cache.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行;
|
||||
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
|
||||
app.use('*', async (c, next) => {
|
||||
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
|
||||
if (user && !canAccessEnergy(user.roles)) {
|
||||
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||
|
||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||
|
||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||
|
||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||||
function customerClause(field: string, customer: CustomerKind): string {
|
||||
if (customer === 'external') return `${field} IS NULL`;
|
||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||||
return '1=1';
|
||||
}
|
||||
|
||||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
|
||||
function rangeClause(localExpr: string, range: Range): string {
|
||||
switch (range) {
|
||||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||||
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||
function enumerateDates(range: Range): string[] {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
let start: Date;
|
||||
if (range === 'thisWeek') {
|
||||
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
||||
const day = today.getDay() || 7; // 周日 7
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - (day - 1));
|
||||
} else if (range === 'thisMonth') {
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
} else {
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 14);
|
||||
}
|
||||
const result: string[] = [];
|
||||
const cur = new Date(start);
|
||||
while (cur <= today) {
|
||||
result.push(fmt(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 氢能 总览:KPI + Top5 + 区域占比
|
||||
// =========================================================
|
||||
app.get('/hydrogen/overview', async (c) => {
|
||||
const yearParam = c.req.query('year');
|
||||
const force = c.req.query('force') === '1';
|
||||
const today = new Date();
|
||||
const todayYear = today.getFullYear();
|
||||
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
||||
|
||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
||||
ORDER BY y DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
);
|
||||
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
|
||||
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
|
||||
const isCurrentYear = year === todayYear;
|
||||
|
||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
[year, year, year, year, year, year, year,
|
||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||
HYDROGEN_MIN_DATE],
|
||||
);
|
||||
const k = kpiRows[0] ?? {};
|
||||
const yearFee = Number(k.yearFee) || 0;
|
||||
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
|
||||
const yearRevenue = Number(k.yearRevenue) || 0;
|
||||
const monthFee = Number(k.monthFee) || 0;
|
||||
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
|
||||
const monthRevenue = Number(k.monthRevenue) || 0;
|
||||
const todayFee = Number(k.todayFee) || 0;
|
||||
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
|
||||
const todayRevenue = Number(k.todayRevenue) || 0;
|
||||
const kpi = {
|
||||
yearKg: Number(k.yearKg) || 0,
|
||||
yearFee,
|
||||
yearRevenue,
|
||||
yearProfit: yearRevenue - yearCustomerCost,
|
||||
ourYearKg: Number(k.ourYearKg) || 0,
|
||||
ourYearFee: Number(k.ourYearFee) || 0,
|
||||
customerYearKg: Number(k.customerYearKg) || 0,
|
||||
monthKg: Number(k.monthKg) || 0,
|
||||
monthFee,
|
||||
monthRevenue,
|
||||
monthProfit: monthRevenue - monthCustomerCost,
|
||||
todayKg: Number(k.todayKg) || 0,
|
||||
todayFee,
|
||||
todayRevenue,
|
||||
todayProfit: todayRevenue - todayCustomerCost,
|
||||
lingniuBornKg: Number(k.lingniuBornKg) || 0,
|
||||
lingniuBornFee: Number(k.lingniuBornFee) || 0,
|
||||
};
|
||||
|
||||
// Top5 加氢站(指定年份)
|
||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.cost_expense) AS fee
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.hydrogen_station_id
|
||||
ORDER BY kg DESC
|
||||
LIMIT 5`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const top5KgSum = kpi.yearKg || 1;
|
||||
const top5 = top5Rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
name: r.name as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
share: (Number(r.kg) || 0) / top5KgSum,
|
||||
}));
|
||||
|
||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.hydrogen_station_id
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
|
||||
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
|
||||
const stations = stationFullRows.map(r => ({
|
||||
name: r.name as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
share: (Number(r.kg) || 0) / stationKgSum,
|
||||
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
|
||||
}));
|
||||
|
||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT region, SUM(kg) AS kg FROM (
|
||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
||||
b.hydrogen_quantity AS kg
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
) r
|
||||
GROUP BY region
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
|
||||
const TOP_REGIONS = 8;
|
||||
const top = regionRows.slice(0, TOP_REGIONS);
|
||||
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
|
||||
const regions = [
|
||||
...top.map(r => ({
|
||||
region: r.region as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
share: (Number(r.kg) || 0) / totalKg,
|
||||
})),
|
||||
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
|
||||
];
|
||||
|
||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
||||
ROUND(SUM(cost_expense), 2) AS fee,
|
||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(customer_expense), 2) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY m
|
||||
ORDER BY m`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
|
||||
for (const r of monthRows) {
|
||||
monthMap.set(r.m as string, {
|
||||
kg: Number(r.kg) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
customerCost: Number(r.customerCost) || 0,
|
||||
});
|
||||
}
|
||||
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
|
||||
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
|
||||
for (let mi = 1; mi <= lastMonth; mi++) {
|
||||
const key = `${year}-${String(mi).padStart(2, '0')}`;
|
||||
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
|
||||
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
|
||||
}
|
||||
|
||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
||||
ELSE 'customer' END AS payer,
|
||||
SUM(hydrogen_quantity) AS kg,
|
||||
SUM(cost_expense) AS cost,
|
||||
SUM(customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY name
|
||||
ORDER BY kg DESC
|
||||
LIMIT 30`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const customers = customerRows.map(r => ({
|
||||
name: r.name as string,
|
||||
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
|
||||
kg: Number(r.kg) || 0,
|
||||
cost: Number(r.cost) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
}));
|
||||
|
||||
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
||||
// =========================================================
|
||||
app.get('/hydrogen/daily', async (c) => {
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||
|
||||
const where = [
|
||||
'b.is_deleted = 0',
|
||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||
rangeClause(`b.hydrogen_time`, range),
|
||||
customerClause('b.truck_id', customer),
|
||||
].join(' AND ');
|
||||
|
||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||||
b.hydrogen_station_id AS stationId,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||
MAX(b.cost_price) AS pricePerKg
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE ${where}
|
||||
GROUP BY d, b.hydrogen_station_id
|
||||
ORDER BY d DESC, kg DESC`,
|
||||
);
|
||||
|
||||
// 站点环比:同站点上一条记录的 kg
|
||||
// 按 stationId 分组、按日期升序计算
|
||||
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
|
||||
const flat: StationRow[] = stationRows.map(r => ({
|
||||
date: r.d as string,
|
||||
stationId: Number(r.stationId),
|
||||
name: r.stationName as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
pricePerKg: Number(r.pricePerKg) || 0,
|
||||
}));
|
||||
|
||||
// 计算日级总量 + 日级环比
|
||||
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
|
||||
for (const s of flat) {
|
||||
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
|
||||
const e = dayMap.get(s.date)!;
|
||||
e.totalKg += s.kg;
|
||||
e.stations.push(s);
|
||||
}
|
||||
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
|
||||
const dayChainPct = new Map<string, number>();
|
||||
let prev = 0;
|
||||
for (const d of dates) {
|
||||
const cur = dayMap.get(d)!.totalKg;
|
||||
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
|
||||
prev = cur;
|
||||
}
|
||||
|
||||
// 站点级环比:按 stationId 分组按日期升序
|
||||
const stationPrev = new Map<number, number>();
|
||||
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
|
||||
// 需要按 stationId 分组排序
|
||||
const byStation = new Map<number, StationRow[]>();
|
||||
for (const s of flat) {
|
||||
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
|
||||
byStation.get(s.stationId)!.push(s);
|
||||
}
|
||||
for (const [, list] of byStation) {
|
||||
list.sort((a, b) => a.date.localeCompare(b.date));
|
||||
let p = 0;
|
||||
for (const r of list) {
|
||||
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
|
||||
p = r.kg;
|
||||
}
|
||||
}
|
||||
|
||||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||
const allDates = enumerateDates(range);
|
||||
const fullDays = allDates.map(date => {
|
||||
const info = dayMap.get(date);
|
||||
return {
|
||||
date,
|
||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||
chainPct: dayChainPct.get(date) ?? 0,
|
||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||
stations: info
|
||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||
name: s.name,
|
||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||||
kg: Math.round(s.kg * 100) / 100,
|
||||
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
});
|
||||
|
||||
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
||||
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
||||
let prevKg = 0;
|
||||
for (const d of ascDays) {
|
||||
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
||||
prevKg = d.totalKg;
|
||||
}
|
||||
|
||||
// 按日期降序返回
|
||||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||
return result;
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||||
// =========================================================
|
||||
app.get('/electric/overview', async (c) => {
|
||||
const force = c.req.query('force') === '1';
|
||||
const data = await cached('electric/overview', async () => {
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
SUM(kwh) AS totalKwh,
|
||||
SUM(fee) AS totalFee,
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN kwh ELSE 0 END) AS monthKwh,
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN fee ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
|
||||
FROM bi_ele_charge_record`,
|
||||
);
|
||||
const k = kpiRows[0] ?? {};
|
||||
const totalKwh = Number(k.totalKwh) || 0;
|
||||
const totalFee = Number(k.totalFee) || 0;
|
||||
const monthKwh = Number(k.monthKwh) || 0;
|
||||
const monthFee = Number(k.monthFee) || 0;
|
||||
const todayKwh = Number(k.todayKwh) || 0;
|
||||
const todayFee = Number(k.todayFee) || 0;
|
||||
|
||||
// 本月每日(用于柱图)
|
||||
const [trendRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`,
|
||||
);
|
||||
// 若本月无数据,降级展示最近一个有数据的自然月
|
||||
let trend = trendRows;
|
||||
if (trend.length === 0) {
|
||||
const [fallback] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
|
||||
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
|
||||
)
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`,
|
||||
);
|
||||
trend = fallback;
|
||||
}
|
||||
const trendArr = trend.map(r => ({
|
||||
date: r.date as string,
|
||||
kwh: Math.round((Number(r.kwh) || 0) * 100) / 100,
|
||||
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
|
||||
chainPct: 0,
|
||||
}));
|
||||
for (let i = 1; i < trendArr.length; i++) {
|
||||
const prev = trendArr[i - 1].kwh;
|
||||
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
|
||||
}
|
||||
|
||||
let todayChainPct = 0;
|
||||
if (todayKwh > 0) {
|
||||
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT SUM(kwh) AS kwh
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||||
);
|
||||
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||
trend: trendArr,
|
||||
};
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||||
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||||
// 缺失日期补零
|
||||
// =========================================================
|
||||
app.get('/electric/monthly', async (c) => {
|
||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||
|
||||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||
let kindClause = '1=1';
|
||||
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||||
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||||
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${kindClause}
|
||||
AND ${rangeClause('start_time', range)}
|
||||
GROUP BY date`,
|
||||
);
|
||||
|
||||
// 实际数据 map
|
||||
const dataMap = new Map<string, { kwh: number; fee: number }>();
|
||||
for (const r of rows) {
|
||||
dataMap.set(r.date as string, {
|
||||
kwh: Number(r.kwh) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 补零:枚举 range 全部日期
|
||||
const allDates = enumerateDates(range);
|
||||
const fullDays = allDates.map(date => {
|
||||
const d = dataMap.get(date);
|
||||
return {
|
||||
date,
|
||||
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
|
||||
fee: d ? Math.round(d.fee * 100) / 100 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
|
||||
const monthMap = new Map<string, typeof fullDays>();
|
||||
for (const d of fullDays) {
|
||||
const m = d.date.slice(0, 7);
|
||||
if (!monthMap.has(m)) monthMap.set(m, []);
|
||||
monthMap.get(m)!.push(d);
|
||||
}
|
||||
|
||||
const months = Array.from(monthMap.entries())
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([month, days]) => {
|
||||
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const chain = new Map<string, number>();
|
||||
let prev = 0;
|
||||
for (const d of asc) {
|
||||
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
|
||||
prev = d.kwh;
|
||||
}
|
||||
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
|
||||
const rowsWithChain = desc.map(d => ({
|
||||
date: d.date,
|
||||
kwh: d.kwh,
|
||||
fee: d.fee,
|
||||
chainPct: chain.get(d.date) ?? 0,
|
||||
}));
|
||||
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
|
||||
const feeSum = days.reduce((s, d) => s + d.fee, 0);
|
||||
return {
|
||||
month,
|
||||
kwh: Math.round(kwhSum * 100) / 100,
|
||||
fee: Math.round(feeSum * 100) / 100,
|
||||
rows: rowsWithChain,
|
||||
};
|
||||
});
|
||||
|
||||
return months;
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
export default app;
|
||||
190
src/server/routes/feedback/index.ts
Normal file
190
src/server/routes/feedback/index.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canManageFeedback } from '../../auth/types.js';
|
||||
import { uploadFeedbackImage } from './oss.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
||||
module VARCHAR(64) NULL,
|
||||
content TEXT NOT NULL,
|
||||
contact VARCHAR(200) NULL,
|
||||
screenshots JSON NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
user_name VARCHAR(128) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||
reply_content TEXT NULL,
|
||||
reply_user VARCHAR(128) NULL,
|
||||
reply_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
KEY idx_created_at (created_at),
|
||||
KEY idx_type (type),
|
||||
KEY idx_status (status),
|
||||
KEY idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
async function ensureTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
// 兼容旧表:补齐缺失列
|
||||
for (const alter of [
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||||
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||||
]) {
|
||||
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||||
}
|
||||
ensured = true;
|
||||
}
|
||||
|
||||
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||||
|
||||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||
|
||||
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
|
||||
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||||
|
||||
app.post('/submit', async (c) => {
|
||||
await ensureTable();
|
||||
const body = await c.req.json().catch(() => ({})) as {
|
||||
type?: string; module?: string | null; content?: string;
|
||||
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||||
};
|
||||
const type = (body.type || '').trim();
|
||||
const content = (body.content || '').trim();
|
||||
if (!VALID_TYPES.has(type)) {
|
||||
return c.json({ ok: false, message: '类型不合法' }, 400);
|
||||
}
|
||||
if (!content || content.length > 2000) {
|
||||
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
||||
}
|
||||
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||||
const contact = (body.contact || '').slice(0, 200) || null;
|
||||
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||||
const screenshots = Array.isArray(body.screenshots)
|
||||
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||||
: [];
|
||||
|
||||
const [r] = await pool.query<ResultSetHeader>(
|
||||
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
||||
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
||||
);
|
||||
return c.json({ ok: true, id: r.insertId });
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||||
// =========================================================
|
||||
app.post('/upload', async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||
}
|
||||
const mime = file.type || 'image/png';
|
||||
if (!ALLOWED_MIME.has(mime)) {
|
||||
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
|
||||
}
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
try {
|
||||
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
|
||||
return c.json({ ok: true, url });
|
||||
} catch (e) {
|
||||
console.error('feedback upload error:', e);
|
||||
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||
app.get('/mine', async (c) => {
|
||||
await ensureTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!user?.userId) return c.json({ items: [] });
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
[user.userId],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.get('/list', async (c) => {
|
||||
await ensureTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!canManageFeedback(user?.roles)) {
|
||||
return c.json({ ok: false, message: '无权限' }, 403);
|
||||
}
|
||||
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||||
const status = c.req.query('status') || '';
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (VALID_STATUS.has(status)) {
|
||||
where.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.patch('/:id', async (c) => {
|
||||
await ensureTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!canManageFeedback(user?.roles)) {
|
||||
return c.json({ ok: false, message: '无权限' }, 403);
|
||||
}
|
||||
const id = Number(c.req.param('id'));
|
||||
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||||
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||||
const fields: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (body.status) {
|
||||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||||
fields.push('status = ?');
|
||||
params.push(body.status);
|
||||
}
|
||||
if (typeof body.reply === 'string') {
|
||||
const reply = body.reply.trim().slice(0, 2000);
|
||||
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
params.push(reply || null, user?.userName || user?.userId || null);
|
||||
}
|
||||
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||||
params.push(id);
|
||||
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
38
src/server/routes/feedback/oss.ts
Normal file
38
src/server/routes/feedback/oss.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import OSS from 'ali-oss';
|
||||
|
||||
let client: OSS | null = null;
|
||||
|
||||
function getClient(): OSS {
|
||||
if (client) return client;
|
||||
const region = process.env.OSS_REGION || 'oss-cn-shanghai';
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID || '';
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || '';
|
||||
const bucket = process.env.OSS_BUCKET || '';
|
||||
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||
throw new Error('OSS 未配置:OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
|
||||
}
|
||||
client = new OSS({ region, accessKeyId, accessKeySecret, bucket, secure: true });
|
||||
return client;
|
||||
}
|
||||
|
||||
function safeExt(filename: string, fallback = 'png'): string {
|
||||
const m = /\.([a-zA-Z0-9]{1,8})$/.exec(filename);
|
||||
return m ? m[1].toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function randId(len = 8): string {
|
||||
return Math.random().toString(36).slice(2, 2 + len);
|
||||
}
|
||||
|
||||
/** 上传 buffer 到 OSS,返回公开访问的 URL */
|
||||
export async function uploadFeedbackImage(filename: string, buf: Buffer, mimetype: string): Promise<string> {
|
||||
const c = getClient();
|
||||
const baseDir = (process.env.OSS_BASE_DIR || '/dos').replace(/^\/+|\/+$/g, '');
|
||||
const ymd = new Date().toISOString().slice(0, 10);
|
||||
const key = `${baseDir}/feedback/${ymd}/${Date.now().toString(36)}-${randId()}.${safeExt(filename, mimetype.split('/')[1] || 'png')}`;
|
||||
await c.put(key, buf, {
|
||||
headers: { 'Content-Type': mimetype, 'x-oss-object-acl': 'public-read' },
|
||||
});
|
||||
const host = (process.env.OSS_HOST || `https://${process.env.OSS_BUCKET}.${process.env.OSS_ENDPOINT}/`).replace(/\/+$/, '/');
|
||||
return host + key;
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const regionMap: Record<string, string> = JSON.parse(
|
||||
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
|
||||
);
|
||||
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
|
||||
|
||||
let monitoringCache: MonitoringCache | null = null;
|
||||
|
||||
export function getCache(): MonitoringCache | null {
|
||||
@@ -38,7 +47,14 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
|
||||
.map(([prefix, count]) => ({ prefix, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
|
||||
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
|
||||
const regions = Array.from(regionSet).sort((a, b) => {
|
||||
const ai = REGION_ORDER.indexOf(a);
|
||||
const bi = REGION_ORDER.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
|
||||
}
|
||||
|
||||
interface MileageRow {
|
||||
@@ -49,10 +65,26 @@ interface MileageRow {
|
||||
source: string;
|
||||
}
|
||||
|
||||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||
// 用它兜底保证 totalKm 汇总完整。
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
||||
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.vehicle_total_mileage);
|
||||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mergeVehicles(
|
||||
mileageRows: MileageRow[],
|
||||
infoMap: Map<string, VehicleInfoRow>,
|
||||
yesterdayMap: Map<string, number>,
|
||||
bizTotalMap: Map<string, number>,
|
||||
): CachedVehicle[] {
|
||||
const mileageMap = new Map<string, MileageRow>();
|
||||
for (const row of mileageRows) {
|
||||
@@ -66,11 +98,13 @@ function mergeVehicles(
|
||||
const info = infoMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const source = m.source || 'NONE';
|
||||
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
|
||||
const bizTotal = bizTotalMap.get(m.plate);
|
||||
return {
|
||||
plate: m.plate,
|
||||
vin: m.vin,
|
||||
dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
totalKm: gpsTotal !== null ? gpsTotal : (bizTotal ?? null),
|
||||
source,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
isDataSynced: source !== 'NONE',
|
||||
@@ -81,6 +115,7 @@ function mergeVehicles(
|
||||
rentStatus: info?.rent_status || null,
|
||||
entity: info?.entity || null,
|
||||
project: info?.project || null,
|
||||
region: regionMap[m.plate] || null,
|
||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||
};
|
||||
});
|
||||
@@ -91,7 +126,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
console.log('[mileage] refreshing monitoring cache...');
|
||||
const start = Date.now();
|
||||
|
||||
const [mileageRows, yesterdayMap, infoMap, targetRows] = await Promise.all([
|
||||
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap] = await Promise.all([
|
||||
(async () => {
|
||||
const [dateRows] = await mileagePool.execute(
|
||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||||
@@ -124,6 +159,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||
WHERE t.is_deleted = 0`
|
||||
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
|
||||
fetchBizTotalMileageMap(),
|
||||
]);
|
||||
|
||||
const targetPlatesMap = new Map<string, Set<string>>();
|
||||
@@ -134,7 +170,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
}
|
||||
const targetNames = Array.from(targetPlatesMap.keys());
|
||||
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap);
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap);
|
||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||
|
||||
@@ -153,7 +189,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||
const [mileageRows, yesterdayRows, infoMap] = await Promise.all([
|
||||
const [mileageRows, yesterdayRows, infoMap, bizTotalMap] = await Promise.all([
|
||||
mileagePool.execute(
|
||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||
[dateStr]
|
||||
@@ -163,6 +199,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
||||
[dateStr]
|
||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchBizTotalMileageMap(),
|
||||
]);
|
||||
|
||||
const yesterdayMap = new Map<string, number>();
|
||||
@@ -172,7 +209,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||
}
|
||||
|
||||
return mergeVehicles(mileageRows, infoMap, yesterdayMap);
|
||||
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap);
|
||||
}
|
||||
|
||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { refreshMonitoringCache } from './cache.js';
|
||||
import monitoringRouter from './monitoring.js';
|
||||
import targetsRouter from './targets.js';
|
||||
import trendRouter from './trend.js';
|
||||
import vehicleRecentRouter from './vehicle-recent.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -10,6 +11,7 @@ app.route('/monitoring', monitoringRouter);
|
||||
app.route('/targets', targetsRouter);
|
||||
app.route('/target', targetsRouter);
|
||||
app.route('/trend', trendRouter);
|
||||
app.route('/vehicle', vehicleRecentRouter);
|
||||
|
||||
// 启动时立即刷新缓存,之后每分钟刷新
|
||||
refreshMonitoringCache();
|
||||
|
||||
@@ -9,7 +9,7 @@ const app = new Hono();
|
||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
vehicles: [],
|
||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
search: string; dept: string; customer: string; project: string;
|
||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||
targetName: string; mileageMin: string; mileageMax: string;
|
||||
targetName: string; region: string; mileageMin: string; mileageMax: string;
|
||||
}): CachedVehicle[] {
|
||||
let result = vehicles;
|
||||
|
||||
@@ -36,8 +36,12 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
if (params.project) result = result.filter(v => v.project === params.project);
|
||||
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||
if (params.plate) result = result.filter(v => v.plate === params.plate);
|
||||
if (params.plate) {
|
||||
const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean));
|
||||
if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate));
|
||||
}
|
||||
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
||||
if (params.region) result = result.filter(v => v.region === params.region);
|
||||
if (params.targetName) {
|
||||
const cache = getCache();
|
||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||
@@ -66,6 +70,7 @@ app.get('/', async (c) => {
|
||||
plate: c.req.query('plate') || '',
|
||||
platePrefix: c.req.query('platePrefix') || '',
|
||||
targetName: c.req.query('targetName') || '',
|
||||
region: c.req.query('region') || '',
|
||||
mileageMin: c.req.query('mileageMin') || '',
|
||||
mileageMax: c.req.query('mileageMax') || '',
|
||||
};
|
||||
@@ -95,6 +100,12 @@ app.get('/', async (c) => {
|
||||
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
||||
}
|
||||
|
||||
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
|
||||
if (filterParams.region) {
|
||||
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
|
||||
filters = buildDateFilters(regionScope);
|
||||
}
|
||||
|
||||
const filtered = applyFilters(allVehicles, filterParams);
|
||||
|
||||
const stats = {
|
||||
|
||||
138
src/server/routes/mileage/region-map.json
Normal file
138
src/server/routes/mileage/region-map.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"粤AGP2009": "华南区域",
|
||||
"粤AGP2011": "华南区域",
|
||||
"粤AGP2017": "华南区域",
|
||||
"粤AGP2032": "华南区域",
|
||||
"粤AGP2035": "华东区域",
|
||||
"粤AGP3027": "华东区域",
|
||||
"粤AGP3029": "华南区域",
|
||||
"粤AGP3071": "华南区域",
|
||||
"粤AGP3078": "华东区域",
|
||||
"粤AGP3079": "华东区域",
|
||||
"粤AGP3082": "西南区域",
|
||||
"粤AGP3087": "西南区域",
|
||||
"粤AGP3097": "华南区域",
|
||||
"粤AGP3486": "西北区域",
|
||||
"粤AGP3502": "华南区域",
|
||||
"粤AGP3503": "华东区域",
|
||||
"粤AGP3505": "西南区域",
|
||||
"粤AGP3506": "华东区域",
|
||||
"粤AGP3509": "西南区域",
|
||||
"粤AGP3513": "华东区域",
|
||||
"粤AGP3515": "华东区域",
|
||||
"粤AGP3605": "华东区域",
|
||||
"粤AGP3607": "华东区域",
|
||||
"粤AGP3609": "华东区域",
|
||||
"粤AGP3612": "华东区域",
|
||||
"粤AGP3615": "华南区域",
|
||||
"粤AGP3617": "西北区域",
|
||||
"粤AGP3625": "华东区域",
|
||||
"粤AGP3627": "华东区域",
|
||||
"粤AGP3631": "西南区域",
|
||||
"粤AGP3642": "华南区域",
|
||||
"粤AGP3645": "华南区域",
|
||||
"粤AGP3649": "华东区域",
|
||||
"粤AGP3651": "华东区域",
|
||||
"粤AGP3659": "华东区域",
|
||||
"粤AGP3660": "华南区域",
|
||||
"粤AGP3667": "华南区域",
|
||||
"粤AGP3672": "华南区域",
|
||||
"粤AGP3673": "西南区域",
|
||||
"粤AGP3690": "华东区域",
|
||||
"粤AGP3692": "华东区域",
|
||||
"粤AGP3695": "华东区域",
|
||||
"粤AGP4223": "华南区域",
|
||||
"粤AGP4318": "华东区域",
|
||||
"粤AGP4321": "华东区域",
|
||||
"粤AGP4325": "华南区域",
|
||||
"粤AGP4335": "华东区域",
|
||||
"粤AGP4355": "华东区域",
|
||||
"粤AGP4377": "华东区域",
|
||||
"粤AGP4386": "华东区域",
|
||||
"粤AGP4396": "西南区域",
|
||||
"粤AGP4422": "华南区域",
|
||||
"粤AGP4435": "华南区域",
|
||||
"粤AGP4451": "华南区域",
|
||||
"粤AGP4482": "华南区域",
|
||||
"粤AGP4486": "华南区域",
|
||||
"粤AGP4489": "华东区域",
|
||||
"粤AGP4502": "华南区域",
|
||||
"粤AGP4522": "华东区域",
|
||||
"粤AGP4538": "华南区域",
|
||||
"粤AGP4548": "华东区域",
|
||||
"粤AGP4566": "华南区域",
|
||||
"粤AGP4569": "华南区域",
|
||||
"粤AGP4583": "华东区域",
|
||||
"粤AGP4586": "华东区域",
|
||||
"粤AGP4587": "西南区域",
|
||||
"粤AGP4596": "华南区域",
|
||||
"粤AGP4597": "华东区域",
|
||||
"粤AGP4599": "华东区域",
|
||||
"粤AGP4623": "华东区域",
|
||||
"粤AGP4629": "华南区域",
|
||||
"粤AGP5165": "华东区域",
|
||||
"粤AGP5167": "华东区域",
|
||||
"粤AGP5169": "华南区域",
|
||||
"粤AGP5301": "华东区域",
|
||||
"粤AGP5350": "华南区域",
|
||||
"粤AGP5351": "华东区域",
|
||||
"粤AGP5357": "华东区域",
|
||||
"粤AGP5363": "华南区域",
|
||||
"粤AGP5379": "华东区域",
|
||||
"粤AGP5613": "华南区域",
|
||||
"粤AGP5615": "华东区域",
|
||||
"粤AGP5617": "华东区域",
|
||||
"粤AGP5621": "西南区域",
|
||||
"粤AGP5622": "华东区域",
|
||||
"粤AGP5623": "华南区域",
|
||||
"粤AGP5642": "华东区域",
|
||||
"粤AGP5643": "西北区域",
|
||||
"粤AGP5646": "华东区域",
|
||||
"粤AGP5651": "华东区域",
|
||||
"粤AGP5661": "华东区域",
|
||||
"粤AGP5681": "华南区域",
|
||||
"粤AGP5691": "华东区域",
|
||||
"粤AGP5710": "华东区域",
|
||||
"粤AGP5711": "西北区域",
|
||||
"粤AGP5712": "华东区域",
|
||||
"粤AGP5719": "华南区域",
|
||||
"粤AGP5749": "华东区域",
|
||||
"粤AGP5760": "华东区域",
|
||||
"粤AGP5763": "华东区域",
|
||||
"粤AGP5769": "华东区域",
|
||||
"粤AGP5770": "华东区域",
|
||||
"粤AGP5791": "西北区域",
|
||||
"粤AGP5792": "华南区域",
|
||||
"粤AGP5797": "华东区域",
|
||||
"粤AGP7016": "华南区域",
|
||||
"粤AGP7019": "西南区域",
|
||||
"粤AGP7022": "华南区域",
|
||||
"粤AGP7026": "华东区域",
|
||||
"粤AGP7047": "华东区域",
|
||||
"粤AGP9330": "华南区域",
|
||||
"粤AGP9346": "华东区域",
|
||||
"粤AGP9347": "华南区域",
|
||||
"粤AGP9350": "华东区域",
|
||||
"粤AGP9351": "华南区域",
|
||||
"粤AGP9702": "华东区域",
|
||||
"粤AGP9703": "西北区域",
|
||||
"粤AGP9706": "华东区域",
|
||||
"粤AGP9707": "华东区域",
|
||||
"粤AGP9713": "华东区域",
|
||||
"粤AGP9717": "华南区域",
|
||||
"粤AGP9721": "华东区域",
|
||||
"粤AGP9726": "华南区域",
|
||||
"粤AGP9731": "华南区域",
|
||||
"粤AGP9735": "华东区域",
|
||||
"粤AGP9739": "华南区域",
|
||||
"粤AGP9751": "华南区域",
|
||||
"粤AGP9753": "华东区域",
|
||||
"粤AGP9755": "华东区域",
|
||||
"粤AGP9759": "华东区域",
|
||||
"粤AGP9782": "华东区域",
|
||||
"粤AGP9790": "华南区域",
|
||||
"粤AGP9791": "华东区域",
|
||||
"粤AGP9817": "华南区域",
|
||||
"粤AGP9827": "华南区域",
|
||||
"粤AGP9836": "华南区域"
|
||||
}
|
||||
@@ -19,8 +19,10 @@ app.get('/', async (c) => {
|
||||
if (plates.length === 0) return c.json([]);
|
||||
}
|
||||
|
||||
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
|
||||
let sql = `
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
|
||||
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
|
||||
`;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CachedVehicle {
|
||||
rentStatus: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
yesterdayKm: number;
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export interface MonitoringFilters {
|
||||
rentStatuses: string[];
|
||||
platePrefixes: PlatePrefix[];
|
||||
targetNames: string[];
|
||||
regions: string[];
|
||||
}
|
||||
|
||||
/** 监控缓存 */
|
||||
|
||||
101
src/server/routes/mileage/vehicle-recent.ts
Normal file
101
src/server/routes/mileage/vehicle-recent.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Hono } from 'hono';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
interface DayRow {
|
||||
date: string;
|
||||
daily_km: string | number | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function parseYmd(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
const MAX_DAYS = 366;
|
||||
|
||||
app.get('/:plate/recent', async (c) => {
|
||||
const plate = c.req.param('plate');
|
||||
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 区间参数:优先 start/end;否则回退 days(兼容旧调用)
|
||||
const startQ = c.req.query('start');
|
||||
const endQ = c.req.query('end');
|
||||
let start: Date;
|
||||
let end: Date;
|
||||
if (startQ) {
|
||||
const ps = parseYmd(startQ);
|
||||
if (!ps) return c.json({ plate, days: [] }, 400);
|
||||
start = ps;
|
||||
end = endQ ? (parseYmd(endQ) ?? today) : today;
|
||||
} else {
|
||||
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), MAX_DAYS);
|
||||
end = today;
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - (days - 1));
|
||||
}
|
||||
if (start > end) [start, end] = [end, start];
|
||||
// 限制区间长度
|
||||
const span = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
if (span > MAX_DAYS) {
|
||||
start = new Date(end);
|
||||
start.setDate(end.getDate() - (MAX_DAYS - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
||||
ORDER BY stat_date`,
|
||||
[plate, fmt(start), fmt(end)]
|
||||
) as [DayRow[], unknown];
|
||||
|
||||
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
||||
const map = new Map<string, { dailyKm: number; source: string }>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const src = r.source || 'NONE';
|
||||
const existing = map.get(r.date);
|
||||
if (!existing || km > existing.dailyKm) {
|
||||
map.set(r.date, { dailyKm: km, source: src });
|
||||
}
|
||||
}
|
||||
|
||||
// 补全:从 start 到 end 每天一条
|
||||
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const key = fmt(cursor);
|
||||
const hit = map.get(key);
|
||||
result.push({
|
||||
date: key,
|
||||
dailyKm: hit?.dailyKm ?? 0,
|
||||
isDataSynced: !!hit && hit.source !== 'NONE',
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
|
||||
} catch (e: unknown) {
|
||||
console.error('vehicle recent error:', e);
|
||||
return c.json({ plate, days: [] }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
257
src/server/routes/scheduling/algorithm.ts
Normal file
257
src/server/routes/scheduling/algorithm.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type {
|
||||
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
|
||||
CandidateVehicle, VehicleClassification, SchedulingSummary,
|
||||
ReasonBlock,
|
||||
} from './types.js';
|
||||
|
||||
function fmtKmSimple(v: number): string {
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万';
|
||||
return Math.round(v).toLocaleString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Vehicle type compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isTypeCompatible(sourceType: string, candidateType: string): boolean {
|
||||
if (sourceType === candidateType) return true;
|
||||
// Cold-chain 4.5T can replace plain-cargo 4.5T
|
||||
if (candidateType === '4.5T冷链' && (sourceType === '4.5T冷链' || sourceType === '4.5T普货')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Vehicle classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function classifyVehicle(
|
||||
currentYearIsQualified: boolean,
|
||||
currentYearMileage: number,
|
||||
yearTarget: number,
|
||||
predictedYearEnd: number,
|
||||
): VehicleClassification {
|
||||
// qualified: current year mileage already >= target (actually done, not just predicted)
|
||||
const actualRate = yearTarget > 0 ? currentYearMileage / yearTarget : 0;
|
||||
if (currentYearIsQualified || actualRate >= 1.0) return 'qualified';
|
||||
// hopeless: even with remaining days, predicted < 60% of target
|
||||
if (yearTarget > 0 && predictedYearEnd / yearTarget < 0.6) return 'hopeless';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Helper – convert EnrichedVehicle to SchedulingVehicleInfo shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { SchedulingVehicleInfo } from './types.js';
|
||||
|
||||
export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
|
||||
// Use current year completion rate instead of overall
|
||||
const yearCompletionRate = v.yearTarget > 0 ? v.currentYearMileage / v.yearTarget : 0;
|
||||
return {
|
||||
plateNumber: v.plateNumber,
|
||||
targetId: v.targetId,
|
||||
targetName: v.targetName,
|
||||
vehicleType: v.vehicleType,
|
||||
totalMileage: v.totalMileage,
|
||||
currentYearMileage: v.currentYearMileage,
|
||||
completionRate: yearCompletionRate,
|
||||
yearTarget: v.yearTarget,
|
||||
region: v.region,
|
||||
province: v.province,
|
||||
customer: v.customer,
|
||||
department: v.department,
|
||||
manager: v.manager,
|
||||
customerAvgDaily: v.customerAvgDaily,
|
||||
customerAvgDaily7d: v.customerAvgDaily7d,
|
||||
predictedYearEnd: v.predictedYearEnd,
|
||||
daysLeft: v.daysLeft,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Main algorithm – generate scheduling suggestions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function generateSuggestions(
|
||||
vehicles: EnrichedVehicle[],
|
||||
inventoryVehicles: InventoryVehicle[],
|
||||
): { suggestions: SchedulingSuggestion[]; summary: SchedulingSummary } {
|
||||
const qualified = vehicles.filter((v) => v.classification === 'qualified');
|
||||
const hopeless = vehicles.filter((v) => v.classification === 'hopeless');
|
||||
|
||||
const suggestions: SchedulingSuggestion[] = [];
|
||||
|
||||
// --- rescue_hopeless (high priority) ---
|
||||
// Take the hopeless car away → give to high-mileage customer to sprint.
|
||||
// Replace with an inventory car that is CLOSE to qualifying — the low-mileage
|
||||
// customer's remaining driving days can push it over the finish line.
|
||||
//
|
||||
// Key insight: pick candidates where
|
||||
// candidate.totalMileage + customer.avgDaily × daysLeft >= yearTarget
|
||||
// i.e., the customer's daily driving is enough to finish the candidate's target.
|
||||
// Among those, prefer the one with the smallest gap (easiest to finish).
|
||||
// Exclude already-qualified (>= 100%) — no value in swapping those.
|
||||
for (const vehicle of hopeless) {
|
||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||
.filter((inv) => {
|
||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||
return true;
|
||||
})
|
||||
.map((inv) => {
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||
return {
|
||||
plateNumber: inv.plateNumber,
|
||||
targetId: inv.targetId,
|
||||
targetName: inv.targetName,
|
||||
vehicleType: inv.vehicleType,
|
||||
totalMileage: inv.totalMileage,
|
||||
completionRate: inv.completionRate,
|
||||
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
|
||||
daysLeft: inv.daysLeft,
|
||||
region: inv.region,
|
||||
province: inv.province,
|
||||
mileageGap,
|
||||
predictedAfterSwap,
|
||||
canQualifyAfterSwap,
|
||||
isSameRegion: inv.region === vehicle.region,
|
||||
notificationId: null,
|
||||
notificationStatus: null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 1. Same-region first (business rule: prefer same-region swaps)
|
||||
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
|
||||
// 2. Can-qualify next
|
||||
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
||||
return a.canQualifyAfterSwap ? -1 : 1;
|
||||
// 3. Smallest gap (closest to target)
|
||||
return a.mileageGap - b.mileageGap;
|
||||
})
|
||||
;
|
||||
|
||||
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
||||
const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
|
||||
const reason: ReasonBlock = {
|
||||
lines: [
|
||||
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||
{ label: '年度考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
|
||||
],
|
||||
conclusion: '预估无法达标,需替换',
|
||||
};
|
||||
|
||||
suggestions.push({
|
||||
id: `hopeless-${vehicle.plateNumber}`,
|
||||
priority: 'high',
|
||||
type: 'rescue_hopeless',
|
||||
currentVehicle: toVehicleInfo(vehicle),
|
||||
candidates,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
// --- replace_qualified (medium priority) ---
|
||||
// Every qualified vehicle gets a suggestion row so the list count matches
|
||||
// `qualifiedCount`. Candidates may be empty when no inventory vehicle can
|
||||
// reach target at this customer — the row still surfaces for manual review.
|
||||
for (const vehicle of qualified) {
|
||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||
.filter((inv) => {
|
||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||
return true;
|
||||
})
|
||||
.map((inv) => {
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||
return {
|
||||
plateNumber: inv.plateNumber,
|
||||
targetId: inv.targetId,
|
||||
targetName: inv.targetName,
|
||||
vehicleType: inv.vehicleType,
|
||||
totalMileage: inv.totalMileage,
|
||||
completionRate: inv.completionRate,
|
||||
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
|
||||
daysLeft: inv.daysLeft,
|
||||
region: inv.region,
|
||||
province: inv.province,
|
||||
mileageGap,
|
||||
predictedAfterSwap,
|
||||
canQualifyAfterSwap,
|
||||
isSameRegion: inv.region === vehicle.region,
|
||||
notificationId: null,
|
||||
notificationStatus: null,
|
||||
};
|
||||
})
|
||||
// Only keep candidates that can actually qualify at this customer —
|
||||
// swapping in a car that still can't reach target wastes the high-mileage customer
|
||||
.filter(c => c.canQualifyAfterSwap)
|
||||
.sort((a, b) => {
|
||||
// 1. Same-region first
|
||||
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
|
||||
// 2. Biggest gap first (most value from the swap)
|
||||
return b.mileageGap - a.mileageGap;
|
||||
})
|
||||
;
|
||||
|
||||
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
||||
const reason: ReasonBlock = {
|
||||
lines: [
|
||||
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||
{ label: '年度完成率', value: `${yearRate}%` },
|
||||
{ label: '年度考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||
{ label: '可为新车贡献', value: `约 ${fmtKmSimple(Math.round(canAddKm))} km` },
|
||||
],
|
||||
conclusion: '已达标,建议换上未达标车辆',
|
||||
};
|
||||
|
||||
suggestions.push({
|
||||
id: `qualified-${vehicle.plateNumber}`,
|
||||
priority: 'medium',
|
||||
type: 'replace_qualified',
|
||||
currentVehicle: toVehicleInfo(vehicle),
|
||||
candidates,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
// Drop rescue_hopeless with no candidates — no actionable rescue available.
|
||||
// Keep every replace_qualified so the list count matches the qualifiedCount card.
|
||||
const filteredSuggestions = suggestions.filter(
|
||||
(s) => s.type === 'replace_qualified' || s.candidates.length > 0,
|
||||
);
|
||||
|
||||
// Sort: high priority first
|
||||
filteredSuggestions.sort((a, b) => {
|
||||
if (a.priority === b.priority) return 0;
|
||||
return a.priority === 'high' ? -1 : 1;
|
||||
});
|
||||
|
||||
// estimatedGain uses strict definition: count suggestions that have at least
|
||||
// one candidate able to qualify after swap. The API layer recomputes this
|
||||
// post permission-filtering, so keep both sides consistent.
|
||||
const estimatedGain = filteredSuggestions.filter((s) =>
|
||||
s.candidates.some((c) => c.canQualifyAfterSwap),
|
||||
).length;
|
||||
|
||||
const summary: SchedulingSummary = {
|
||||
qualifiedCount: qualified.length,
|
||||
hopelessCount: hopeless.length,
|
||||
suggestionCount: filteredSuggestions.length,
|
||||
estimatedGain,
|
||||
recentInterventionCount: 0,
|
||||
};
|
||||
|
||||
return { suggestions: filteredSuggestions, summary };
|
||||
}
|
||||
34
src/server/routes/scheduling/db-schema.ts
Normal file
34
src/server/routes/scheduling/db-schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import pool from '../../db.js';
|
||||
|
||||
const CREATE_NOTIFICATIONS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
suggestion_id VARCHAR(128) NOT NULL,
|
||||
current_plate VARCHAR(32) NOT NULL,
|
||||
candidate_plate VARCHAR(32) NOT NULL,
|
||||
operator_id VARCHAR(64),
|
||||
operator_name VARCHAR(128),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'sent',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
executed_at DATETIME NULL,
|
||||
notes VARCHAR(500) NULL,
|
||||
before_mileage INT NULL,
|
||||
after_mileage INT NULL,
|
||||
INDEX idx_suggestion_id (suggestion_id),
|
||||
INDEX idx_current_plate (current_plate),
|
||||
INDEX idx_candidate_plate (candidate_plate),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
|
||||
`;
|
||||
|
||||
export async function ensureSchedulingTables(): Promise<void> {
|
||||
try {
|
||||
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
||||
console.log('[scheduling] notifications table ready');
|
||||
} catch (e) {
|
||||
console.error('[scheduling] failed to ensure tables:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
23
src/server/routes/scheduling/index.ts
Normal file
23
src/server/routes/scheduling/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Hono } from 'hono';
|
||||
import suggestionsRouter from './suggestions.js';
|
||||
import notifyRouter from './notify.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessScheduling } from '../../auth/types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Module-level access guard. When auth middleware is active, `user` is set and
|
||||
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
|
||||
// When auth is bypassed (dev), `user` is undefined and requests pass through.
|
||||
app.use('*', async (c, next) => {
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
if (user && !canAccessScheduling(user.roles)) {
|
||||
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.route('/suggestions', suggestionsRouter);
|
||||
app.route('/notify', notifyRouter);
|
||||
|
||||
export default app;
|
||||
281
src/server/routes/scheduling/notify.ts
Normal file
281
src/server/routes/scheduling/notify.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type {
|
||||
NotifyRequest,
|
||||
NotifyBatchRequest,
|
||||
NotifyBatchResult,
|
||||
NotificationRecord,
|
||||
NotificationStatus,
|
||||
UpdateNotificationRequest,
|
||||
} from './types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToRecord(row: any): NotificationRecord {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
suggestionId: row.suggestion_id,
|
||||
currentPlate: row.current_plate,
|
||||
candidatePlate: row.candidate_plate,
|
||||
operatorId: row.operator_id,
|
||||
operatorName: row.operator_name,
|
||||
status: row.status,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
|
||||
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
|
||||
notes: row.notes,
|
||||
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
|
||||
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Count non-cancelled interventions created within the last 7 days.
|
||||
*/
|
||||
export async function fetchRecentInterventionCount(): Promise<number> {
|
||||
const [rows] = (await pool.execute(
|
||||
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
|
||||
WHERE status != 'cancelled'
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
|
||||
)) as [any[], unknown];
|
||||
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
|
||||
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
|
||||
*/
|
||||
export async function fetchActiveNotificationMap(): Promise<
|
||||
Map<string, { id: number; status: NotificationStatus }>
|
||||
> {
|
||||
const [rows] = (await pool.execute(
|
||||
`SELECT id, suggestion_id, candidate_plate, status, created_at
|
||||
FROM tab_scheduling_notifications
|
||||
WHERE status != 'cancelled'
|
||||
ORDER BY created_at DESC`,
|
||||
)) as [any[], unknown];
|
||||
|
||||
const map = new Map<string, { id: number; status: NotificationStatus }>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.suggestion_id}::${row.candidate_plate}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { id: Number(row.id), status: row.status });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function insertNotification(
|
||||
req: NotifyRequest,
|
||||
operator: { id: string | null; name: string | null },
|
||||
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
|
||||
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
|
||||
// active intervention at a time. Any non-cancelled record for the same
|
||||
// suggestion_id blocks further interventions until it is cancelled.
|
||||
const [existing] = (await pool.execute(
|
||||
`SELECT id, candidate_plate FROM tab_scheduling_notifications
|
||||
WHERE suggestion_id = ? AND status != 'cancelled'
|
||||
LIMIT 1`,
|
||||
[req.suggestionId],
|
||||
)) as [any[], unknown];
|
||||
|
||||
if (existing.length > 0) {
|
||||
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
|
||||
}
|
||||
|
||||
const [result] = (await pool.execute(
|
||||
`INSERT INTO tab_scheduling_notifications
|
||||
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'sent')`,
|
||||
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name],
|
||||
)) as [any, unknown];
|
||||
|
||||
const insertedId = Number(result.insertId);
|
||||
const [rows] = (await pool.execute(
|
||||
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
|
||||
[insertedId],
|
||||
)) as [any[], unknown];
|
||||
|
||||
return rowToRecord(rows[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/scheduling/notify — single notify
|
||||
app.post('/', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<NotifyRequest>();
|
||||
const { suggestionId, currentPlate, candidatePlate } = body;
|
||||
|
||||
if (!suggestionId || !currentPlate || !candidatePlate) {
|
||||
return c.json({ success: false, message: '缺少必要参数' }, 400);
|
||||
}
|
||||
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
const operator = {
|
||||
id: user?.userId ?? null,
|
||||
name: user?.userName ?? null,
|
||||
};
|
||||
|
||||
const result = await insertNotification(body, operator);
|
||||
if ('skipped' in result) {
|
||||
return c.json(
|
||||
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
|
||||
);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `干预已登记:${currentPlate} → ${candidatePlate}`,
|
||||
record: result,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling notify error:', e);
|
||||
return c.json({ success: false, message: '登记干预失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/scheduling/notify/batch — bulk notify
|
||||
app.post('/batch', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<NotifyBatchRequest>();
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||
return c.json({ success: false, message: '缺少 items' }, 400);
|
||||
}
|
||||
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
const operator = {
|
||||
id: user?.userId ?? null,
|
||||
name: user?.userName ?? null,
|
||||
};
|
||||
|
||||
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
|
||||
for (const item of body.items) {
|
||||
if (!item.suggestionId || !item.currentPlate || !item.candidatePlate) {
|
||||
result.failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await insertNotification(item, operator);
|
||||
if ('skipped' in r) result.skipped++;
|
||||
else {
|
||||
result.success++;
|
||||
result.records.push(r);
|
||||
}
|
||||
} catch {
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
|
||||
);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
|
||||
result,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling batch notify error:', e);
|
||||
return c.json({ success: false, message: '批量干预失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/scheduling/notify — list all notifications (history)
|
||||
app.get('/', async (c) => {
|
||||
try {
|
||||
const status = c.req.query('status');
|
||||
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
|
||||
|
||||
const where: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
if (status) {
|
||||
where.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
params.push(limit);
|
||||
|
||||
const [rows] = (await pool.query(
|
||||
`SELECT * FROM tab_scheduling_notifications
|
||||
${whereSql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)) as [any[], unknown];
|
||||
|
||||
return c.json({ records: rows.map(rowToRecord) });
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling notifications list error:', e);
|
||||
return c.json({ records: [] }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
|
||||
app.patch('/:id', async (c) => {
|
||||
try {
|
||||
const id = Number(c.req.param('id'));
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return c.json({ success: false, message: 'id 无效' }, 400);
|
||||
}
|
||||
|
||||
const body = await c.req.json<UpdateNotificationRequest>();
|
||||
if (!body.status) {
|
||||
return c.json({ success: false, message: '缺少 status' }, 400);
|
||||
}
|
||||
|
||||
const validStatuses: NotificationStatus[] = ['sent', 'executed', 'cancelled'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
return c.json({ success: false, message: 'status 不合法' }, 400);
|
||||
}
|
||||
|
||||
const fields: string[] = ['status = ?'];
|
||||
const params: (string | number | null)[] = [body.status];
|
||||
if (body.status === 'executed') {
|
||||
fields.push('executed_at = CURRENT_TIMESTAMP');
|
||||
}
|
||||
if (body.notes !== undefined) {
|
||||
fields.push('notes = ?');
|
||||
params.push(body.notes);
|
||||
}
|
||||
if (body.afterMileage !== undefined) {
|
||||
fields.push('after_mileage = ?');
|
||||
params.push(body.afterMileage);
|
||||
}
|
||||
params.push(id);
|
||||
|
||||
await pool.execute(
|
||||
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
|
||||
params,
|
||||
);
|
||||
|
||||
const [rows] = (await pool.execute(
|
||||
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
|
||||
[id],
|
||||
)) as [any[], unknown];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return c.json({ success: false, message: '记录不存在' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true, record: rowToRecord(rows[0]) });
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling notification update error:', e);
|
||||
return c.json({ success: false, message: '更新失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
376
src/server/routes/scheduling/suggestions.ts
Normal file
376
src/server/routes/scheduling/suggestions.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||
import { mapRegion } from '../vehicles.js';
|
||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
|
||||
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: vehicle type classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Infer vehicle type from target name when truck table has no match.
|
||||
* e.g. "交投190辆4.5T冷链车" → "4.5T冷链", "羚牛100辆18T" → "18T"
|
||||
*/
|
||||
function inferTypeFromTargetName(targetName: string): string {
|
||||
const t = targetName || '';
|
||||
if (t.includes('冷链')) return '4.5T冷链';
|
||||
if (t.includes('普货') || (t.includes('4.5') && !t.includes('冷链'))) return '4.5T普货';
|
||||
if (t.includes('18T') || t.includes('18t')) return '18T';
|
||||
if (t.includes('49') || t.includes('牵引')) return '49T';
|
||||
if (t.includes('挂车')) return '挂车';
|
||||
return '其他';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
|
||||
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
|
||||
*/
|
||||
function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
||||
const t = (typeName || '').trim();
|
||||
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
|
||||
if (t.includes('4.5')) return '4.5T普货';
|
||||
if (t.includes('18')) return '18T';
|
||||
if (t.includes('49') || t.includes('牵引')) return '49T';
|
||||
if (t.includes('挂车')) return '挂车';
|
||||
return t || '其他';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', async (c) => {
|
||||
try {
|
||||
const targetIdParam = c.req.query('targetId');
|
||||
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
|
||||
|
||||
// ---- Query 1: Assessment targets ----
|
||||
const [targets] = await pool.execute(
|
||||
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
||||
) as [any[], unknown];
|
||||
|
||||
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
|
||||
for (const t of targets) {
|
||||
targetMap.set(t.id, {
|
||||
targetName: t.target_name,
|
||||
annualMileage: Number(t.annual_mileage_per_vehicle) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Query 2: Assessment vehicles ----
|
||||
const [assessmentRows] = await pool.execute(`
|
||||
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
|
||||
current_mileage, current_year_mileage, current_year_mileage_task,
|
||||
completion_rate, is_qualified, current_year_is_qualified,
|
||||
daily_required_mileage, current_year_assessment_end_date
|
||||
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
||||
`) as [any[], unknown];
|
||||
|
||||
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
||||
const vehicleInfoMap = await fetchVehicleInfoMap();
|
||||
|
||||
// ---- Query 4: Vehicle types from tab_truck ----
|
||||
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
|
||||
// but are still active in the assessment. We need their type info.
|
||||
const [truckTypeRows] = await pool.execute(`
|
||||
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
||||
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
||||
WHERE truck.is_operation = 1
|
||||
`) as [any[], unknown];
|
||||
|
||||
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
||||
for (const row of truckTypeRows) {
|
||||
truckTypeMap.set(row.plate_number, {
|
||||
typeName: row.type_name || '',
|
||||
modelRaw: row.model_raw || '',
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Query 5: Real-time location ----
|
||||
const [locationRows] = await pool.execute(`
|
||||
SELECT plate_number, province, city
|
||||
FROM tab_truck_remote_sync_realtime_info
|
||||
WHERE is_deleted = 0 AND plate_number IS NOT NULL
|
||||
`) as [any[], unknown];
|
||||
|
||||
const locationMap = new Map<string, { province: string; city: string }>();
|
||||
for (const row of locationRows) {
|
||||
locationMap.set(row.plate_number, {
|
||||
province: row.province || '',
|
||||
city: row.city || '',
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Collect all plates for Query 6 ----
|
||||
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
||||
|
||||
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
|
||||
const customerAvgDailyMap = new Map<string, number>();
|
||||
const customerAvgDaily7dMap = new Map<string, number>();
|
||||
if (allPlates.length > 0) {
|
||||
const placeholders = allPlates.map(() => '?').join(',');
|
||||
// Single query returning both windows per plate.
|
||||
const [dailyRows] = await mileagePool.execute(
|
||||
`SELECT plate,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND stat_date < CURDATE()
|
||||
AND plate IN (${placeholders})
|
||||
GROUP BY plate`,
|
||||
allPlates,
|
||||
) as [any[], unknown];
|
||||
|
||||
const plateAvg30Map = new Map<string, number>();
|
||||
const plateAvg7Map = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
|
||||
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
|
||||
}
|
||||
|
||||
const customerPlates30 = new Map<string, number[]>();
|
||||
const customerPlates7 = new Map<string, number[]>();
|
||||
for (const plate of allPlates) {
|
||||
const info = vehicleInfoMap.get(plate);
|
||||
const customer = info?.customer || '未知客户';
|
||||
if (!customerPlates30.has(customer)) customerPlates30.set(customer, []);
|
||||
if (!customerPlates7.has(customer)) customerPlates7.set(customer, []);
|
||||
const v30 = plateAvg30Map.get(plate);
|
||||
const v7 = plateAvg7Map.get(plate);
|
||||
if (v30 !== undefined) customerPlates30.get(customer)!.push(v30);
|
||||
if (v7 !== undefined) customerPlates7.get(customer)!.push(v7);
|
||||
}
|
||||
for (const [customer, avgs] of customerPlates30) {
|
||||
if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||
}
|
||||
for (const [customer, avgs] of customerPlates7) {
|
||||
if (avgs.length > 0) customerAvgDaily7dMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
||||
const [inventoryTruckRows] = await pool.execute(`
|
||||
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
||||
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
||||
AND truck.truck_rent_status = 0
|
||||
`) as [any[], unknown];
|
||||
|
||||
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
||||
const assessmentByPlate = new Map<string, any>();
|
||||
for (const row of assessmentRows) {
|
||||
assessmentByPlate.set(row.plate_number, row);
|
||||
}
|
||||
|
||||
// ---- Enrich assessment vehicles ----
|
||||
const now = new Date();
|
||||
const yearEnd = new Date(now.getFullYear(), 11, 31); // Dec 31
|
||||
|
||||
const enrichedVehicles: EnrichedVehicle[] = [];
|
||||
for (const row of assessmentRows) {
|
||||
const targetId = row.target_id as number;
|
||||
if (filterTargetId !== null && targetId !== filterTargetId) continue;
|
||||
|
||||
const target = targetMap.get(targetId);
|
||||
if (!target) continue;
|
||||
|
||||
const plate = row.plate_number as string;
|
||||
const info = vehicleInfoMap.get(plate);
|
||||
|
||||
// Only include vehicles that are actively rented/operated (租赁 or 自营)
|
||||
const rentStatus = info?.rent_status || '';
|
||||
if (rentStatus !== '租赁' && rentStatus !== '自营') continue;
|
||||
|
||||
const loc = locationMap.get(plate);
|
||||
const truckType = truckTypeMap.get(plate);
|
||||
|
||||
const province = loc?.province || '';
|
||||
const city = loc?.city || '';
|
||||
const region = mapRegion(province, city);
|
||||
|
||||
// Determine vehicle type: prefer truck table, fallback to target name
|
||||
let vehicleType = '其他';
|
||||
if (truckType) {
|
||||
vehicleType = classifyVehicleType(truckType.typeName, truckType.modelRaw);
|
||||
} else {
|
||||
// Fallback: infer from target name (e.g. "交投190辆4.5T冷链车" → "4.5T冷链")
|
||||
vehicleType = inferTypeFromTargetName(target.targetName);
|
||||
}
|
||||
|
||||
const endDate = row.current_year_assessment_end_date
|
||||
? new Date(row.current_year_assessment_end_date)
|
||||
: yearEnd;
|
||||
const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
||||
|
||||
const customer = info?.customer || null;
|
||||
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
|
||||
const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0;
|
||||
const currentYearMileage = Number(row.current_year_mileage) || 0;
|
||||
const yearTarget = Number(row.current_year_mileage_task) || 0;
|
||||
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
|
||||
|
||||
const currentYearIsQualified = row.current_year_is_qualified === 1;
|
||||
const classification = classifyVehicle(currentYearIsQualified, currentYearMileage, yearTarget, predictedYearEnd);
|
||||
|
||||
enrichedVehicles.push({
|
||||
plateNumber: plate,
|
||||
targetId,
|
||||
targetName: target.targetName,
|
||||
vehicleType,
|
||||
totalMileage: Number(row.vehicle_total_mileage) || 0,
|
||||
currentYearMileage,
|
||||
completionRate: Number(row.completion_rate) || 0,
|
||||
yearTarget,
|
||||
isQualified: row.is_qualified === 1,
|
||||
currentYearIsQualified,
|
||||
dailyRequiredMileage: Number(row.daily_required_mileage) || 0,
|
||||
region,
|
||||
province,
|
||||
customer,
|
||||
department: info?.department || null,
|
||||
manager: info?.manager || null,
|
||||
customerAvgDaily,
|
||||
customerAvgDaily7d,
|
||||
predictedYearEnd,
|
||||
daysLeft,
|
||||
classification,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Build inventory vehicles ----
|
||||
const inventoryVehicles: InventoryVehicle[] = [];
|
||||
for (const row of inventoryTruckRows) {
|
||||
const plate = row.plate_number as string;
|
||||
const loc = locationMap.get(plate);
|
||||
const province = loc?.province || '';
|
||||
const city = loc?.city || '';
|
||||
const region = mapRegion(province, city);
|
||||
const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || '');
|
||||
|
||||
// Cross-reference with assessment data
|
||||
const assessment = assessmentByPlate.get(plate);
|
||||
// Compute this vehicle's own daysLeft from its assessment end date
|
||||
let invDaysLeft = 0;
|
||||
if (assessment?.current_year_assessment_end_date) {
|
||||
const endDate = new Date(assessment.current_year_assessment_end_date);
|
||||
invDaysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
||||
} else {
|
||||
invDaysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||
}
|
||||
inventoryVehicles.push({
|
||||
plateNumber: plate,
|
||||
vehicleType,
|
||||
region,
|
||||
province,
|
||||
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
|
||||
daysLeft: invDaysLeft,
|
||||
targetId: assessment ? (assessment.target_id as number) : null,
|
||||
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
|
||||
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,
|
||||
completionRate: assessment ? Number(assessment.completion_rate) || 0 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Run algorithm ----
|
||||
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
||||
|
||||
// ---- Attach notification status to candidates ----
|
||||
const notificationMap = await fetchActiveNotificationMap();
|
||||
for (const s of suggestions) {
|
||||
for (const c of s.candidates) {
|
||||
const key = `${s.id}::${c.plateNumber}`;
|
||||
const notif = notificationMap.get(key);
|
||||
if (notif) {
|
||||
c.notificationId = notif.id;
|
||||
c.notificationStatus = notif.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Permission filtering & customer name masking ----
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
|
||||
// Attach department/manager info so filterByPermission can work
|
||||
const suggestionsWithPermFields = suggestions.map((s) => {
|
||||
const info = vehicleInfoMap.get(s.currentVehicle.plateNumber);
|
||||
return {
|
||||
...s,
|
||||
department: info?.department || null,
|
||||
departmentName: info?.department || null,
|
||||
managerId: info?.manager_id || null,
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = user
|
||||
? filterByPermission(suggestionsWithPermFields, user)
|
||||
: suggestionsWithPermFields;
|
||||
|
||||
// Mask customer names in suggestions
|
||||
const masked = maskCustomerNames(
|
||||
filtered.map((s) => {
|
||||
// Strip permission-filtering fields from response
|
||||
const { department, departmentName, managerId, ...rest } = s;
|
||||
return rest;
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- Build target options list for filter UI ----
|
||||
const targetVehicleCounts = new Map<number, number>();
|
||||
for (const v of enrichedVehicles) {
|
||||
targetVehicleCounts.set(v.targetId, (targetVehicleCounts.get(v.targetId) || 0) + 1);
|
||||
}
|
||||
|
||||
const targetOptions = targets.map((t: any) => ({
|
||||
id: t.id as number,
|
||||
name: t.target_name as string,
|
||||
vehicleCount: targetVehicleCounts.get(t.id) || 0,
|
||||
}));
|
||||
|
||||
// Recalculate summary based on permission-filtered results
|
||||
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
||||
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
||||
const recentInterventionCount = await fetchRecentInterventionCount();
|
||||
const filteredSummary: SchedulingSummary = {
|
||||
qualifiedCount: filteredQualified,
|
||||
hopelessCount: filteredHopeless,
|
||||
suggestionCount: masked.length,
|
||||
estimatedGain: masked.filter((s: any) =>
|
||||
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
||||
).length,
|
||||
recentInterventionCount,
|
||||
};
|
||||
|
||||
const response: SchedulingResponse = {
|
||||
summary: filteredSummary,
|
||||
suggestions: masked,
|
||||
targets: targetOptions,
|
||||
};
|
||||
|
||||
return c.json(response);
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling suggestions error:', e);
|
||||
return c.json(
|
||||
{
|
||||
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0, recentInterventionCount: 0 },
|
||||
suggestions: [],
|
||||
targets: [],
|
||||
} satisfies SchedulingResponse,
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
59
src/server/routes/scheduling/types.ts
Normal file
59
src/server/routes/scheduling/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type {
|
||||
SchedulingVehicleInfo,
|
||||
CandidateVehicle,
|
||||
SchedulingSuggestion,
|
||||
SchedulingSummary,
|
||||
SchedulingTargetOption,
|
||||
SchedulingResponse,
|
||||
NotifyRequest,
|
||||
NotifyBatchRequest,
|
||||
NotifyBatchResult,
|
||||
NotificationStatus,
|
||||
NotificationRecord,
|
||||
UpdateNotificationRequest,
|
||||
ReasonLine,
|
||||
ReasonBlock,
|
||||
} from '../../../shared/scheduling/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-only types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
|
||||
|
||||
export interface EnrichedVehicle {
|
||||
plateNumber: string;
|
||||
targetId: number;
|
||||
targetName: string;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
currentYearMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number;
|
||||
isQualified: boolean;
|
||||
currentYearIsQualified: boolean;
|
||||
dailyRequiredMileage: number;
|
||||
region: string;
|
||||
province: string;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
customerAvgDaily7d: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
classification: VehicleClassification;
|
||||
}
|
||||
|
||||
export interface InventoryVehicle {
|
||||
plateNumber: string;
|
||||
vehicleType: string;
|
||||
region: string;
|
||||
province: string;
|
||||
totalMileage: number;
|
||||
daysLeft: number;
|
||||
targetId: number | null;
|
||||
targetName: string | null;
|
||||
yearTarget: number | null;
|
||||
completionRate: number;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ WHERE truck.is_deleted = 0
|
||||
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||||
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
|
||||
|
||||
function mapRegion(province: string | null, city: string | null): string {
|
||||
export function mapRegion(province: string | null, city: string | null): string {
|
||||
if (!province && !city) return '其他';
|
||||
const loc = (city || province || '').trim();
|
||||
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';
|
||||
@@ -313,11 +313,21 @@ async function getVehicles(): Promise<Vehicle[]> {
|
||||
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
|
||||
const all = await getVehicles();
|
||||
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
|
||||
if (user) {
|
||||
const filtered = filterByPermission(all, user);
|
||||
return maskCustomerNames(filtered);
|
||||
}
|
||||
return maskCustomerNames(all);
|
||||
let list = user ? filterByPermission(all, user) : all;
|
||||
list = applySubjectFilter(c, list);
|
||||
return maskCustomerNames(list);
|
||||
}
|
||||
|
||||
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg)
|
||||
function getSubjectParam(c: Context): string | null {
|
||||
const raw = (c.req.query('subject') || '').trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
|
||||
function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
|
||||
const subject = getSubjectParam(c);
|
||||
if (!subject) return vehicles;
|
||||
return vehicles.filter((v) => (v.subjectOrg || '') === subject);
|
||||
}
|
||||
|
||||
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
|
||||
@@ -410,7 +420,7 @@ interface WeeklyStats {
|
||||
// 交车单 SQL
|
||||
const DELIVERED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
@@ -429,7 +439,7 @@ WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||||
// 还车单 SQL
|
||||
const RETURNED_SQL = `SELECT
|
||||
r.id, DATE(r.return_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_return r
|
||||
@@ -447,7 +457,7 @@ WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
||||
// 替换车单 SQL
|
||||
const REPLACED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
@@ -611,7 +621,7 @@ app.get('/by-batch', async (c) => {
|
||||
|
||||
// GET /api/vehicles/inventory-analysis — 库存分析,不设数据权限,对所有人开放
|
||||
app.get('/inventory-analysis', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const vehicles = applySubjectFilter(c, await getVehicles());
|
||||
|
||||
const typeFilters = [
|
||||
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
||||
@@ -680,16 +690,43 @@ app.get('/dept-stats', async (c) => {
|
||||
if (plate) todayMileageMap.set(plate, Number(row.day_mileage) || 0);
|
||||
}
|
||||
|
||||
// 不在部门列表展示的用户(非业务员或管理账号)
|
||||
const EXCLUDED_MANAGERS = new Set(['超级用户', '刘思宇', '潘舒', '黄卓华', '许铮杰']);
|
||||
|
||||
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
||||
for (const v of withManager) {
|
||||
const dept = v.departmentName || '公务车';
|
||||
const mgr = v.customerManager || '未分配';
|
||||
if (EXCLUDED_MANAGERS.has(mgr)) continue;
|
||||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||||
const mgrMap = deptMap.get(dept)!;
|
||||
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
|
||||
mgrMap.get(mgr)!.push(v);
|
||||
}
|
||||
|
||||
// 补齐:业务部门内所有在职用户,即使当前无车辆也需显示
|
||||
const deptNames = Array.from(deptMap.keys()).filter((d) => d !== '公务车');
|
||||
if (deptNames.length > 0) {
|
||||
const placeholders = deptNames.map(() => '?').join(',');
|
||||
const [userRows] = await pool.query<any[]>(
|
||||
`SELECT u.user_name, dep.dep_name
|
||||
FROM tab_user u
|
||||
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
|
||||
WHERE u.is_deleted = 0
|
||||
AND dep.dep_name IN (${placeholders})`,
|
||||
deptNames,
|
||||
);
|
||||
for (const r of userRows as any[]) {
|
||||
const dept = r.dep_name as string | null;
|
||||
const mgr = r.user_name as string | null;
|
||||
if (!dept || !mgr) continue;
|
||||
if (EXCLUDED_MANAGERS.has(mgr)) continue;
|
||||
const mgrMap = deptMap.get(dept);
|
||||
if (!mgrMap) continue;
|
||||
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute attendance & avg mileage from realtime data
|
||||
const getMileageStats = (vList: Vehicle[]) => {
|
||||
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
|
||||
@@ -756,11 +793,21 @@ app.get('/region-stats', async (c) => {
|
||||
cityMap.get(city)!.push(v);
|
||||
}
|
||||
|
||||
const getTypeBreakdown = (vList: Vehicle[]) =>
|
||||
['4.5T', '18T', '49T'].map((type) => {
|
||||
const tv = vList.filter((v) => v.type === type);
|
||||
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
|
||||
}).filter((t) => t.total > 0);
|
||||
const getTypeBreakdown = (vList: Vehicle[]) => {
|
||||
const KNOWN = ['4.5T', '18T', '49T'] as const;
|
||||
const make = (label: string, tv: Vehicle[]) => ({
|
||||
type: label,
|
||||
total: tv.length,
|
||||
operating: tv.filter((v) => v.status === 'Operating').length,
|
||||
inventory: tv.filter((v) => v.status === 'Inventory').length,
|
||||
pending: tv.filter((v) => v.status === 'Pending').length,
|
||||
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
});
|
||||
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
|
||||
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
|
||||
if (other.length > 0) known.push(make('其他', other));
|
||||
return known.filter((t) => t.total > 0);
|
||||
};
|
||||
|
||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||
const result = regionOrder
|
||||
@@ -843,6 +890,21 @@ app.get('/customer-stats', async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Location 过滤器:支持展示区域(嘉兴/广东/北京/新疆/其他)、库存区域(江浙沪/其它)、
|
||||
// 城市(嘉兴市)、宏观区域(华东/华南/...)。
|
||||
// '其他' 在两个体系里都存在(资产表的"库存-其他" vs 区域表的"其他"宏观区域),
|
||||
// 用 source 区分:source==='asset' 时按 v.location 匹配,其它情况按宏观区域匹配。
|
||||
function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
|
||||
if (isMacro) {
|
||||
return vehicles.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||
}
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
return vehicles.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||
}
|
||||
|
||||
// Vehicle type filter map (same logic as /by-type)
|
||||
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
||||
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
|
||||
@@ -888,15 +950,7 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.model === model);
|
||||
}
|
||||
if (location && location !== 'All') {
|
||||
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
if (macroRegions.includes(location) || location === '其他') {
|
||||
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||
} else {
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||
}
|
||||
filtered = filterByLocation(filtered, location, c.req.query('source'));
|
||||
}
|
||||
if (status && status !== 'All') {
|
||||
filtered = filtered.filter((v) => v.status === status);
|
||||
@@ -906,6 +960,8 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||
} else if (category === 'Operating') {
|
||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||
} else if (category === 'Pending') {
|
||||
filtered = filtered.filter((v) => v.status === 'Pending');
|
||||
}
|
||||
}
|
||||
if (manager) {
|
||||
@@ -951,7 +1007,7 @@ app.get('/list', async (c) => {
|
||||
|
||||
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
|
||||
app.get('/inventory-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const vehicles = applySubjectFilter(c, await getVehicles());
|
||||
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||
|
||||
const TYPE_NAME_MAP: Record<string, string> = {
|
||||
@@ -986,8 +1042,11 @@ app.get('/inventory-stats', async (c) => {
|
||||
});
|
||||
|
||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||
// Optional filters: model, batch, location, source — 按缓存车辆集合的 truck_id 交集过滤
|
||||
app.get('/weekly-detail', async (c) => {
|
||||
const type = c.req.query('type');
|
||||
const { model, batch, location } = c.req.query();
|
||||
const source = c.req.query('source');
|
||||
let sql: string;
|
||||
if (type === 'delivered') {
|
||||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||
@@ -996,20 +1055,60 @@ app.get('/weekly-detail', async (c) => {
|
||||
} else if (type === 'replaced') {
|
||||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||
} else if (type === 'pending') {
|
||||
sql = `SELECT truck.id AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||
} else if (type === 'new') {
|
||||
sql = `SELECT truck.id AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
||||
} else {
|
||||
return c.json([]);
|
||||
}
|
||||
const [rows] = await pool.query<any[]>(sql);
|
||||
const masked = (rows as any[]).map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
|
||||
let result = rows as any[];
|
||||
|
||||
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
|
||||
const hasModelFilter = model && model !== 'All';
|
||||
const hasBatchFilter = batch && batch !== 'All';
|
||||
const hasLocationFilter = location && location !== 'All';
|
||||
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
let pool2 = vehicles;
|
||||
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
|
||||
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
|
||||
if (hasLocationFilter) pool2 = filterByLocation(pool2, location, source);
|
||||
const truckSet = new Set(pool2.map((v) => String(v.id)));
|
||||
result = result.filter((r: any) => truckSet.has(String(r.truck_id)));
|
||||
}
|
||||
|
||||
const masked = result.map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
|
||||
return c.json(masked);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
|
||||
app.get('/subjects', async (c) => {
|
||||
const all = await getVehicles();
|
||||
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
|
||||
const visible = user ? filterByPermission(all, user) : all;
|
||||
|
||||
const map = new Map<string, { total: number; inventory: number; operating: number }>();
|
||||
for (const v of visible) {
|
||||
const name = (v.subjectOrg || '').trim();
|
||||
if (!name) continue;
|
||||
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
|
||||
const s = map.get(name)!;
|
||||
s.total += 1;
|
||||
if (v.status === 'Inventory' || v.status === 'Abnormal') s.inventory += 1;
|
||||
if (v.status === 'Operating') s.operating += 1;
|
||||
}
|
||||
|
||||
const result = Array.from(map.entries())
|
||||
.map(([name, stats]) => ({ name, ...stats }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/refresh — force cache refresh
|
||||
app.get('/refresh', async (c) => {
|
||||
lastFetchTime = 0;
|
||||
|
||||
36
src/shared/auth/roles.ts
Normal file
36
src/shared/auth/roles.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Role constants and role-based access helpers shared between server (JWT
|
||||
// issuance / API guards) and client (nav visibility / module gating).
|
||||
|
||||
/** 全量权限角色名 */
|
||||
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
|
||||
|
||||
/** 部门级权限角色名 */
|
||||
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
||||
|
||||
/** 智能调度模块访问角色 */
|
||||
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
||||
|
||||
/** 反馈管理(管理员)访问角色 */
|
||||
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
|
||||
|
||||
/** 能源管理模块访问角色 */
|
||||
export const ENERGY_ACCESS_ROLES = ['BI-LEADER-ENERGY'];
|
||||
|
||||
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
|
||||
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
|
||||
}
|
||||
|
||||
/** 用户是否可管理反馈。仅 BI-ADMIN-FEEDBACK 或全量权限角色可访问。 */
|
||||
export function canManageFeedback(roles: readonly string[] | null | undefined): boolean {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r));
|
||||
}
|
||||
|
||||
/** 用户是否可访问能源管理模块。仅 BI-LEADER-ENERGY 或「所有权限」可访问。 */
|
||||
const ENERGY_FULL_ACCESS = '所有权限';
|
||||
export function canAccessEnergy(roles: readonly string[] | null | undefined): boolean {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS);
|
||||
}
|
||||
123
src/shared/scheduling/types.ts
Normal file
123
src/shared/scheduling/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Shared scheduling types — used by both client (modules/scheduling) and server
|
||||
// (server/routes/scheduling). Keep server-only types (EnrichedVehicle etc.) in
|
||||
// server/routes/scheduling/types.ts.
|
||||
|
||||
export interface SchedulingVehicleInfo {
|
||||
plateNumber: string;
|
||||
targetId: number;
|
||||
targetName: string;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
currentYearMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number;
|
||||
region: string;
|
||||
province: string;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
customerAvgDaily7d: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
}
|
||||
|
||||
export type NotificationStatus = 'sent' | 'executed' | 'cancelled';
|
||||
|
||||
export interface CandidateVehicle {
|
||||
plateNumber: string;
|
||||
targetId: number | null;
|
||||
targetName: string | null;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number | null;
|
||||
daysLeft: number;
|
||||
region: string;
|
||||
province: string;
|
||||
mileageGap: number;
|
||||
predictedAfterSwap: number;
|
||||
canQualifyAfterSwap: boolean;
|
||||
isSameRegion: boolean;
|
||||
notificationId: number | null;
|
||||
notificationStatus: NotificationStatus | null;
|
||||
}
|
||||
|
||||
export interface NotificationRecord {
|
||||
id: number;
|
||||
suggestionId: string;
|
||||
currentPlate: string;
|
||||
candidatePlate: string;
|
||||
operatorId: string | null;
|
||||
operatorName: string | null;
|
||||
status: NotificationStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
executedAt: string | null;
|
||||
notes: string | null;
|
||||
beforeMileage: number | null;
|
||||
afterMileage: number | null;
|
||||
}
|
||||
|
||||
export interface NotifyBatchRequest {
|
||||
items: NotifyRequest[];
|
||||
}
|
||||
|
||||
export interface NotifyBatchResult {
|
||||
success: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
records: NotificationRecord[];
|
||||
}
|
||||
|
||||
export interface UpdateNotificationRequest {
|
||||
status: NotificationStatus;
|
||||
notes?: string;
|
||||
afterMileage?: number;
|
||||
}
|
||||
|
||||
export interface ReasonLine {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ReasonBlock {
|
||||
lines: ReasonLine[];
|
||||
conclusion: string;
|
||||
}
|
||||
|
||||
export interface SchedulingSuggestion {
|
||||
id: string;
|
||||
priority: 'high' | 'medium';
|
||||
type: 'replace_qualified' | 'rescue_hopeless';
|
||||
currentVehicle: SchedulingVehicleInfo;
|
||||
candidates: CandidateVehicle[];
|
||||
reason: ReasonBlock;
|
||||
}
|
||||
|
||||
export interface SchedulingSummary {
|
||||
qualifiedCount: number;
|
||||
hopelessCount: number;
|
||||
suggestionCount: number;
|
||||
estimatedGain: number;
|
||||
/** Count of interventions created within the last 7 days (excluding cancelled). */
|
||||
recentInterventionCount: number;
|
||||
}
|
||||
|
||||
export interface SchedulingTargetOption {
|
||||
id: number;
|
||||
name: string;
|
||||
vehicleCount: number;
|
||||
}
|
||||
|
||||
export interface SchedulingResponse {
|
||||
summary: SchedulingSummary;
|
||||
suggestions: SchedulingSuggestion[];
|
||||
targets: SchedulingTargetOption[];
|
||||
}
|
||||
|
||||
export interface NotifyRequest {
|
||||
suggestionId: string;
|
||||
currentPlate: string;
|
||||
candidatePlate: string;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user