feat: 羚牛 BI 报表服务初始版本
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
- Hono + TypeScript 后端,连接 MySQL 数据库 - React + Vite + Tailwind 前端 - 车辆资产实时汇总(按车型/品牌型号分组) - 本周交车/还车/替换统计(关联业务单据) - 车牌号详情弹窗 - Dockerfile + Woodpecker CI 流水线 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY src/server ./src/server
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV SERVER_PORT=3001
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>羚牛 BI 报表</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3516
package-lock.json
generated
Normal file
3516
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "ln-bi",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
|
"dev:server": "tsx watch src/server/index.ts",
|
||||||
|
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "node --import tsx src/server/index.ts",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"@hono/node-server": "^1.13.0",
|
||||||
|
"mysql2": "^3.11.0",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"concurrently": "^9.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
scripts/check-full-tags.ts
Normal file
40
scripts/check-full-tags.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
CONCAT(
|
||||||
|
IFNULL(dic_brand.dic_name,''),
|
||||||
|
'-',
|
||||||
|
IFNULL(dic_type.dic_name,''),
|
||||||
|
'-',
|
||||||
|
IFNULL(truck.color,''),
|
||||||
|
IF(dic_asc.dic_name='外租', IFNULL(truck.rent_from_company,''), '')
|
||||||
|
) AS tag,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
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_brand ON dic_brand.parent_code='dic_vehicle_brand' AND dic_brand.dic_code=truck.brand AND dic_brand.is_deleted=0
|
||||||
|
LEFT JOIN tab_dic dic_asc ON dic_asc.parent_code='dic_truck_ascription_status' AND dic_asc.dic_code=truck.ascription_status AND dic_asc.is_deleted=0
|
||||||
|
WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||||
|
GROUP BY tag
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const r of rows as any[]) {
|
||||||
|
console.log(`[${String(r.cnt).padStart(3)}] ${r.tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
main();
|
||||||
54
scripts/check-schema.ts
Normal file
54
scripts/check-schema.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check tab_truck columns for time-related fields
|
||||||
|
const [truckCols] = await pool.query(`
|
||||||
|
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA='lingniu_prod3' AND TABLE_NAME='tab_truck'
|
||||||
|
AND (COLUMN_NAME LIKE '%time%' OR COLUMN_NAME LIKE '%date%' OR COLUMN_NAME LIKE '%status%' OR COLUMN_NAME LIKE '%create%' OR COLUMN_NAME LIKE '%update%' OR COLUMN_NAME LIKE '%delete%' OR COLUMN_NAME LIKE '%operation%')
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
console.log('=== tab_truck time/status columns ===');
|
||||||
|
for (const c of truckCols as any[]) {
|
||||||
|
console.log(` ${c.COLUMN_NAME} (${c.DATA_TYPE}) — ${c.COLUMN_COMMENT || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for status change/history tables
|
||||||
|
const [tables] = await pool.query(`
|
||||||
|
SELECT TABLE_NAME, TABLE_COMMENT
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA='lingniu_prod3'
|
||||||
|
AND (TABLE_NAME LIKE '%log%' OR TABLE_NAME LIKE '%history%' OR TABLE_NAME LIKE '%change%' OR TABLE_NAME LIKE '%record%' OR TABLE_NAME LIKE '%status%')
|
||||||
|
ORDER BY TABLE_NAME
|
||||||
|
`);
|
||||||
|
console.log('\n=== Related history/log tables ===');
|
||||||
|
for (const t of tables as any[]) {
|
||||||
|
console.log(` ${t.TABLE_NAME} — ${t.TABLE_COMMENT || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tab_truck_status_info structure
|
||||||
|
const [statusCols] = await pool.query(`
|
||||||
|
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA='lingniu_prod3' AND TABLE_NAME='tab_truck_status_info'
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
console.log('\n=== tab_truck_status_info columns ===');
|
||||||
|
for (const c of statusCols as any[]) {
|
||||||
|
console.log(` ${c.COLUMN_NAME} (${c.DATA_TYPE}) — ${c.COLUMN_COMMENT || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
main();
|
||||||
66
scripts/check-status.ts
Normal file
66
scripts/check-status.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [statusRows] = await pool.query(`
|
||||||
|
SELECT dic_status.dic_name AS status_label, truck.truck_rent_status AS status_code, COUNT(*) AS cnt
|
||||||
|
FROM tab_truck truck
|
||||||
|
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
|
||||||
|
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
||||||
|
GROUP BY dic_status.dic_name, truck.truck_rent_status
|
||||||
|
`);
|
||||||
|
console.log('=== Rental Status ===');
|
||||||
|
console.log(JSON.stringify(statusRows, null, 2));
|
||||||
|
|
||||||
|
const [ownerRows] = await pool.query(`
|
||||||
|
SELECT dic.dic_name AS label, truck.ascription_status AS code, COUNT(*) AS cnt
|
||||||
|
FROM tab_truck truck
|
||||||
|
LEFT JOIN tab_dic dic
|
||||||
|
ON dic.parent_code = 'dic_truck_ascription_status'
|
||||||
|
AND dic.dic_code = truck.ascription_status
|
||||||
|
AND dic.is_deleted = 0
|
||||||
|
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
||||||
|
GROUP BY dic.dic_name, truck.ascription_status
|
||||||
|
`);
|
||||||
|
console.log('=== Ownership Status ===');
|
||||||
|
console.log(JSON.stringify(ownerRows, null, 2));
|
||||||
|
|
||||||
|
const [regionRows] = await pool.query(`
|
||||||
|
SELECT info.province, info.city, COUNT(*) AS cnt
|
||||||
|
FROM tab_truck truck
|
||||||
|
LEFT JOIN tab_truck_remote_sync_realtime_info info ON info.id = truck.id
|
||||||
|
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
||||||
|
GROUP BY info.province, info.city
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
console.log('=== Top Regions ===');
|
||||||
|
console.log(JSON.stringify(regionRows, null, 2));
|
||||||
|
|
||||||
|
const [modelRows] = await pool.query(`
|
||||||
|
SELECT dic_type.dic_name AS model_label, dic_brand.dic_name AS brand_label, COUNT(*) AS cnt
|
||||||
|
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_brand ON dic_brand.parent_code = 'dic_vehicle_brand' AND dic_brand.dic_code = truck.brand AND dic_brand.is_deleted = 0
|
||||||
|
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
||||||
|
GROUP BY dic_type.dic_name, dic_brand.dic_name
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
`);
|
||||||
|
console.log('=== Models ===');
|
||||||
|
console.log(JSON.stringify(modelRows, null, 2));
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
main();
|
||||||
42
scripts/check-tags.ts
Normal file
42
scripts/check-tags.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
CONCAT(
|
||||||
|
IFNULL(dic_brand.dic_name,''),
|
||||||
|
'-',
|
||||||
|
IFNULL(dic_type.dic_name,''),
|
||||||
|
'-',
|
||||||
|
IFNULL(truck.color,''),
|
||||||
|
IF(dic_asc.dic_name='外租', IFNULL(truck.rent_from_company,''), '')
|
||||||
|
) AS tag,
|
||||||
|
dic_brand.dic_name AS brand,
|
||||||
|
dic_type.dic_name AS model_label,
|
||||||
|
truck.color AS color,
|
||||||
|
dic_asc.dic_name AS ownership,
|
||||||
|
truck.rent_from_company AS rent_company,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
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_brand ON dic_brand.parent_code='dic_vehicle_brand' AND dic_brand.dic_code=truck.brand AND dic_brand.is_deleted=0
|
||||||
|
LEFT JOIN tab_dic dic_asc ON dic_asc.parent_code='dic_truck_ascription_status' AND dic_asc.dic_code=truck.ascription_status AND dic_asc.is_deleted=0
|
||||||
|
WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||||
|
GROUP BY tag, dic_brand.dic_name, dic_type.dic_name, truck.color, dic_asc.dic_name, truck.rent_from_company
|
||||||
|
ORDER BY dic_type.dic_name, cnt DESC
|
||||||
|
`);
|
||||||
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
main();
|
||||||
87
scripts/check-weekly.ts
Normal file
87
scripts/check-weekly.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check rent_status_check table structure
|
||||||
|
const [checkCols] = await pool.query(`
|
||||||
|
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA='lingniu_prod3' AND TABLE_NAME='tab_truck_rent_status_check'
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
console.log('=== tab_truck_rent_status_check columns ===');
|
||||||
|
for (const c of checkCols as any[]) {
|
||||||
|
console.log(` ${c.COLUMN_NAME} (${c.DATA_TYPE}) — ${c.COLUMN_COMMENT || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly stats: is_operation set to 1 this week (newly added to operation)
|
||||||
|
const [newOp] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS cnt FROM tab_truck
|
||||||
|
WHERE is_deleted=0 AND is_operation=1
|
||||||
|
AND create_time >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY)
|
||||||
|
`);
|
||||||
|
console.log('\n=== This week new (by create_time, since last Saturday) ===');
|
||||||
|
console.log(JSON.stringify(newOp));
|
||||||
|
|
||||||
|
// Pending delivery count (status=7)
|
||||||
|
const [pending] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS cnt FROM tab_truck
|
||||||
|
WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7
|
||||||
|
`);
|
||||||
|
console.log('\n=== Pending delivery (status=7) ===');
|
||||||
|
console.log(JSON.stringify(pending));
|
||||||
|
|
||||||
|
// Weekly deliveries (take_date this week)
|
||||||
|
const [delivered] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS cnt FROM tab_truck_status_info
|
||||||
|
WHERE is_deleted=0
|
||||||
|
AND take_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY)
|
||||||
|
AND take_date < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY), INTERVAL 7 DAY)
|
||||||
|
`);
|
||||||
|
console.log('\n=== This week delivered (by take_date) ===');
|
||||||
|
console.log(JSON.stringify(delivered));
|
||||||
|
|
||||||
|
// Weekly returns (return_date this week)
|
||||||
|
const [returned] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS cnt FROM tab_truck_status_info
|
||||||
|
WHERE is_deleted=0
|
||||||
|
AND return_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY)
|
||||||
|
AND return_date < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY), INTERVAL 7 DAY)
|
||||||
|
`);
|
||||||
|
console.log('\n=== This week returned (by return_date) ===');
|
||||||
|
console.log(JSON.stringify(returned));
|
||||||
|
|
||||||
|
// return_change_record values
|
||||||
|
const [rcValues] = await pool.query(`
|
||||||
|
SELECT return_change_record, COUNT(*) AS cnt
|
||||||
|
FROM tab_truck_status_info
|
||||||
|
WHERE is_deleted=0
|
||||||
|
GROUP BY return_change_record
|
||||||
|
`);
|
||||||
|
console.log('\n=== return_change_record values ===');
|
||||||
|
console.log(JSON.stringify(rcValues));
|
||||||
|
|
||||||
|
// Check aa_temp table
|
||||||
|
const [tempCols] = await pool.query(`
|
||||||
|
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA='lingniu_prod3' AND TABLE_NAME='tab_aa_temp_truck_rent_status'
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
console.log('\n=== tab_aa_temp_truck_rent_status columns ===');
|
||||||
|
for (const c of tempCols as any[]) {
|
||||||
|
console.log(` ${c.COLUMN_NAME} (${c.DATA_TYPE}) — ${c.COLUMN_COMMENT || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
main();
|
||||||
66
scripts/check-weekly2.ts
Normal file
66
scripts/check-weekly2.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
// return_change_record dic
|
||||||
|
const [rcDic] = await pool.query(`
|
||||||
|
SELECT dic_code, dic_name FROM tab_dic
|
||||||
|
WHERE parent_code LIKE '%change%' OR parent_code LIKE '%return%'
|
||||||
|
AND is_deleted=0
|
||||||
|
ORDER BY parent_code, dic_code
|
||||||
|
`);
|
||||||
|
console.log('=== return/change dic ===');
|
||||||
|
console.log(JSON.stringify(rcDic, null, 2));
|
||||||
|
|
||||||
|
// Weekly removed: is_operation changed to 0 this week, or is_deleted set to 1
|
||||||
|
const [removed] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS cnt FROM tab_truck
|
||||||
|
WHERE (is_deleted=1 OR is_operation=0)
|
||||||
|
AND update_time >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY)
|
||||||
|
`);
|
||||||
|
console.log('\n=== This week removed (is_operation=0 or deleted, by update_time) ===');
|
||||||
|
console.log(JSON.stringify(removed));
|
||||||
|
|
||||||
|
// Weekly new: is_operation set to 1 this week
|
||||||
|
const [newByUpdate] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS cnt FROM tab_truck
|
||||||
|
WHERE is_deleted=0 AND is_operation=1
|
||||||
|
AND buy_time >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY)
|
||||||
|
`);
|
||||||
|
console.log('\n=== This week new (by buy_time) ===');
|
||||||
|
console.log(JSON.stringify(newByUpdate));
|
||||||
|
|
||||||
|
// Replacements this week: return_change_record=3 means replacement?
|
||||||
|
const [replaced] = await pool.query(`
|
||||||
|
SELECT return_change_record, COUNT(*) AS cnt FROM tab_truck_status_info
|
||||||
|
WHERE is_deleted=0
|
||||||
|
AND return_change_record IN (2, 3)
|
||||||
|
AND update_time >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE())+2 DAY)
|
||||||
|
GROUP BY return_change_record
|
||||||
|
`);
|
||||||
|
console.log('\n=== This week replace records (rc=2,3 by update_time) ===');
|
||||||
|
console.log(JSON.stringify(replaced));
|
||||||
|
|
||||||
|
// Sample recent take_date
|
||||||
|
const [recentTake] = await pool.query(`
|
||||||
|
SELECT si.truck_id, t.plate_number, si.take_date, si.return_date, si.return_change_record
|
||||||
|
FROM tab_truck_status_info si
|
||||||
|
JOIN tab_truck t ON t.id = si.truck_id
|
||||||
|
WHERE si.is_deleted=0 AND si.take_date IS NOT NULL
|
||||||
|
ORDER BY si.take_date DESC LIMIT 5
|
||||||
|
`);
|
||||||
|
console.log('\n=== Recent deliveries ===');
|
||||||
|
console.log(JSON.stringify(recentTake, null, 2));
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
main();
|
||||||
772
src/App.tsx
Normal file
772
src/App.tsx
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Truck,
|
||||||
|
Warehouse,
|
||||||
|
Activity,
|
||||||
|
PlusCircle,
|
||||||
|
MinusCircle,
|
||||||
|
History,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import type { SummaryData, TypeSummary, VehicleListItem } from './types';
|
||||||
|
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail } from './api';
|
||||||
|
import type { WeeklyDetailItem } from './api';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft');
|
||||||
|
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set());
|
||||||
|
const [showPlateNumbers, setShowPlateNumbers] = useState<{
|
||||||
|
batch: string;
|
||||||
|
model: string;
|
||||||
|
location: string;
|
||||||
|
category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced';
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [summary, setSummary] = useState<SummaryData | null>(null);
|
||||||
|
const [processedData, setProcessedData] = useState<TypeSummary[]>([]);
|
||||||
|
const [modalVehicles, setModalVehicles] = useState<VehicleListItem[]>([]);
|
||||||
|
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<string>('');
|
||||||
|
const [modalLoading, setModalLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [s, byType] = await Promise.all([
|
||||||
|
fetchSummary(),
|
||||||
|
fetchByType(),
|
||||||
|
]);
|
||||||
|
setSummary(s);
|
||||||
|
setProcessedData(byType);
|
||||||
|
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// Load modal vehicles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPlateNumbers) {
|
||||||
|
setModalVehicles([]);
|
||||||
|
setModalWeeklyDetail([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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' };
|
||||||
|
if (cat && weeklyTypes[cat]) {
|
||||||
|
setModalVehicles([]);
|
||||||
|
fetchWeeklyDetail(weeklyTypes[cat])
|
||||||
|
.then(setModalWeeklyDetail)
|
||||||
|
.catch(() => setModalWeeklyDetail([]))
|
||||||
|
.finally(() => setModalLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal vehicle list
|
||||||
|
setModalWeeklyDetail([]);
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
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';
|
||||||
|
fetchVehicleList(params)
|
||||||
|
.then(setModalVehicles)
|
||||||
|
.catch(() => setModalVehicles([]))
|
||||||
|
.finally(() => setModalLoading(false));
|
||||||
|
}, [showPlateNumbers]);
|
||||||
|
|
||||||
|
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
|
||||||
|
|
||||||
|
const toggleAllAssetTypes = () => {
|
||||||
|
if (allTypesExpanded) {
|
||||||
|
setExpandedAssetTypes(new Set());
|
||||||
|
} else {
|
||||||
|
setExpandedAssetTypes(new Set(processedData.map((t) => t.type)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAssetType = (type: string) => {
|
||||||
|
const newSet = new Set(expandedAssetTypes);
|
||||||
|
if (newSet.has(type)) newSet.delete(type);
|
||||||
|
else newSet.add(type);
|
||||||
|
setExpandedAssetTypes(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleModel = (model: string) => {
|
||||||
|
const newSet = new Set(expandedModels);
|
||||||
|
if (newSet.has(model)) newSet.delete(model);
|
||||||
|
else newSet.add(model);
|
||||||
|
setExpandedModels(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (loading && !summary) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||||
|
<span className="text-sm text-gray-500">正在加载数据...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !summary) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="text-red-500 text-lg font-bold">加载失败</div>
|
||||||
|
<div className="text-sm text-gray-500">{error}</div>
|
||||||
|
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY = summary!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6">
|
||||||
|
{/* Main Title and Global Descriptions */}
|
||||||
|
<div className="mb-6 text-center relative">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">羚牛氢能车辆资产</h1>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-1 text-[11px] text-gray-500">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-500"></span>
|
||||||
|
最后更新: {lastUpdate}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-green-500"></span>
|
||||||
|
每分钟更新
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="animate-spin" size={10} />
|
||||||
|
刷新中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Switcher */}
|
||||||
|
<div className="absolute top-0 right-0 hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme('soft')}
|
||||||
|
className={`px-2 py-1 rounded-md transition-all ${theme === 'soft' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
柔和
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme('minimal')}
|
||||||
|
className={`px-2 py-1 rounded-md transition-all ${theme === 'minimal' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
简约
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme('vibrant')}
|
||||||
|
className={`px-2 py-1 rounded-md transition-all ${theme === 'vibrant' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
经典
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header Summary - Ultra Compact */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2 mb-6">
|
||||||
|
{/* Total Assets */}
|
||||||
|
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
|
||||||
|
<Truck size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
|
||||||
|
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operating */}
|
||||||
|
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<Activity size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||||
|
<span className="text-[8px] text-gray-400 leading-none">
|
||||||
|
自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
|
||||||
|
<Warehouse size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||||
|
<span className="text-[8px] text-gray-400 leading-none">
|
||||||
|
库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending */}
|
||||||
|
<div
|
||||||
|
className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending' })}
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<PlusCircle size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div>
|
||||||
|
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New */}
|
||||||
|
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-green-50 flex items-center justify-center text-green-500">
|
||||||
|
<PlusCircle size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-green-500 font-bold uppercase leading-none mb-0.5">本周新增</div>
|
||||||
|
<div className="text-base font-bold text-green-600 leading-none">{SUMMARY.weeklyNew}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Removed */}
|
||||||
|
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-red-50 flex items-center justify-center text-red-500">
|
||||||
|
<MinusCircle size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-red-500 font-bold uppercase leading-none mb-0.5">本周移除</div>
|
||||||
|
<div className="text-base font-bold text-red-600 leading-none">{SUMMARY.weeklyRemoved}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamics */}
|
||||||
|
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2 md:col-span-1">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight">本周动态</div>
|
||||||
|
<div className="text-[7px] text-gray-300 font-normal italic">上周六-本周五</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered' })}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
|
||||||
|
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5">交车</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[1px] h-3 bg-gray-100"></div>
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned' })}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
|
||||||
|
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5">还车</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[1px] h-3 bg-gray-100"></div>
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced' })}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
|
||||||
|
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5">替换</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Asset Summary Table with Dimension Switch */}
|
||||||
|
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
|
||||||
|
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||||
|
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||||
|
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
|
||||||
|
<Info size={10} />
|
||||||
|
点击车型展开品牌型号明细
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View Table */}
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-24">
|
||||||
|
<button onClick={toggleAllAssetTypes} className="flex items-center gap-1 hover:text-blue-600 transition-colors">
|
||||||
|
{allTypesExpanded ? <MinusCircle size={12} /> : <PlusCircle size={12} />}
|
||||||
|
<span>车型</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||||
|
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-xs">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<React.Fragment key={typeGroup.type}>
|
||||||
|
{/* Category Header Row */}
|
||||||
|
<tr
|
||||||
|
className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant'
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: theme === 'minimal'
|
||||||
|
? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50'
|
||||||
|
: 'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}
|
||||||
|
>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ? (
|
||||||
|
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} />
|
||||||
|
<span>{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className={`p-3 font-bold border-r border-gray-100 ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||||
|
<span>{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={`p-3 border-r border-gray-100 text-[11px] ${theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} italic`}>小计</td>
|
||||||
|
<td className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-gray-700'}`}>{typeGroup.totalAssets}</td>
|
||||||
|
<td className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-600'}`}>{typeGroup.totalInventory}</td>
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
|
||||||
|
<td key={reg} className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white/80' : 'text-blue-600'}`}>
|
||||||
|
{(typeGroup.inventoryRegions?.[reg] || 0) > 0 ? typeGroup.inventoryRegions[reg] : ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-gray-600'}`}>{typeGroup.pending || ''}</td>
|
||||||
|
<td className={`p-3 text-center border-r border-gray-100 font-bold bg-green-50/10 ${theme === 'vibrant' ? 'text-white' : 'text-green-600'}`}>{typeGroup.totalOperating}</td>
|
||||||
|
<td className={`p-3 text-center border-r border-gray-100 font-bold bg-blue-50/5 ${theme === 'vibrant' ? 'text-white/80' : 'text-blue-600'}`}>{typeGroup.weeklyDelivered || ''}</td>
|
||||||
|
<td className={`p-3 text-center border-r border-gray-100 font-bold bg-orange-50/5 ${theme === 'vibrant' ? 'text-white/80' : 'text-orange-600'}`}>{typeGroup.weeklyReturned || ''}</td>
|
||||||
|
<td className={`p-3 text-center font-bold bg-purple-50/5 ${theme === 'vibrant' ? 'text-white/80' : 'text-purple-600'}`}>{typeGroup.weeklyReplaced || ''}</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) &&
|
||||||
|
typeGroup.models.map((model) => (
|
||||||
|
<React.Fragment key={model.model}>
|
||||||
|
<motion.tr
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||||
|
<td className="p-3 border-r border-gray-100 flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? (
|
||||||
|
<ChevronDown size={14} className="text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} className="text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All' }); }}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>{model.total}</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.inventory > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory' }); }}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>{model.inventory}</button>
|
||||||
|
) : model.inventory}
|
||||||
|
</td>
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
|
||||||
|
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||||
|
{(model.inventoryRegions[reg] || 0) > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory' });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.pending > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending' });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.pending}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
model.pending
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||||
|
{model.operating}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||||
|
{model.weeklyDelivered > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered' });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyDelivered}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
model.weeklyDelivered
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||||
|
{model.weeklyReturned > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned' });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReturned}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
model.weeklyReturned
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||||
|
{model.weeklyReplaced > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced' });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReplaced}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
model.weeklyReplaced
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile View Cards for Asset Summary */}
|
||||||
|
<div className="lg:hidden p-4 space-y-4">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<div key={typeGroup.type} className="space-y-3">
|
||||||
|
<div
|
||||||
|
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant'
|
||||||
|
? 'bg-blue-600 text-white active:bg-blue-700'
|
||||||
|
: theme === 'minimal'
|
||||||
|
? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50'
|
||||||
|
: 'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ? (
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-bold">{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||||
|
<span>
|
||||||
|
资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
库存{' '}
|
||||||
|
<span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
运营{' '}
|
||||||
|
<span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="space-y-3 overflow-hidden"
|
||||||
|
>
|
||||||
|
{typeGroup.models.map((model) => (
|
||||||
|
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? (
|
||||||
|
<ChevronDown size={14} className="text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} className="text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All' }); }}
|
||||||
|
className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold active:bg-blue-100"
|
||||||
|
>
|
||||||
|
资产 {model.total}
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold">
|
||||||
|
运营 {model.operating}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedModels.has(model.model) && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
|
||||||
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory' })}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-gray-400">总库存</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-gray-400">待交车</span>
|
||||||
|
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
|
||||||
|
<div key={reg} className="text-center">
|
||||||
|
<div className="text-[8px] text-gray-400 mb-0.5">
|
||||||
|
{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}
|
||||||
|
</div>
|
||||||
|
{(model.inventoryRegions[reg] || 0) > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory' });
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] font-bold text-gray-300">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
|
||||||
|
<div
|
||||||
|
className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
|
||||||
|
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
|
||||||
|
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plate Number Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showPlateNumbers && (
|
||||||
|
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white rounded-lg shadow-xl w-full max-w-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-blue-600 text-white">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm">
|
||||||
|
{showPlateNumbers.batch === 'All' ? '全量' : showPlateNumbers.batch} - 车牌明细
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] opacity-80">
|
||||||
|
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |{' '}
|
||||||
|
{!showPlateNumbers.category
|
||||||
|
? '全部车辆'
|
||||||
|
: showPlateNumbers.category === 'Inventory'
|
||||||
|
? (showPlateNumbers.location === 'All' ? '库存' : `${showPlateNumbers.location}库存`)
|
||||||
|
: showPlateNumbers.category === 'Pending'
|
||||||
|
? '待交车'
|
||||||
|
: showPlateNumbers.category === 'Delivered'
|
||||||
|
? '本周已交车'
|
||||||
|
: showPlateNumbers.category === 'Returned'
|
||||||
|
? '已还车'
|
||||||
|
: '已替换'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/20 p-1 rounded">
|
||||||
|
<PlusCircle className="rotate-45" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 max-h-[400px] overflow-y-auto">
|
||||||
|
{modalLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={24} />
|
||||||
|
</div>
|
||||||
|
) : modalWeeklyDetail.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{modalWeeklyDetail.map((v, i) => (
|
||||||
|
<div key={`${v.truck_id}-${i}`} className="flex items-center justify-between bg-gray-50 px-3 py-2 rounded border border-gray-100">
|
||||||
|
<span className="font-mono text-[11px] sm:text-xs font-bold text-gray-700">{v.plate_number}</span>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-gray-400">
|
||||||
|
{v.customer_name && <span>{v.customer_name}</span>}
|
||||||
|
{v.handover_date && <span>{v.handover_date}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : modalVehicles.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{modalVehicles.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className="bg-gray-50 p-2 rounded border border-gray-100 text-center font-mono text-[11px] sm:text-xs font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
{v.plateNumber}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
||||||
|
<span className="text-[10px] text-gray-400">共 {modalWeeklyDetail.length > 0 ? modalWeeklyDetail.length : modalVehicles.length} 辆</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers(null)}
|
||||||
|
className="px-4 py-1.5 bg-white border border-gray-200 rounded text-xs font-medium hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer / Navigation */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden">
|
||||||
|
<button className="flex flex-col items-center text-blue-600">
|
||||||
|
<Activity size={20} />
|
||||||
|
<span className="text-[10px] mt-1">资产汇总</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center text-gray-400">
|
||||||
|
<Warehouse size={20} />
|
||||||
|
<span className="text-[10px] mt-1">库存分布</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center text-gray-400">
|
||||||
|
<History size={20} />
|
||||||
|
<span className="text-[10px] mt-1">运营记录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/api.ts
Normal file
49
src/api.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
SummaryData,
|
||||||
|
TypeSummary,
|
||||||
|
VehicleListItem,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const BASE = '/api/vehicles';
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSummary(): Promise<SummaryData> {
|
||||||
|
return fetchJson<SummaryData>(`${BASE}/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchByType(): Promise<TypeSummary[]> {
|
||||||
|
return fetchJson<TypeSummary[]>(`${BASE}/by-type`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVehicleList(params: {
|
||||||
|
batch?: string;
|
||||||
|
model?: string;
|
||||||
|
location?: string;
|
||||||
|
status?: string;
|
||||||
|
category?: 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);
|
||||||
|
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyDetailItem {
|
||||||
|
truck_id: number;
|
||||||
|
plate_number: string;
|
||||||
|
handover_date: string | null;
|
||||||
|
contract_type: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||||
|
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
||||||
|
}
|
||||||
1
src/index.css
Normal file
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
17
src/server/db.ts
Normal file
17
src/server/db.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
26
src/server/index.ts
Normal file
26
src/server/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import vehiclesRouter from './routes/vehicles.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.use('/api/*', cors());
|
||||||
|
app.route('/api/vehicles', vehiclesRouter);
|
||||||
|
|
||||||
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
// Serve static files in production
|
||||||
|
app.use('/*', serveStatic({ root: './dist' }));
|
||||||
|
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
||||||
|
|
||||||
|
const port = Number(process.env.SERVER_PORT) || 3001;
|
||||||
|
|
||||||
|
console.log(`Server starting on port ${port}...`);
|
||||||
|
serve({ fetch: app.fetch, port }, () => {
|
||||||
|
console.log(`Server running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
613
src/server/routes/vehicles.ts
Normal file
613
src/server/routes/vehicles.ts
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import pool from '../db.js';
|
||||||
|
import type {
|
||||||
|
VehicleRow,
|
||||||
|
Vehicle,
|
||||||
|
SummaryData,
|
||||||
|
TypeSummary,
|
||||||
|
ModelSummary,
|
||||||
|
BatchSummary,
|
||||||
|
BatchGroup,
|
||||||
|
InventoryTypeSummary,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const MAIN_SQL = `SELECT
|
||||||
|
truck.id AS id,
|
||||||
|
truck.plate_number AS 车牌号,
|
||||||
|
truck.vin AS vin,
|
||||||
|
truck.brand AS 车辆品牌,
|
||||||
|
truck.model AS 车辆型号,
|
||||||
|
truck.color AS 车辆颜色,
|
||||||
|
truck.rent_from_company AS 租赁公司,
|
||||||
|
dic_ascription_status.dic_name AS 车辆归属状态Label,
|
||||||
|
dic_type.dic_name AS 车辆型号Label,
|
||||||
|
truck.stock_area AS 库存区域,
|
||||||
|
truck.truck_rent_status AS 车辆租赁状态,
|
||||||
|
dic_status.dic_name AS 车辆租赁状态Label,
|
||||||
|
truck.is_operation AS 是否营运,
|
||||||
|
info.province AS 省,
|
||||||
|
info.city AS 市,
|
||||||
|
info.lat AS 纬度,
|
||||||
|
info.lng AS 经度,
|
||||||
|
dic_brand.dic_name AS 车辆品牌Label,
|
||||||
|
si.contract_id AS 合同ID,
|
||||||
|
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
|
||||||
|
cus.customer_name AS 客户名称,
|
||||||
|
org.org_name AS 合同归属公司,
|
||||||
|
dep.dep_name AS 合同归属部门,
|
||||||
|
org_truck.org_name AS 主体,
|
||||||
|
c.project_name AS 项目名称,
|
||||||
|
u.user_name AS 客户经理
|
||||||
|
FROM tab_truck truck
|
||||||
|
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
||||||
|
ON info.id = truck.id
|
||||||
|
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_dic dic_brand
|
||||||
|
ON dic_brand.parent_code = 'dic_vehicle_brand'
|
||||||
|
AND dic_brand.dic_code = truck.brand
|
||||||
|
AND dic_brand.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
|
||||||
|
ON org.id = c.org_id
|
||||||
|
AND org.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_dic dic_ascription_status
|
||||||
|
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
|
||||||
|
AND dic_ascription_status.dic_code = truck.ascription_status
|
||||||
|
AND dic_ascription_status.is_deleted = 0
|
||||||
|
LEFT JOIN tab_user u
|
||||||
|
ON u.id = c.bd
|
||||||
|
AND u.is_deleted = 0
|
||||||
|
LEFT JOIN tab_department dep
|
||||||
|
ON dep.id = u.dep_id
|
||||||
|
AND dep.is_deleted = 0
|
||||||
|
WHERE truck.is_deleted = 0
|
||||||
|
AND truck.is_operation = 1`;
|
||||||
|
|
||||||
|
// Region mapping: province/city -> display region
|
||||||
|
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||||||
|
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
|
||||||
|
|
||||||
|
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 '嘉兴';
|
||||||
|
if (loc.includes('广东') || loc.includes('广州') || loc.includes('深圳') || loc.includes('佛山') || loc.includes('东莞')) return '广东';
|
||||||
|
if (loc.includes('北京')) return '北京';
|
||||||
|
if (loc.includes('新疆') || loc.includes('乌鲁木齐')) return '新疆';
|
||||||
|
// Also check province
|
||||||
|
const prov = (province || '').trim();
|
||||||
|
if (prov.includes('浙江') || prov.includes('上海') || prov.includes('江苏')) return '嘉兴';
|
||||||
|
if (prov.includes('广东')) return '广东';
|
||||||
|
if (prov.includes('北京')) return '北京';
|
||||||
|
if (prov.includes('新疆')) return '新疆';
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapInventoryRegion(region: string): string {
|
||||||
|
if (region === '嘉兴') return '江浙沪';
|
||||||
|
if (region === '广东') return '广东';
|
||||||
|
if (region === '新疆') return '新疆';
|
||||||
|
return '其它';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map rental status to frontend status
|
||||||
|
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
|
||||||
|
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Abnormal' {
|
||||||
|
if (!rentStatus) return 'Inventory';
|
||||||
|
const s = rentStatus.trim();
|
||||||
|
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
|
||||||
|
if (s === '在库' || s === '待交车') return 'Inventory';
|
||||||
|
if (s === '异动') return 'Abnormal';
|
||||||
|
return 'Inventory';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map ownership status
|
||||||
|
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
|
||||||
|
function mapOwnership(ascriptionLabel: string | null): string {
|
||||||
|
if (!ascriptionLabel) return 'Self';
|
||||||
|
const s = ascriptionLabel.trim();
|
||||||
|
if (s === '自有') return 'Self';
|
||||||
|
if (s === '外租') return 'Leased';
|
||||||
|
if (s === '挂靠') return 'Hanging';
|
||||||
|
return 'Self';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive vehicle type category from model label
|
||||||
|
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
|
||||||
|
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
|
||||||
|
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
|
||||||
|
const label = (modelLabel || '').trim();
|
||||||
|
if (label.includes('4.5吨')) return '4.5T';
|
||||||
|
if (label.includes('18吨')) return '18T';
|
||||||
|
if (label.includes('49吨')) return '49T';
|
||||||
|
if (label.includes('35吨')) return '35T';
|
||||||
|
if (label.includes('叉车')) return '叉车';
|
||||||
|
if (label.includes('半挂车')) return '挂车';
|
||||||
|
return '其他车型';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag → alias mapping with sort order
|
||||||
|
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
|
||||||
|
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
|
||||||
|
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
||||||
|
// 4.5T 普货
|
||||||
|
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
|
||||||
|
'现代-4.5吨货车-白': { alias: '现代4.5T普货(恒运)', order: 102 },
|
||||||
|
// 4.5T 冷链
|
||||||
|
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
|
||||||
|
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
|
||||||
|
'跃进-4.5吨冷链车-白/绿/灰': { alias: '跃进4.5T冷链', order: 203 },
|
||||||
|
// 18T
|
||||||
|
'飞驰-18吨厢式货车-红': { alias: '飞驰18T(红车)', order: 301 },
|
||||||
|
'飞驰-18吨厢式货车-白/绿': { alias: '飞驰18T(白车)', order: 302 },
|
||||||
|
'楚风-18吨厢式货车-白': { alias: '楚风18T厢货', order: 303 },
|
||||||
|
'苏龙-18吨双飞翼货车-白': { alias: '苏龙18T飞翼', order: 304 },
|
||||||
|
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
|
||||||
|
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
|
||||||
|
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
||||||
|
// 49T
|
||||||
|
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
|
||||||
|
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
|
||||||
|
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
|
||||||
|
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
|
||||||
|
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
|
||||||
|
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
|
||||||
|
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
|
||||||
|
// 其他
|
||||||
|
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
|
||||||
|
'其他-氢能叉车-蓝白绿': { alias: '氢能叉车', order: 502 },
|
||||||
|
'通华-重型集装箱半挂车-红色浙江锦昌仓储有限公司': { alias: '挂车', order: 503 },
|
||||||
|
'通华-重型集装箱半挂车-红色嘉兴市鼎义物流有限公司': { alias: '挂车', order: 503 },
|
||||||
|
'通华-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
|
||||||
|
'大通-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
|
||||||
|
'明威-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
|
||||||
|
'明威-重型集装箱半挂车-红': { alias: '挂车', order: 503 },
|
||||||
|
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
|
||||||
|
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
|
||||||
|
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function deriveModelTag(
|
||||||
|
brandLabel: string | null,
|
||||||
|
modelLabel: string | null,
|
||||||
|
color: string | null,
|
||||||
|
ownershipLabel: string | null,
|
||||||
|
rentCompany: string | null,
|
||||||
|
): string {
|
||||||
|
const brand = (brandLabel || '').trim();
|
||||||
|
const model = (modelLabel || '').trim();
|
||||||
|
const c = (color || '').trim();
|
||||||
|
const isRented = ownershipLabel?.trim() === '外租';
|
||||||
|
const company = isRented ? (rentCompany || '').trim() : '';
|
||||||
|
|
||||||
|
if (!brand && !model) return '未知车型';
|
||||||
|
const tag = `${brand}-${model}-${c}${company}`;
|
||||||
|
const mapped = MODEL_ALIAS_MAP[tag];
|
||||||
|
return mapped ? mapped.alias : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelOrder(model: string): number {
|
||||||
|
// Find the order from alias mapping
|
||||||
|
for (const entry of Object.values(MODEL_ALIAS_MAP)) {
|
||||||
|
if (entry.alias === model) return entry.order;
|
||||||
|
}
|
||||||
|
return 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformRow(row: VehicleRow): Vehicle {
|
||||||
|
const region = mapRegion(row.省, row.市);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
plateNumber: row.车牌号 || '',
|
||||||
|
vin: row.vin || '',
|
||||||
|
type: deriveType(row.车辆型号Label, row.车辆品牌Label),
|
||||||
|
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
|
||||||
|
color: row.车辆颜色 || '',
|
||||||
|
location: region,
|
||||||
|
region,
|
||||||
|
status: mapStatus(row.车辆租赁状态Label),
|
||||||
|
ownership: mapOwnership(row.车辆归属状态Label),
|
||||||
|
rentCompany: row.租赁公司 || '',
|
||||||
|
contractNo: row.合同编码,
|
||||||
|
customerName: row.客户名称,
|
||||||
|
orgName: row.合同归属公司,
|
||||||
|
departmentName: row.合同归属部门,
|
||||||
|
subjectOrg: row.主体,
|
||||||
|
projectName: row.项目名称,
|
||||||
|
customerManager: row.客户经理,
|
||||||
|
brandLabel: row.车辆品牌Label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for vehicles data (refresh every 5 minutes)
|
||||||
|
let cachedVehicles: Vehicle[] = [];
|
||||||
|
let lastFetchTime = 0;
|
||||||
|
const CACHE_TTL = 60 * 1000;
|
||||||
|
|
||||||
|
async function getVehicles(): Promise<Vehicle[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedVehicles.length > 0 && now - lastFetchTime < CACHE_TTL) {
|
||||||
|
return cachedVehicles;
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query<any[]>(MAIN_SQL);
|
||||||
|
cachedVehicles = (rows as VehicleRow[]).map(transformRow);
|
||||||
|
lastFetchTime = now;
|
||||||
|
return cachedVehicles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
|
||||||
|
return regions.reduce((acc, reg) => {
|
||||||
|
acc[reg] = vehicles.filter((v) => v.location === reg).length;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStats(list: Vehicle[]) {
|
||||||
|
return {
|
||||||
|
total: list.length,
|
||||||
|
inventory: list.filter((v) => v.status === 'Inventory').length,
|
||||||
|
inventoryRegions: getRegionCounts(
|
||||||
|
list.filter((v) => v.status === 'Inventory'),
|
||||||
|
REGIONS,
|
||||||
|
),
|
||||||
|
pending: 0,
|
||||||
|
operating: list.filter((v) => v.status === 'Operating').length,
|
||||||
|
weeklyDelivered: 0,
|
||||||
|
weeklyReturned: 0,
|
||||||
|
weeklyReplaced: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Week range: last Saturday 00:00 to this Friday 23:59
|
||||||
|
// MySQL: WEEKDAY() returns 0=Monday..6=Sunday, Saturday=5
|
||||||
|
// "上周六-本周五": offset from today to last Saturday
|
||||||
|
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
|
||||||
|
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
|
||||||
|
|
||||||
|
interface WeeklyStats {
|
||||||
|
pendingDelivery: number;
|
||||||
|
weeklyNew: number;
|
||||||
|
weeklyRemoved: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交车单 SQL
|
||||||
|
const DELIVERED_SQL = `SELECT
|
||||||
|
take.id, DATE(take.handover_date) AS handover_date,
|
||||||
|
truck.id AS truck_id, truck.plate_number,
|
||||||
|
dic_contract_type.dic_name AS contract_type,
|
||||||
|
customer.customer_name
|
||||||
|
FROM tab_truck_rent_take take
|
||||||
|
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||||
|
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||||
|
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||||
|
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||||
|
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||||
|
LEFT JOIN tab_dic dic_contract_type
|
||||||
|
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||||
|
AND dic_contract_type.dic_code = contract.contract_type
|
||||||
|
AND dic_contract_type.is_deleted = 0
|
||||||
|
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||||||
|
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time 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,
|
||||||
|
dic_contract_type.dic_name AS contract_type,
|
||||||
|
customer.customer_name
|
||||||
|
FROM tab_truck_rent_return r
|
||||||
|
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||||||
|
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||||
|
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||||
|
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||||
|
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||||
|
LEFT JOIN tab_dic dic_contract_type
|
||||||
|
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||||
|
AND dic_contract_type.dic_code = contract.contract_type
|
||||||
|
AND dic_contract_type.is_deleted = 0
|
||||||
|
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,
|
||||||
|
dic_contract_type.dic_name AS contract_type,
|
||||||
|
customer.customer_name
|
||||||
|
FROM tab_truck_rent_take take
|
||||||
|
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||||
|
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||||
|
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||||
|
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||||
|
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||||
|
LEFT JOIN tab_dic dic_contract_type
|
||||||
|
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||||
|
AND dic_contract_type.dic_code = contract.contract_type
|
||||||
|
AND dic_contract_type.is_deleted = 0
|
||||||
|
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||||||
|
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
||||||
|
|
||||||
|
let cachedWeeklyStats: WeeklyStats | null = null;
|
||||||
|
let weeklyStatsLastFetch = 0;
|
||||||
|
|
||||||
|
async function getWeeklyStats(): Promise<WeeklyStats> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedWeeklyStats && now - weeklyStatsLastFetch < CACHE_TTL) {
|
||||||
|
return cachedWeeklyStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
||||||
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
|
||||||
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
|
||||||
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`),
|
||||||
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${DELIVERED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
||||||
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${RETURNED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
||||||
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${REPLACED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
cachedWeeklyStats = {
|
||||||
|
pendingDelivery: (pendingRows as any[])[0]?.cnt || 0,
|
||||||
|
weeklyNew: (newRows as any[])[0]?.cnt || 0,
|
||||||
|
weeklyRemoved: (removedRows as any[])[0]?.cnt || 0,
|
||||||
|
weeklyDelivered: (deliveredRows as any[])[0]?.cnt || 0,
|
||||||
|
weeklyReturned: (returnedRows as any[])[0]?.cnt || 0,
|
||||||
|
weeklyReplaced: (replacedRows as any[])[0]?.cnt || 0,
|
||||||
|
};
|
||||||
|
weeklyStatsLastFetch = now;
|
||||||
|
return cachedWeeklyStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/vehicles/summary
|
||||||
|
app.get('/summary', async (c) => {
|
||||||
|
const [vehicles, weekly] = await Promise.all([getVehicles(), getWeeklyStats()]);
|
||||||
|
const summary: SummaryData = {
|
||||||
|
totalAssets: vehicles.length,
|
||||||
|
operating: {
|
||||||
|
total: vehicles.filter((v) => v.status === 'Operating').length,
|
||||||
|
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
|
||||||
|
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
|
||||||
|
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
|
||||||
|
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
|
||||||
|
},
|
||||||
|
inventory: {
|
||||||
|
total: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||||
|
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||||
|
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
|
||||||
|
},
|
||||||
|
...weekly,
|
||||||
|
};
|
||||||
|
return c.json(summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/by-type
|
||||||
|
app.get('/by-type', async (c) => {
|
||||||
|
const vehicles = await getVehicles();
|
||||||
|
|
||||||
|
const typeFilters = [
|
||||||
|
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
||||||
|
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
|
||||||
|
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
|
||||||
|
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
|
||||||
|
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result: TypeSummary[] = typeFilters.map((t) => {
|
||||||
|
const typeVehicles = vehicles.filter(t.filter);
|
||||||
|
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
|
||||||
|
|
||||||
|
const modelSummaries: ModelSummary[] = models.map((model) => {
|
||||||
|
const modelVehicles = typeVehicles.filter((v) => v.model === model);
|
||||||
|
// Use contractNo as batch identifier
|
||||||
|
const batches = Array.from(new Set(modelVehicles.map((v) => v.contractNo || '未知'))).filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
...getStats(modelVehicles),
|
||||||
|
batches: batches.map((batch) => ({
|
||||||
|
batch,
|
||||||
|
...getStats(modelVehicles.filter((v) => (v.contractNo || '未知') === batch)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeStats = getStats(typeVehicles);
|
||||||
|
return {
|
||||||
|
type: t.name,
|
||||||
|
totalAssets: typeVehicles.length,
|
||||||
|
totalInventory: typeStats.inventory,
|
||||||
|
totalOperating: typeStats.operating,
|
||||||
|
inventoryRegions: typeStats.inventoryRegions,
|
||||||
|
pending: typeStats.pending,
|
||||||
|
weeklyDelivered: typeStats.weeklyDelivered,
|
||||||
|
weeklyReturned: typeStats.weeklyReturned,
|
||||||
|
weeklyReplaced: typeStats.weeklyReplaced,
|
||||||
|
models: modelSummaries.sort((a, b) => getModelOrder(a.model) - getModelOrder(b.model)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/by-batch
|
||||||
|
app.get('/by-batch', async (c) => {
|
||||||
|
const vehicles = await getVehicles();
|
||||||
|
const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知')))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
const result: BatchGroup[] = batches.map((batch) => {
|
||||||
|
const batchVehicles = vehicles.filter((v) => (v.contractNo || '未知') === batch);
|
||||||
|
const models = Array.from(new Set(batchVehicles.map((v) => v.model)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
batch,
|
||||||
|
...getStats(batchVehicles),
|
||||||
|
models: models.map((model) => {
|
||||||
|
const modelVehicles = batchVehicles.filter((v) => v.model === model);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
type: modelVehicles[0]?.type || '',
|
||||||
|
...getStats(modelVehicles),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/inventory-analysis
|
||||||
|
app.get('/inventory-analysis', async (c) => {
|
||||||
|
const vehicles = await getVehicles();
|
||||||
|
|
||||||
|
const typeFilters = [
|
||||||
|
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
||||||
|
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
|
||||||
|
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
|
||||||
|
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
|
||||||
|
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result: InventoryTypeSummary[] = typeFilters.map((t) => {
|
||||||
|
const typeVehicles = vehicles.filter(t.filter);
|
||||||
|
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
|
||||||
|
|
||||||
|
const modelData = models.map((model) => {
|
||||||
|
const modelVehicles = typeVehicles.filter((v) => v.model === model);
|
||||||
|
const inventoryVehicles = modelVehicles.filter((v) => v.status === 'Inventory');
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
totalAssets: modelVehicles.length,
|
||||||
|
totalInventory: inventoryVehicles.length,
|
||||||
|
regions: INVENTORY_REGIONS.reduce(
|
||||||
|
(acc, reg) => {
|
||||||
|
acc[reg] = inventoryVehicles.filter((v) => mapInventoryRegion(v.location) === reg).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeInventory = typeVehicles.filter((v) => v.status === 'Inventory');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: t.name,
|
||||||
|
totalAssets: typeVehicles.length,
|
||||||
|
totalInventory: typeInventory.length,
|
||||||
|
models: modelData,
|
||||||
|
regionSubtotals: INVENTORY_REGIONS.reduce(
|
||||||
|
(acc, reg) => {
|
||||||
|
acc[reg] = typeInventory.filter((v) => mapInventoryRegion(v.location) === reg).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/list — flat list with optional filters
|
||||||
|
app.get('/list', async (c) => {
|
||||||
|
const vehicles = await getVehicles();
|
||||||
|
const { batch, model, location, status, category } = c.req.query();
|
||||||
|
|
||||||
|
let filtered = vehicles;
|
||||||
|
if (batch && batch !== 'All') {
|
||||||
|
filtered = filtered.filter((v) => (v.contractNo || '未知') === batch);
|
||||||
|
}
|
||||||
|
if (model && model !== 'All') {
|
||||||
|
filtered = filtered.filter((v) => v.model === model);
|
||||||
|
}
|
||||||
|
if (location && location !== 'All') {
|
||||||
|
// Support both display region names and inventory region names
|
||||||
|
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||||
|
const mappedLocation = inventoryRegionMap[location] || location;
|
||||||
|
filtered = filtered.filter((v) => v.location === mappedLocation);
|
||||||
|
}
|
||||||
|
if (status && status !== 'All') {
|
||||||
|
filtered = filtered.filter((v) => v.status === status);
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
if (category === 'Inventory') {
|
||||||
|
filtered = filtered.filter((v) => v.status === 'Inventory');
|
||||||
|
} else if (category === 'Operating') {
|
||||||
|
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
filtered.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
plateNumber: v.plateNumber,
|
||||||
|
type: v.type,
|
||||||
|
model: v.model,
|
||||||
|
location: v.location,
|
||||||
|
status: v.status,
|
||||||
|
ownership: v.ownership,
|
||||||
|
contractNo: v.contractNo,
|
||||||
|
customerName: v.customerName,
|
||||||
|
subjectOrg: v.subjectOrg,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||||
|
app.get('/weekly-detail', async (c) => {
|
||||||
|
const type = c.req.query('type');
|
||||||
|
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`;
|
||||||
|
} else if (type === 'returned') {
|
||||||
|
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
|
||||||
|
} 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
|
||||||
|
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||||
|
} else {
|
||||||
|
return c.json([]);
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query<any[]>(sql);
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/refresh — force cache refresh
|
||||||
|
app.get('/refresh', async (c) => {
|
||||||
|
lastFetchTime = 0;
|
||||||
|
const vehicles = await getVehicles();
|
||||||
|
return c.json({ message: 'Cache refreshed', count: vehicles.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
147
src/server/types.ts
Normal file
147
src/server/types.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
export interface VehicleRow {
|
||||||
|
id: number;
|
||||||
|
车牌号: string;
|
||||||
|
vin: string;
|
||||||
|
车辆品牌: string;
|
||||||
|
车辆型号: string;
|
||||||
|
车辆颜色: string;
|
||||||
|
租赁公司: string;
|
||||||
|
车辆归属状态Label: string | null;
|
||||||
|
车辆型号Label: string | null;
|
||||||
|
库存区域: string | null;
|
||||||
|
车辆租赁状态: string | null;
|
||||||
|
车辆租赁状态Label: string | null;
|
||||||
|
是否营运: number;
|
||||||
|
省: string | null;
|
||||||
|
市: string | null;
|
||||||
|
纬度: number | null;
|
||||||
|
经度: number | null;
|
||||||
|
车辆品牌Label: string | null;
|
||||||
|
合同ID: number | null;
|
||||||
|
合同编码: string | null;
|
||||||
|
客户名称: string | null;
|
||||||
|
合同归属公司: string | null;
|
||||||
|
合同归属部门: string | null;
|
||||||
|
主体: string | null;
|
||||||
|
项目名称: string | null;
|
||||||
|
客户经理: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vehicle {
|
||||||
|
id: number;
|
||||||
|
plateNumber: string;
|
||||||
|
vin: string;
|
||||||
|
type: string;
|
||||||
|
model: string;
|
||||||
|
color: string;
|
||||||
|
location: string;
|
||||||
|
region: string;
|
||||||
|
status: 'Operating' | 'Inventory' | 'Abnormal';
|
||||||
|
ownership: string;
|
||||||
|
rentCompany: string;
|
||||||
|
contractNo: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
orgName: string | null;
|
||||||
|
departmentName: string | null;
|
||||||
|
subjectOrg: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
customerManager: string | null;
|
||||||
|
brandLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryData {
|
||||||
|
totalAssets: number;
|
||||||
|
operating: {
|
||||||
|
total: number;
|
||||||
|
self: number;
|
||||||
|
leased: number;
|
||||||
|
public: number;
|
||||||
|
hanging: number;
|
||||||
|
};
|
||||||
|
inventory: {
|
||||||
|
total: number;
|
||||||
|
inStock: number;
|
||||||
|
abnormal: number;
|
||||||
|
};
|
||||||
|
pendingDelivery: number;
|
||||||
|
weeklyNew: number;
|
||||||
|
weeklyRemoved: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeSummary {
|
||||||
|
type: string;
|
||||||
|
totalAssets: number;
|
||||||
|
totalInventory: number;
|
||||||
|
totalOperating: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
models: ModelSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSummary {
|
||||||
|
model: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
batches: BatchSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSummary {
|
||||||
|
batch: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchGroup {
|
||||||
|
batch: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
models: {
|
||||||
|
model: string;
|
||||||
|
type: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryTypeSummary {
|
||||||
|
type: string;
|
||||||
|
totalAssets: number;
|
||||||
|
totalInventory: number;
|
||||||
|
models: {
|
||||||
|
model: string;
|
||||||
|
totalAssets: number;
|
||||||
|
totalInventory: number;
|
||||||
|
regions: Record<string, number>;
|
||||||
|
}[];
|
||||||
|
regionSubtotals: Record<string, number>;
|
||||||
|
}
|
||||||
109
src/types.ts
Normal file
109
src/types.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
export interface SummaryData {
|
||||||
|
totalAssets: number;
|
||||||
|
operating: {
|
||||||
|
total: number;
|
||||||
|
self: number;
|
||||||
|
leased: number;
|
||||||
|
public: number;
|
||||||
|
hanging: number;
|
||||||
|
};
|
||||||
|
inventory: {
|
||||||
|
total: number;
|
||||||
|
inStock: number;
|
||||||
|
abnormal: number;
|
||||||
|
};
|
||||||
|
pendingDelivery: number;
|
||||||
|
weeklyNew: number;
|
||||||
|
weeklyRemoved: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSummary {
|
||||||
|
batch: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSummary {
|
||||||
|
model: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
batches: BatchSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeSummary {
|
||||||
|
type: string;
|
||||||
|
totalAssets: number;
|
||||||
|
totalInventory: number;
|
||||||
|
totalOperating: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
models: ModelSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchGroup {
|
||||||
|
batch: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
models: {
|
||||||
|
model: string;
|
||||||
|
type: string;
|
||||||
|
total: number;
|
||||||
|
inventory: number;
|
||||||
|
inventoryRegions: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
operating: number;
|
||||||
|
weeklyDelivered: number;
|
||||||
|
weeklyReturned: number;
|
||||||
|
weeklyReplaced: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryTypeSummary {
|
||||||
|
type: string;
|
||||||
|
totalAssets: number;
|
||||||
|
totalInventory: number;
|
||||||
|
models: {
|
||||||
|
model: string;
|
||||||
|
totalAssets: number;
|
||||||
|
totalInventory: number;
|
||||||
|
regions: Record<string, number>;
|
||||||
|
}[];
|
||||||
|
regionSubtotals: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleListItem {
|
||||||
|
id: number;
|
||||||
|
plateNumber: string;
|
||||||
|
type: string;
|
||||||
|
model: string;
|
||||||
|
location: string;
|
||||||
|
status: string;
|
||||||
|
ownership: string;
|
||||||
|
contractNo: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
subjectOrg: string | null;
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
62
woodpecker.yml
Normal file
62
woodpecker.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
steps:
|
||||||
|
- name: npm-build
|
||||||
|
image: node:22-alpine
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
- pull_request
|
||||||
|
- manual
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
commands: |
|
||||||
|
cd $CI_WORKSPACE
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 获取分支名
|
||||||
|
BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -)
|
||||||
|
echo "Branch name: $BRANCH_NAME"
|
||||||
|
|
||||||
|
# 版本号: 分支名-package.json版本
|
||||||
|
PKG_VERSION=$(node -e "console.log(require('./package.json').version)")
|
||||||
|
PROJECT_VERSION="$BRANCH_NAME-$PKG_VERSION"
|
||||||
|
echo "Docker tag: $PROJECT_VERSION"
|
||||||
|
echo $PROJECT_VERSION > $CI_WORKSPACE/project_version.txt
|
||||||
|
|
||||||
|
- name: docker-build
|
||||||
|
image: docker:24.0.5-cli
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
- pull_request
|
||||||
|
- manual
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands: |
|
||||||
|
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
||||||
|
MODULE_NAME=ln-bi
|
||||||
|
|
||||||
|
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
|
||||||
|
|
||||||
|
cd $CI_WORKSPACE
|
||||||
|
|
||||||
|
docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION .
|
||||||
|
|
||||||
|
mkdir -p /root/.docker
|
||||||
|
cat > /root/.docker/config.json <<EOF
|
||||||
|
{
|
||||||
|
"auths": {
|
||||||
|
"harbor.lnh2e.com": {
|
||||||
|
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker push harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION
|
||||||
Reference in New Issue
Block a user