2 Commits

Author SHA1 Message Date
kkfluous
6b75437423 Merge branch 'main' into demo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 解决 3 处冲突,保留 demo 的演示行为:
  - AuthProvider: 无 jumpToken 时放行
  - auth middleware: BYPASS_AUTH=true
  - Shell: DemoModeProvider enabled=true
- 引入 main 上的智能调度模块等改动
2026-04-24 10:37:54 +08:00
kkfluous
cf8f7cf969 feat(demo): 演示模式脱敏 + 临时跳过认证
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 Blur 组件与 DemoModeProvider,全局包裹 Shell
- 资产/里程模块的客户名、业务经理、车牌、VIN 等敏感字段使用 <Blur> 包裹
- 前端 AuthProvider 无 jumpToken 时放行
- 后端 authMiddleware 开头直接 next(),跳过所有认证

仅用于演示分支,勿合并至 main。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:40:58 +08:00
68 changed files with 559 additions and 9370 deletions

View File

@@ -5,21 +5,11 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host
environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
DB_HOST: "47.101.148.99"
DB_PORT: "3306"
DB_USER: "oneos_db_prod"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "ln_asset_management"
HYDROGEN_DB_HOST: "47.99.185.173"
HYDROGEN_DB_PORT: "3306"
HYDROGEN_DB_USER: "root"
HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
DB_USER: "root"
DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "lingniu_prod"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"

File diff suppressed because it is too large Load Diff

View File

@@ -1,264 +0,0 @@
# 能源管理模块设计
在底部导航增加「能源管理」入口,集中展示加氢/充电的成本与用量数据。当前阶段**只做前端原型,数据全部走前端 mock**,后端接入留作下一阶段。
## 业务背景
参考 FineBI 既有的三张大屏:
| BI 链接 | 标题 | 内容 |
|---------|------|------|
| `link/0iqP` | 氢气管理(氢费统计) | 年/月/日加氢量与加氢费 KPI、加氢业务区域分布、Top5 加氢站、各区域占比 |
| `link/GBSp` | 加氢站氢量每日汇总 | 每日加氢量与环比,下钻到站点级带单价 |
| `link/TPqB` | 龙王路停车场充电站每日充电汇总 | 每日充电量(度) + 充电费用(元) |
这些大屏目前只在桌面浏览器里好看,移动端体验较差,且与现有应用风格割裂。新模块的目标是把核心信息按「移动优先 + 双端响应」重新组织进当前 BI App 的底部导航,让一线运营在手机上也能秒级抓取关键能耗指标。
## 用户故事
- 作为运营人员,进 App 底部 Tab 「能源管理」,第一屏立刻能看到本年/本月/今日的加氢量与加氢费。
- 作为运营人员,能切换查看「氢能」和「电能」两类业务。
- 作为氢能业务方,能看到每日加氢明细,并下钻到站点级别(站名 + 单价)。
- 作为电能业务方,能看到龙王路充电站每日充电量与充电费。
## 范围
### 在范围内
- 新模块 `src/modules/energy/`,遵循 mileage 模块的目录骨架
- `EnergyModule` 顶级组件 + 二级 Tab氢能 / 电能)
- 氢能下总览KPI + Top5 横柱 + 区域占比环)+ 每日(明细表带下钻)
- 电能下mini KPI 头 + 每日明细表
- 全前端 mock 数据,数据值从 BI 截图取真实样本,文件 `mock.ts`
- 双端响应mobile + web
-`App.tsx``BASE_MODULES` 注册(登录即可见)
### 不在范围内
- 后端接入、真实数据库表设计(下一期)
- 中国地图(加氢业务区域分布)— 工作量与依赖(高德/echarts-geo后续单独立项
- iframe 嵌入 FineBI已否决
- 角色权限门禁(暂不需要)
- 数据导出 / CSV
- 站点详情页 / 区域详情页
## 模块注册App.tsx
```ts
import { Truck, Route, Activity, Zap } from 'lucide-react';
import EnergyModule from './modules/energy/EnergyModule';
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
];
```
并在 `Shell.tsx``PATH_MAP``'/energy': 'energy'``#energy` hash 由 Shell 已有逻辑兜底。
## 页面结构
```
EnergyModule
├── 顶部 sticky sub-nav: [氢能] [电能] ← motion layoutId 滑块动画
├── 氢能 view (HydrogenView)
│ ├── 内层 sticky tab: [总览] [每日]
│ ├── HydrogenOverview
│ │ ├── 数据时间提示条:「数据自 2025-01-01 起,每 5 分钟更新」
│ │ ├── KPI 网格(移动 2×2 / 桌面 1×4
│ │ ├── Top5 加氢站横柱图
│ │ └── 各区域加氢占比环形图
│ └── HydrogenDaily
│ ├── 日期速选 6 选 1当天 / 本周 / 本月 / 本季度 / 最近7天 / 最近30天
│ ├── 客户类型 2 选 1外部 / 羚牛
│ ├── 合计行pin 在表头下)
│ └── 表格:日期 → 加氢量(Kg) → 环比%(日期行可展开为站点级)
└── 电能 view (ElectricView)
├── 数据时间提示条:「龙王路停车场充电站,期初 2025-01-01手工导入每日更新」
├── 横向 mini KPI 头3 列:累计 / 本月 / 今日)
├── 客户类型 2 选 1外部 / 羚牛
└── 表格:月份/日期 → 充电电量(度) → 充电费用(元)(月份组可展开为日级)
```
## 文件结构
```
src/modules/energy/
├── EnergyModule.tsx # 顶级容器 + 氢能/电能 切换
├── HydrogenView.tsx # 含「总览/每日」二级 Tab
├── HydrogenOverview.tsx # KPI + Top5 + 区域占比
├── HydrogenDaily.tsx # 加氢量明细表
├── ElectricView.tsx # 充电汇总表
├── mock.ts # 所有 mock 数据
└── types.ts # 共享类型
```
参照 `src/modules/mileage/` 风格。**没有 api.ts**(后端先不接)。
## 数据形状mock.ts / types.ts
```ts
// types.ts
export type CustomerType = 'external' | 'lingniu'; // 外部 / 羚牛
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
export interface HydrogenKpi {
yearKg: number; // 年加氢量 Kg
yearFee: number; // 年加氢费 元
ourYearKg: number; // 我方年加氢量
ourYearFee: number; // 我方年加氢费
customerYearKg: number; // 客户产生年加氢量
monthKg: number;
monthFee: number;
todayKg: number;
todayFee: number;
lingniuBornKg: number; // 累计羚牛承担量
lingniuBornFee: number; // 累计羚牛承担费
}
export interface HydrogenStation {
name: string;
kg: number;
fee: number;
}
export interface HydrogenRegionShare {
region: string;
kg: number;
share: number; // 0-1
}
export interface HydrogenStationRow {
name: string;
pricePerKg: number; // 单价 元/Kg
kg: number;
chainPct: number; // 环比 -1..+1
}
export interface HydrogenDailyRow {
date: string; // 'YYYY-MM-DD'
totalKg: number;
chainPct: number;
stations: HydrogenStationRow[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
monthKwh: number;
monthFee: number;
todayKwh: number;
todayFee: number;
todayChainPct: number;
}
export interface ElectricDailyRow {
date: string; // 'YYYY-MM-DD'
kwh: number;
fee: number;
chainPct: number; // 环比,用于趋势箭头
}
export interface ElectricMonthGroup {
month: string; // 'YYYY-MM'
kwh: number;
fee: number;
rows: ElectricDailyRow[];
}
```
`mock.ts` 提供:
- `hydrogenKpi`:取自 0iqP 的 362.43T / ¥1066.46 万 / 10.03 万 等真实样本
- `hydrogenStationsTop5`5 家站,名字取自 GBSp 截图
- `hydrogenRegionShare`:约 8-12 个区域条目
- `hydrogenDaily`:约 30 天数据,前 7 天每天 3-4 个站点;剩余天只有汇总 + 1-2 站点
- `electricKpi`:取自 TPqB 合计 817,632.24 度 / ¥151,542.92
- `electricMonthly`:以月为顶级 group含每日明细至少覆盖 2026-04 起若干天
数字均做轻微抖动,但保留量级与百分比,避免与 BI 大屏数字"完全一致"误导。
## 视觉规范
### 通用
- 容器:`min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6` + `max-w-6xl mx-auto`
- 横屏:保留 `landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden`
- 卡片:`bg-white rounded-2xl border border-slate-100 shadow-sm`
- sub-nav`bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm` + sticky top 0
- Tab active 用 `motion.div layoutId="..."` 下划线动画(沿用 mileage 模式)
- 主色:`text-blue-600 / bg-blue-50`
- 正向:`text-emerald-500 / bg-emerald-50`
- 负向:`text-red-500 / bg-red-50`
- 警示:`text-amber-500 / bg-amber-50`
### 氢能总览
- KPI 卡4 张主角卡,顺序固定
1. 年加氢量(青蓝渐变 `from-cyan-50 to-blue-50`+ 主数 `362.43T` + 分解两行(我方 / 客户产生)
2. 年加氢费(蓝紫渐变 `from-blue-50 to-violet-50`+ 主数 `¥1066.46万` + 分解(我方)
3. 累计羚牛承担(蜜橙渐变 `from-amber-50 to-orange-50`+ 主数 `¥10.03万` + 分解(量 / 费)
4. 本月/今日合并卡(浅灰)+ 左半月度 + 右半今日,文字主数 `text-2xl md:text-3xl`
- 卡左上 lucide icon`Fuel` / `Wallet` / `Coins` / `CalendarClock`
- KPI 主数移动端 `text-2xl font-bold`,桌面 `md:text-3xl`,分解行 `text-[11px] text-slate-500 font-bold`
- Top5 加氢站横向柱状recharts BarChart `layout="vertical"`),柱子蓝→青渐变,柱左侧带数字徽章 1-5柱右端贴 `XX,XXX Kg · XX%``<ResponsiveContainer width="100%" height={240}>`
- 区域占比环形recharts PieChart外环切片中心圆心放年合计 `362.43T`,下方两列图例(移动单列、桌面双列)
### 氢能每日表
- 日期速选6 个 pill 一行,可横向滚动,激活态 `bg-blue-50 text-blue-600 border-blue-200`
- 客户类型2 列等宽 segmented control背景 `bg-slate-100 rounded-xl`,激活态白底+阴影)
- 合计行:`bg-blue-50/50 text-blue-600` 粗体
- 主行 = 日期:左侧 `▶` 折叠图标 + 日期;展开后子行内缩进 `pl-6`,子行内容 = 站名 - 单价 元/Kg
- 环比 pill 双端共用样式:
-`↑` 绿底 `bg-emerald-50 text-emerald-600`
-`↓` 红底 `bg-red-50 text-red-600`
- 持平 `` 灰底 `bg-slate-100 text-slate-500`
- 圆角 `rounded-full px-2 py-0.5 text-[11px] font-bold`
- 移动 3 列(日期 / 量 / 环比),桌面 4 列加站价列(站点级行用网格而非缩进)
### 电能
- mini KPI 头3 卡横排(移动也保持横排,不堆叠),每卡上排 `¥金额` 主数(`text-xl md:text-2xl`),下排 `XXX 度` 副数 + 今日卡再加 pill 环比
- 月份组:`▶` + `2026-04` + 该月合计 `度 / 元`;展开后是日级行
- 月份组 active 时背景 `bg-blue-50/30`
- 行的趋势图标 → 与氢能页用同一 pill 组件(保证视觉一致)
## 响应式行为
| 区域 | 移动 (<md) | 桌面 (≥md) |
|------|-----------|-----------|
| 顶部 sub-nav (氢/电) | sticky 满宽 | sticky 满宽,左对齐 |
| 氢能内层 sub-tab | 紧贴 sub-nav 下 | 同 |
| 氢能总览 KPI 网格 | `grid-cols-2` | `md:grid-cols-4` |
| Top5 横柱 + 区域占比 | 上下叠 `grid-cols-1` | `md:grid-cols-2 gap-4` |
| 每日氢能表 | 3 列:日期/量/环比 | 4 列:日期/站价/量/环比,站点级行同样 4 列 |
| 电能 mini KPI 头 | 横排 3 卡gap 紧凑) | 3 卡gap 宽松) |
| 电能表格 | 3 列 | 4-5 列(可加客户类型 / 趋势火花线,留作后期) |
| 容器宽度上限 | 100% | `max-w-6xl mx-auto` |
| recharts 图 | `<ResponsiveContainer width="100%">` | 同 |
## 组件复用
- 内层 sub-nav 抄 `MileageModule.tsx` 的 sticky tab 实现(含 motion 滑块)
- segmented control客户类型 / 日期速选)抄 mileage 已有的实现
- 表格行 chevron 折叠抄 assets 模块里现有展开行的写法
- 环比 pill 单独抽 `<TrendBadge value={number} />` 共用组件,放在 `src/modules/energy/HydrogenDaily.tsx` 顶端 export
## 边界与开放点
1. **`氢费`字面口径**:用户原始描述「每日氢费」,但 BI GBSp 实际只有加氢量+环比,单价在站点级。本期采纳 BI 的口径——表格只展示量+环比+单价(嵌入站名),费汇总放氢能总览的 KPI 卡。
2. **数据时间**mock 用 2026-04-28 为「今天」(`currentDate` 取自 user 自动 memory`今日加氢` 在 0Kg 时显示 `0` 而非 `--`,避免误判为缺失。
3. **空态**mock 数据已写满UI 仍要支持 `rows.length === 0` 时显示「暂无数据」灰文 + 图标。
4. **未来后端接入**mock 文件命名 `mock.ts`,不放在 `api.ts`;后期添加 `api.ts` 时同名 exportUI 切到 `useEffect + fetch` 即可。
5. **icon 选型**`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`
## 验收标准
- [ ] 底部 nav 多出「能源管理」Tabicon Zap登录后可见
- [ ] 进入后默认在「氢能 → 总览」
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
- [ ] 切到电能 Tab3 张 mini KPI + 月份分组表,月份能展开日级行
- [ ] 横屏不出现底部 nav 遮挡内容
- [ ] `npm run lint` 通过
- [ ] 不引入新依赖recharts / lucide-react / motion 已有)

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>羚牛氢能</title>
<title>羚牛氢能车辆资产</title>
</head>
<body>
<div id="root"></div>

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
"dotenv": "^16.4.0",
"hono": "^4.7.0",
"jsonwebtoken": "^9.0.3",
@@ -24,12 +23,10 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"tsx": "^4.21.0",
"xlsx": "^0.18.5"
"tsx": "^4.21.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -1,145 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
import { Shell, type ModuleConfig } from "./components/Shell";
import AssetsModule from "./modules/assets/AssetsModule";
import MileageModule from "./modules/mileage/MileageModule";
import SchedulingModule from "./modules/scheduling/SchedulingModule";
import HydrogenModule from "./modules/energy/HydrogenModule";
import ElectricModule from "./modules/energy/ElectricModule";
import EtcModule from "./modules/energy/EtcModule";
import EleImportPage from "./modules/ele/EleImportPage";
import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage";
import AuthProvider from "./auth/AuthProvider";
import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage";
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
import { useMemo } from 'react';
import { Truck, Route, Activity } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
import SchedulingModule from './modules/scheduling/SchedulingModule';
import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling } from './shared/auth/roles';
const ASSETS_MODULE: ModuleConfig = {
id: "assets",
label: "资产管理",
icon: Truck,
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = {
id: "mileage",
label: "里程管理",
icon: Route,
component: MileageModule,
};
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling",
label: "智能调度",
icon: Activity,
component: SchedulingModule,
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
};
const HYDROGEN_MODULE: ModuleConfig = {
id: "hydrogen",
label: "氢能",
icon: Fuel,
component: HydrogenModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
icon: BatteryCharging,
component: ElectricModule,
};
const ETC_MODULE: ModuleConfig = {
id: "etc",
label: "ETC",
icon: Receipt,
component: EtcModule,
};
/**
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
*/
function normalizePath() {
if (typeof window === "undefined") return;
const { pathname, hash, search } = window.location;
// 主路径 & 隐藏后台页保持不变
if (pathname === "/asset" || pathname === "/energy") return;
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
const legacyMap: Record<string, { path: string; hash?: string }> = {
"/": { path: "/asset" },
"/vehicle": { path: "/asset", hash: "assets" },
"/assets": { path: "/asset", hash: "assets" },
"/mileage": { path: "/asset", hash: "mileage" },
"/scheduling": { path: "/asset", hash: "scheduling" },
};
// 未知路径兜底到 /asset保留原 hash 让 Shell 内部继续解析)
const target = legacyMap[pathname] ?? { path: "/asset" };
const finalHash = target.hash ? `#${target.hash}` : hash || "";
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
}
normalizePath();
type PathSet = "asset" | "energy";
function getPathSet(): PathSet {
return window.location.pathname === "/energy" ? "energy" : "asset";
}
function getRouteKey(): string {
if (typeof window === "undefined") return "";
const path = window.location.pathname;
const hash = window.location.hash;
if (
path === "/ele/import" ||
hash === "#/ele/import" ||
hash === "#ele/import"
)
return "ele/import";
if (
path === "/admin/feedback" ||
hash === "#/admin/feedback" ||
hash === "#admin/feedback"
)
return "admin/feedback";
return "";
}
function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth();
const [routeKey, setRouteKey] = useState(getRouteKey);
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
// 监听 hashchange / popstate让 a href="#/..." 跳转与浏览器前进后退能即时生效
useEffect(() => {
const update = () => {
setRouteKey(getRouteKey());
setPathSet(getPathSet());
};
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
return () => {
window.removeEventListener("hashchange", update);
window.removeEventListener("popstate", update);
};
}, []);
useEffect(() => {
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
}, [pathSet]);
const modules = useMemo<ModuleConfig[]>(() => {
if (pathSet === "energy") {
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
const modules = useMemo(() => {
if (canAccessScheduling(user?.roles)) {
return [...BASE_MODULES, SCHEDULING_MODULE];
}
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result;
}, [pathSet, user?.roles]);
return BASE_MODULES;
}, [user?.roles]);
if (isLoading) {
return (
@@ -156,17 +43,7 @@ function AuthGate() {
return <UnauthorizedPage message={error || undefined} />;
}
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") return <EleImportPage />;
if (routeKey === "admin/feedback") return <FeedbackAdminPage />;
// /energy 整组按能源权限控制
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
return <Shell key={pathSet} modules={modules} />;
return <Shell modules={modules} />;
}
export default function App() {

View File

@@ -36,23 +36,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}, []);
async function authenticate() {
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: 'dev-local',
userName: '本地开发',
permissionLevel: 'full',
depName: '',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
},
error: null,
});
return;
}
// 1. 检查 sessionStorage 中是否有 JWT
const savedToken = sessionStorage.getItem('bi_jwt');
if (savedToken) {
@@ -82,7 +65,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken');
if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
// 演示模式:无 token 时直接放行
setState({ isLoading: false, isAuthenticated: true, user: null, error: null });
return;
}

View File

@@ -1,511 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
type LucideIcon,
} from 'lucide-react';
import { fetchJson } from '../auth/api-client';
import { useAuth } from '../auth/useAuth';
import { canManageFeedback } from '../shared/auth/roles';
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
import RotatingFooterHint from './RotatingFooterHint';
const MAX_SCREENSHOTS = 6;
const MAX_IMG_SIZE_MB = 5;
interface UploadedImg {
url: string;
thumbDataUrl: string;
}
async function uploadImage(file: File): Promise<string> {
const fd = new FormData();
fd.append('file', file);
const token = sessionStorage.getItem('bi_jwt');
const res = await fetch('/api/feedback/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
return json.url as string;
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result || ''));
r.onerror = reject;
r.readAsDataURL(file);
});
}
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function detectModule(): string {
const hash = (window.location.hash || '').slice(1);
const path = window.location.pathname;
if (path.includes('/ele/')) return 'ele';
if (hash.includes('ele')) return 'ele';
if (hash.startsWith('/')) return hash.split('/')[1] || '';
return hash || '';
}
interface Props {
/** 显式覆盖当前模块(否则自动从 URL 检测) */
module?: string;
}
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const { user } = useAuth();
const isAdmin = canManageFeedback(user?.roles);
const [open, setOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
const [type, setType] = useState<FeedbackType | null>(null);
const [mod, setMod] = useState<string>('');
const [content, setContent] = useState('');
const [shots, setShots] = useState<UploadedImg[]>([]);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const addFiles = async (files: FileList | File[]) => {
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
if (list.length === 0) return;
setError(null);
setUploading(true);
try {
for (const f of list) {
if (shots.length >= MAX_SCREENSHOTS) break;
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
setError(`${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
continue;
}
const thumbDataUrl = await readAsDataUrl(f);
const url = await uploadImage(f);
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
};
// Open: detect current module
useEffect(() => {
if (open && step === 1) {
setMod(moduleProp ?? detectModule());
}
}, [open, step, moduleProp]);
// Lock scroll when open
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const reset = () => {
setStep(1);
setType(null);
setContent('');
setShots([]);
setError(null);
};
const close = () => {
setOpen(false);
setTimeout(reset, 300); // 等动画完
};
const submit = async () => {
if (!type || !content.trim()) return;
setSubmitting(true);
setError(null);
try {
await fetchJson('/api/feedback/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
module: mod || null,
content: content.trim(),
screenshots: shots.map(s => s.url),
userAgent: navigator.userAgent.slice(0, 500),
}),
});
setStep(3);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
};
const next = () => {
if (step === 1) {
if (!type) return;
setStep(2);
return;
}
if (step === 2) {
if (!content.trim()) { taRef.current?.focus(); return; }
submit();
return;
}
};
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
const progress = step >= 3 ? 100 : (step / 2) * 100;
return (
<>
{/* Floating Action Button */}
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={() => setMenuOpen(m => !m)}
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
aria-label="反馈"
title="提建议 / 我的反馈"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 8 }}
transition={{ duration: 0.15 }}
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
>
<button
onClick={() => { setMenuOpen(false); setOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
>
<Sparkles size={14} className="text-blue-500" />
</button>
<button
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
>
<Inbox size={14} className="text-emerald-500" />
</button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" />
</a>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* 点击外面关菜单 */}
{menuOpen && (
<div
className="fixed inset-0 z-[-1]"
onClick={() => setMenuOpen(false)}
/>
)}
</div>
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
{/* Modal */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header + progress */}
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
<p className="text-[13px] font-bold text-slate-700 mb-1.5"></p>
<RotatingFooterHint className="justify-start mb-4" />
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map((opt, i) => {
const Icon = opt.icon;
const selected = type === opt.key;
return (
<motion.button
key={opt.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.99 }}
onClick={() => { setType(opt.key); setStep(2); }}
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
>
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
);
})}
</div>
</motion.div>
)}
{step === 2 && (
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2"></p>
<textarea
ref={taRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imgs: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length > 0) {
e.preventDefault();
addFiles(imgs);
}
}}
autoFocus
rows={5}
maxLength={1000}
placeholder={
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
: '随便聊聊你的想法'
}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
{/* 截图上传 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-bold text-slate-400 uppercase"></p>
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS}</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
<div className="flex flex-wrap gap-1.5">
{shots.map((s, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
>
<X size={10} />
</button>
</div>
))}
{shots.length < MAX_SCREENSHOTS && (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
>
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([k, label]) => (
<button
key={k}
onClick={() => setMod(k)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
)}
{step === 3 && (
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1"> </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed"><br /></div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step < 3 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={back}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} />
</button>
)}
<div className="flex-1" />
<button
onClick={next}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
{!submitting && step !== 2 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 3 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={close}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -1,163 +0,0 @@
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../auth/api-client';
interface FeedbackItem {
id: number;
type: 'dimension' | 'bug' | 'ux' | 'other';
module: string | null;
content: string;
contact: string | null;
screenshots: string[] | string | null;
status: 'open' | 'in_progress' | 'done' | 'rejected';
reply_content: string | null;
reply_user: string | null;
reply_at: string | null;
created_at: string;
}
const TYPE_LABEL: Record<string, string> = {
dimension: '💡 新维度',
bug: '🐛 Bug',
ux: '🎨 体验',
other: '📝 其他',
};
const STATUS_LABEL: Record<string, string> = {
open: '待处理',
in_progress: '处理中',
done: '已完成',
rejected: '已忽略',
};
const STATUS_STYLE: Record<string, string> = {
open: 'bg-slate-100 text-slate-500',
in_progress: 'bg-amber-100 text-amber-600',
done: 'bg-emerald-100 text-emerald-600',
rejected: 'bg-rose-100 text-rose-500',
};
interface Props {
open: boolean;
onClose: () => void;
onBack?: () => void;
}
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
if (!s) return [];
if (Array.isArray(s)) return s;
try { return JSON.parse(String(s)); } catch { return []; }
}
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
const [items, setItems] = useState<FeedbackItem[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setItems(null);
setError(null);
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
.then(d => setItems(d.items))
.catch(e => setError(e instanceof Error ? e.message : String(e)));
}, [open]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={onClose}
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-lg md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
{onBack && (
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
<ArrowLeft size={18} />
</button>
)}
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<MailOpen size={14} className="text-white" />
</div>
<div className="flex-1">
<div className="text-sm font-black text-slate-800 leading-tight"></div>
<div className="text-[10px] text-slate-400 font-bold"></div>
</div>
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{error ? (
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
) : items === null ? (
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : (
<div className="space-y-2.5">
{items.map(it => {
const shots = parseScreenshots(it.screenshots);
return (
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
{STATUS_LABEL[it.status]}
</span>
<span className="text-[10px] text-slate-400 ml-auto">
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
</span>
</div>
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
{it.content}
</div>
{shots.length > 0 && (
<div className="flex flex-wrap gap-1">
{shots.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
<img src={url} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
{it.reply_content && (
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
{it.reply_user || '产品同学'}
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
</div>
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
{it.reply_content}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
const FOOTER_HINTS = [
'想看哪个角度的数据?告诉我们一下嘛',
'更多统计维度接入中,欢迎您的建议 ~',
'下一个图表,可能就是您建议的那个',
'数据科学家正在深夜挖掘新维度…',
'维度灵感正在路上,钉一下产品同学也行',
'数字背后还有故事,等下一次上线揭晓',
];
interface Props {
/** 自定义提示词集合,默认使用通用文案 */
hints?: string[];
/** 切换间隔,默认 4 秒 */
intervalMs?: number;
/** 额外类名 */
className?: string;
/** 点击时回调(一般用来打开反馈弹窗) */
onClick?: () => void;
}
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
const [idx, setIdx] = useState(0);
useEffect(() => {
if (hints.length <= 1) return;
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
return () => clearInterval(t);
}, [hints, intervalMs]);
return (
<div
className={`flex items-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className || 'mt-1 justify-center'}`}
onClick={onClick}
>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
<span
key={idx}
style={{ animation: 'rotatingHintFade 0.5s ease' }}
>
{hints[idx]}
</span>
<style>{`
@keyframes rotatingHintFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
export interface ModuleConfig {
id: string;
@@ -10,20 +9,28 @@ export interface ModuleConfig {
component: ComponentType;
}
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */
function getHashHead(): string {
return window.location.hash.slice(1).split('/')[0];
}
/** path 到模块 id 的映射 */
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
};
function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead();
if (modules.some((m) => m.id === head)) return head;
// 优先看 hash
const hash = window.location.hash.slice(1);
if (modules.some((m) => m.id === hash)) return hash;
// 再看 pathname
const pathModule = PATH_MAP[window.location.pathname];
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
// 默认第一个
return modules[0]?.id ?? '';
}
function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead();
return modules.some((m) => m.id === head) ? head : '';
const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === hash) ? hash : '';
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
@@ -39,17 +46,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [modules]);
useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
if (getHashHead() !== activeModule) {
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
if (window.location.hash.slice(1) !== activeModule) {
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
}
}, [activeModule]);
const switchModule = (id: string) => {
if (getHashHead() === id) return;
if (window.location.hash.slice(1) === id) return;
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id);
@@ -65,7 +71,8 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [user]);
return (
<DemoModeProvider enabled={false}>
<DemoModeProvider enabled={true}>
<div className="flex min-h-screen">
{/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
@@ -99,7 +106,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{/* 内容区 */}
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
{ActiveComponent && <ActiveComponent />}
<FeedbackFab module={activeModule} />
</main>
{/* 移动端底部导航 (md 以下) */}

View File

@@ -14,12 +14,3 @@ body {
overflow: auto;
height: 100%;
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
}

View File

@@ -1,341 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../../auth/api-client';
interface FeedbackItem {
id: number;
type: 'dimension' | 'bug' | 'ux' | 'other';
module: string | null;
content: string;
contact: string | null;
screenshots: string[] | string | null;
user_id: string | null;
user_name: string | null;
status: 'open' | 'in_progress' | 'done' | 'rejected';
reply_content: string | null;
reply_user: string | null;
reply_at: string | null;
created_at: string;
}
const TYPE_LABEL: Record<string, string> = {
dimension: '💡 新维度',
bug: '🐛 Bug',
ux: '🎨 体验',
other: '📝 其他',
};
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
};
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
if (!s) return [];
if (Array.isArray(s)) return s;
try { return JSON.parse(String(s)); } catch { return []; }
}
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
const token = sessionStorage.getItem('bi_jwt');
const res = await fetch(`/api/feedback/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
}
export default function FeedbackAdminPage() {
const [items, setItems] = useState<FeedbackItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
const [active, setActive] = useState<FeedbackItem | null>(null);
const [replyDraft, setReplyDraft] = useState('');
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
const [saving, setSaving] = useState(false);
const [hint, setHint] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (statusFilter) params.set('status', statusFilter);
params.set('limit', '200');
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
setItems(d.items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => { reload(); }, [reload]);
const open = (it: FeedbackItem) => {
setActive(it);
setReplyDraft(it.reply_content || '');
setReplyStatus(it.status === 'open' ? 'done' : it.status);
};
const save = async () => {
if (!active) return;
setSaving(true);
setHint(null);
try {
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
setHint('已保存');
setActive(null);
await reload();
} catch (e) {
setHint(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
setTimeout(() => setHint(null), 3000);
}
};
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
try {
await patchItem(it.id, { status });
await reload();
} catch (e) {
setHint(e instanceof Error ? e.message : String(e));
setTimeout(() => setHint(null), 3000);
}
};
const counters = items.reduce<Record<string, number>>((m, it) => {
m[it.status] = (m[it.status] || 0) + 1;
return m;
}, {});
return (
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
<div className="max-w-5xl mx-auto space-y-4">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={() => {
// 优先 history.back来自 SPA 内部跳转);否则回到主页
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回"
>
<ArrowLeft size={16} />
</button>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
<Inbox size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"></p>
</div>
</div>
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</header>
{/* 状态过滤 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
<button
onClick={() => setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
> {items.length}</button>
{STATUS_OPTIONS.map(o => (
<button
key={o.key}
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
>
{o.label} {counters[o.key] ?? 0}
</button>
))}
</div>
{error && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
<AlertCircle size={14} /> {error}
</div>
)}
<AnimatePresence>
{hint && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
>
<CheckCircle2 size={14} /> {hint}
</motion.div>
)}
</AnimatePresence>
{/* 列表 */}
<div className="space-y-2">
{loading && items.length === 0 ? (
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.length === 0 ? (
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.map(it => {
const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
return (
<div
key={it.id}
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
onClick={() => open(it)}
>
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
{statusOpt?.label || it.status}
</span>
<span className="text-[10px] text-slate-400 ml-auto">
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
</span>
</div>
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
{(shots.length > 0 || it.contact) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>}
{it.contact && <span>📞 {it.contact}</span>}
</div>
)}
{it.reply_content && (
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
: {it.reply_content}
</div>
)}
{it.status === 'open' && (
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100"></button>
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100"></button>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 详情 / 回复弹窗 */}
<AnimatePresence>
{active && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={() => setActive(null)}
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-xl md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
<div className="flex-1">
<div className="text-sm font-black text-slate-800 leading-tight"> #{active.id}</div>
<div className="text-[10px] text-slate-400 font-bold">
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
</div>
</div>
<button onClick={() => setActive(null)} className="p-1.5 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
{active.module && <span className="text-slate-400">: {MODULE_LABELS[active.module] || active.module}</span>}
{active.contact && <span className="text-slate-400">: {active.contact}</span>}
</div>
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
{parseScreenshots(active.screenshots).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{parseScreenshots(active.screenshots).map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
<img src={u} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<div className="flex gap-1 flex-wrap">
{STATUS_OPTIONS.map(o => (
<button
key={o.key}
onClick={() => setReplyStatus(o.key)}
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
>
{o.label}
</button>
))}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
rows={4}
maxLength={2000}
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{replyDraft.length} / 2000</div>
</div>
</div>
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
<div className="flex-1" />
<button
onClick={() => setActive(null)}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
></button>
<button
onClick={save}
disabled={saving}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
{saving ? '保存中…' : '保存'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -35,7 +35,6 @@ import type { WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
// --- Constants ---
@@ -46,52 +45,6 @@ const TABS = [
{ id: 'customer', label: '按客户' },
];
function MarqueeBanner() {
const trackRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState(false);
useEffect(() => {
const check = () => {
if (!trackRef.current || !innerRef.current) return;
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
};
check();
const ro = new ResizeObserver(check);
ro.observe(trackRef.current!);
return () => ro.disconnect();
}, []);
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
<div ref={trackRef} className="overflow-hidden">
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
function formatLocalDateTime(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
export default function AssetsModule() {
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
const [tabReady, setTabReady] = useState(true);
@@ -138,7 +91,7 @@ export default function AssetsModule() {
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
const [lastUpdate, setLastUpdate] = useState<string>('');
const [modalLoading, setModalLoading] = useState(false);
// Dept/Region/Customer data
@@ -208,7 +161,7 @@ export default function AssetsModule() {
setRegionData(region);
setCustomerData(cust);
setInventoryData(inv);
setLastUpdate(formatLocalDateTime(new Date()));
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
@@ -270,18 +223,11 @@ export default function AssetsModule() {
setModalLoading(true);
const cat = showPlateNumbers.category;
// Weekly categories use the dedicated weekly-detail endpoint.
// Pending 不属于 weeklyweekly-detail 不支持 model/batch/location 过滤,
// 走下面的 /list 路径才能按型号/区域等维度过滤。
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced' };
// 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], {
model: showPlateNumbers.model,
batch: showPlateNumbers.batch,
location: showPlateNumbers.location,
source: showPlateNumbers.source,
})
fetchWeeklyDetail(weeklyTypes[cat])
.then(setModalWeeklyDetail)
.catch(() => setModalWeeklyDetail([]))
.finally(() => setModalLoading(false));
@@ -295,10 +241,8 @@ export default function AssetsModule() {
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
if (showPlateNumbers.source) params.source = showPlateNumbers.source;
if (cat === 'Inventory') params.category = 'Inventory';
if (cat === 'Operating') params.category = 'Operating';
if (cat === 'Pending') params.category = 'Pending';
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
@@ -572,7 +516,7 @@ export default function AssetsModule() {
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">-BI</h1>
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide"></h1>
{/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
@@ -735,12 +679,13 @@ export default function AssetsModule() {
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate}
</div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
</div>
</div>
</div>
{/* OneOS 迁移提示滚动条 */}
<MarqueeBanner />
{/* Main Content Area */}
<div className="flex flex-col gap-6">
@@ -2235,7 +2180,7 @@ export default function AssetsModule() {
</td>
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}</td>
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 库存` }); }}>{tb.inventory}</td>
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
</tr>
))}
@@ -2297,9 +2242,9 @@ export default function AssetsModule() {
</span>
<span
className="font-bold text-orange-600 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 库存` })}
>
:{tb.pending}
:{tb.inventory}
</span>
</div>
</div>
@@ -2884,7 +2829,7 @@ export default function AssetsModule() {
</AnimatePresence>
</div>
<RotatingFooterHint className="pb-4" />
</div>
);
}

View File

@@ -50,7 +50,6 @@ export async function fetchVehicleList(params: {
department?: string;
attendance?: string;
subject?: string | null;
source?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch);
@@ -66,7 +65,6 @@ export async function fetchVehicleList(params: {
if (params.department) query.set('department', params.department);
if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject);
if (params.source) query.set('source', params.source);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
@@ -114,14 +112,6 @@ export async function fetchRegionChart(
);
}
export async function fetchWeeklyDetail(
type: string,
filters?: { model?: string; batch?: string; location?: string; source?: string },
): Promise<WeeklyDetailItem[]> {
const params = new URLSearchParams({ type });
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
if (filters?.source) params.set('source', filters.source);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
}

View File

@@ -106,7 +106,6 @@ export interface VehicleListItem {
city: string | null;
status: string;
ownership: string;
rentCompany?: string | null;
contractNo: string | null;
customerName: string | null;
subjectOrg: string | null;
@@ -153,7 +152,6 @@ export interface RegionTypeBreakdown {
total: number;
operating: number;
inventory: number;
pending: number;
customers: string[];
}

View File

@@ -1,392 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
Truck, ExternalLink, Layers, Zap, ArrowLeft,
} from 'lucide-react';
import { fetchJson } from '../../auth/api-client';
import { useAuth } from '../../auth/useAuth';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import FeedbackFab from '../../components/FeedbackFab';
function getJwt(): string | null {
return sessionStorage.getItem('bi_jwt');
}
interface UploadResult {
ok: boolean;
filename: string;
batchId: string;
parsed: number;
fileDuplicates: number;
inserted: number;
dbDuplicates: number;
breakdown: { internal: number; external: number };
}
interface ListItem {
id: number;
order_no: string;
station_name: string | null;
terminal_name: string | null;
region: string | null;
city: string | null;
start_time: string | null;
end_time: string | null;
duration_min: number | null;
kwh: number | null;
fee: number | null;
e_fee: number | null;
service_fee: number | null;
plate: string | null;
judged_plate: string | null;
customer_name: string | null;
vehicle_kind: 'internal' | 'external' | 'unknown';
batch_id: string;
imported_at: string;
}
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
const KIND_LABEL: Record<string, string> = {
internal: '内部',
external: '外部',
};
const KIND_STYLE: Record<string, string> = {
internal: 'bg-blue-50 text-blue-600 border-blue-200',
external: 'bg-amber-50 text-amber-600 border-amber-200',
};
async function uploadFile(file: File): Promise<UploadResult> {
const fd = new FormData();
fd.append('file', file);
const token = getJwt();
const res = await fetch('/api/ele/import', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
return json as UploadResult;
}
export default function EleImportPage() {
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [items, setItems] = useState<ListItem[]>([]);
const [total, setTotal] = useState(0);
const [overall, setOverall] = useState<OverallRow[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]);
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
const [batchFilter, setBatchFilter] = useState('');
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const reload = useCallback(async () => {
const params = new URLSearchParams({ page: '1', limit: '50' });
if (filter) params.set('kind', filter);
if (batchFilter) params.set('batchId', batchFilter);
if (search) params.set('search', search);
const [list, agg, b] = await Promise.all([
fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`),
fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`),
fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`),
]);
setItems(list.items);
setTotal(list.total);
setOverall(agg.overall);
setBatches(b.items);
}, [filter, batchFilter, search]);
useEffect(() => {
reload().catch(e => console.error(e));
}, [reload]);
const handleUpload = async (f: File) => {
setUploading(true);
setError(null);
setResult(null);
try {
const r = await uploadFile(f);
setResult(r);
await reload();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
};
const onPick = (f: File | null) => {
setFile(f);
if (f) handleUpload(f);
};
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
<div className="max-w-6xl mx-auto space-y-4">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={() => {
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回"
>
<ArrowLeft size={16} />
</button>
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
<Zap size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"> xlsx · · </p>
</div>
</div>
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
</header>
{/* 上传区 */}
<section
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onPick(f);
}}
onClick={() => inputRef.current?.click()}
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
}`}
>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(e) => onPick(e.target.files?.[0] || null)}
/>
<div className="px-6 py-10 flex flex-col items-center text-center">
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
</div>
<div className="text-sm font-bold text-slate-700 mb-1">
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
</div>
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
</div>
</div>
</section>
{/* 上传结果提示 */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
>
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-[12px] font-bold text-slate-700 mb-1">
<span className="text-slate-500 font-mono">{result.filename}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
<Stat label="解析" value={result.parsed} color="text-slate-700" />
<Stat label="新增" value={result.inserted} color="text-blue-600" />
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
</div>
</div>
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold"></button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
>
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold"></button>
</motion.div>
)}
</AnimatePresence>
{/* 聚合卡 */}
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
</section>
{/* 批次 */}
{batches.length > 0 && (
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
<span className="text-[11px] font-bold text-slate-500"></span>
{batchFilter && (
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500"></button>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead className="text-slate-400 font-bold bg-slate-50/40">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right">()</th>
<th className="px-3 py-2 text-right">()</th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{batches.map(b => (
<tr
key={b.batch_id}
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
>
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* 列表 */}
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-bold text-slate-500"></span>
<span className="text-[10px] font-bold text-slate-400"> {total.toLocaleString()} </span>
<div className="flex-1" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
placeholder="搜索订单/车牌/电站"
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
/>
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
<button
key={k}
onClick={() => setFilter(k as typeof filter)}
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
>{label}</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead className="bg-slate-50/40 text-slate-400 font-bold">
<tr>
<th className="px-3 py-2 text-left whitespace-nowrap"></th>
<th className="px-3 py-2 text-left whitespace-nowrap"></th>
<th className="px-3 py-2 text-center whitespace-nowrap"></th>
<th className="px-3 py-2 text-left"> / </th>
<th className="px-3 py-2 text-right whitespace-nowrap">()</th>
<th className="px-3 py-2 text-right whitespace-nowrap">()</th>
<th className="px-3 py-2 text-right whitespace-nowrap">()</th>
<th className="px-3 py-2 text-left whitespace-nowrap"></th>
</tr>
</thead>
<tbody>
{items.map(it => (
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300"></span>}</td>
<td className="px-3 py-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
{KIND_LABEL[it.vehicle_kind]}
</span>
</td>
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
xlsx
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<RotatingFooterHint className="pb-4" />
</div>
<FeedbackFab module="ele" />
</div>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
</div>
);
}
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
const accentMap: Record<string, string> = {
slate: 'text-slate-700',
blue: 'text-blue-600',
amber: 'text-amber-600',
};
return (
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
<span className={accentMap[accent]}>{icon}</span>
<span>{label}</span>
</div>
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
</div>
);
}

View File

@@ -1,79 +0,0 @@
import { motion } from 'motion/react';
import { Construction, Hammer } from 'lucide-react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
const ETC_HINTS = [
'ETC 通行费数据正在与发卡方系统打通…',
'工人 GG 正在搭脚手架,敬请期待 ~',
'马上能看到每月通行费明细啦',
'想看哪个维度的 ETC反馈一下嘛',
'上线时机:等数据接通的那一天',
];
export default function ETCView() {
return (
<div className="flex flex-col gap-4">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="relative w-20 h-20 mb-4">
<motion.div
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
>
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
<motion.div
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
>
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
</div>
<div className="text-base font-black text-slate-800 mb-1.5">ETC </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
<br />
</div>
{/* 简单的里程碑进度感 */}
<div className="mt-6 w-full max-w-xs space-y-2">
{[
{ label: '需求评审', done: true },
{ label: '数据对接', done: true },
{ label: '页面开发', done: false, current: true },
{ label: '正式上线', done: false },
].map((m, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
className="flex items-center gap-2.5 text-[11px]"
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
m.done ? 'bg-emerald-400'
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
: 'bg-slate-200'
}`} />
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
{m.label}
</span>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto"></span>}
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto"></span>}
</motion.div>
))}
</div>
</motion.div>
<RotatingFooterHint hints={ETC_HINTS} />
</div>
);
}

View File

@@ -1,171 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<DateQuickPick>('last15');
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
fetchElectricMonthly(customer, pick)
.then(m => {
if (cancelled) return;
setMonths(m);
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
})
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [customer, pick]);
const toggleMonth = (m: string) => setOpenMonths(prev => {
const next = new Set(prev);
next.has(m) ? next.delete(m) : next.add(m);
return next;
});
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
return (
<div className="flex flex-col gap-3">
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{/* 客户类型 */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
{/* 外部车辆 数据未就绪 */}
{showExternalEmpty && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 月份分组表 */}
{!showExternalEmpty && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="text-right"> ()</span>
<span className="text-right"></span>
</div>
{error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : months === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.map(m => {
const open = openMonths.has(m.month);
return (
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
<button
onClick={() => toggleMonth(m.month)}
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
}`}
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{m.month}
</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
{m.rows.map(d => {
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: 'bg-slate-50/50';
return (
<div
key={d.date}
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
>
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<ElectricView sub={sub} />
</div>
</div>
);
}

View File

@@ -1,105 +0,0 @@
import { useEffect, useState } from 'react';
import { Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function fmtKwh(kwh: number) {
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
}
export default function ElectricOverview() {
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchElectricOverview()
.then(d => { if (!cancelled) setData(d); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, []);
if (error) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
}
if (!data) {
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm"></div>;
}
const k = data.kpi;
const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const currentMonth = new Date().toISOString().slice(0, 7);
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
return (
<div className="flex flex-col gap-3">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
2025-01-01
</div>
{/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-2 md:gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<Wallet size={11} className="text-blue-600" />
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<CalendarClock size={11} className="text-blue-600" />
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
</div>
</div>
{/* 本月每日充电柱图 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
<RotatingFooterHint />
</div>
);
}

View File

@@ -1,12 +0,0 @@
import ElectricOverview from './ElectricOverview';
import ElectricDaily from './ElectricDaily';
export type ElectricSubTab = 'daily' | 'overview';
interface Props {
sub: ElectricSubTab;
}
export default function ElectricView({ sub }: Props) {
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />;
}

View File

@@ -1,11 +0,0 @@
import ETCView from './ETCView';
export default function EtcModule() {
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
<ETCView />
</div>
</div>
);
}

View File

@@ -1,228 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
export default function HydrogenDaily() {
const [pick, setPick] = useState<DateQuickPick>('last15');
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
fetchHydrogenDaily(pick, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
return (
<div className="flex flex-col gap-3">
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{/* 客户类型 segmented */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="hidden md:block text-right"> (/Kg)</span>
<span className="text-right"> (Kg)</span>
<span className="text-right"></span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span></span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</div>
{/* 主行 + 子行 */}
{error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : rows === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: '';
return (
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300"></span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{s.name}
</div>
{s.pricePerKg > 0 && (
<div className="md:hidden mt-1">
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
{s.pricePerKg} /Kg
</span>
</div>
)}
</div>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<HydrogenView sub={sub} />
</div>
</div>
);
}

View File

@@ -1,641 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
// ---------- 数字格式化 ----------
function fmtKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
function fmtYuan(yuan: number): { value: string; unit: string } {
const abs = Math.abs(yuan);
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
if (abs >= 10_000) {
const w = yuan / 10_000;
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
}
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
}
// ---------- KPI 卡 ----------
interface KpiCardProps {
icon: React.ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
))}
</div>
</div>
);
}
// ============================================================
export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
const refreshSeq = useRef(0);
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
const seq = ++refreshSeq.current;
setRefreshing(true);
try {
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
if (seq !== refreshSeq.current) return; // outdated
setData(d);
setError(null);
setLastRefreshAt(Date.now());
} catch (e) {
if (seq !== refreshSeq.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (seq === refreshSeq.current) setRefreshing(false);
}
}, []);
// 初始加载 + 年份切换:用 force=false 命中热缓存
useEffect(() => { void load(year, false); }, [year, load]);
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
useEffect(() => {
const t = setInterval(() => { void load(year, false); }, 60_000);
return () => clearInterval(t);
}, [year, load]);
if (error && !data) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
}
if (!data) {
return <HydrogenOverviewSkeleton />;
}
const k = data.kpi;
const top5 = data.top5;
const regions = data.regions;
const monthly = data.monthly;
const customers = data.customers;
const stations = data.stations;
const availableYears = data.availableYears;
const activeYear = data.year;
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
...m,
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
}));
return (
<div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => {
const active = y === activeYear;
return (
<button
key={y}
onClick={() => setYear(y)}
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
}`}
>
{y}
</button>
);
})}
</div>
<button
onClick={() => void load(year, true)}
disabled={refreshing}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold"></span>
</button>
</div>
</div>
{/* KPI 5 卡 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
<KpiCard
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
iconBg="bg-cyan-50"
accentClass="text-slate-800"
label="累计加氢量"
hero={yearKgFmt}
rows={[
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
]}
/>
<KpiCard
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
iconBg="bg-blue-50"
accentClass="text-slate-800"
label="累计加氢费"
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
rows={[
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
iconBg="bg-emerald-50"
accentClass={profitColor}
label="时享加氢获利"
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
rows={[
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
iconBg="bg-amber-50"
accentClass="text-amber-600"
label="本月加氢"
hero={monthKgFmt}
rows={[
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
<KpiCard
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
iconBg="bg-violet-50"
accentClass="text-violet-600"
label="本日加氢"
hero={todayKgFmt}
rows={[
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
</div>
{/* 月度趋势:年内每月加氢量 */}
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
labelFormatter={(d) => `${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* 月度收支对比 */}
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Legend
verticalAlign="top"
height={20}
iconSize={8}
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
/>
<Tooltip
formatter={(v, name) => {
const f = fmtYuan(Number(v ?? 0));
return [`¥${f.value} ${f.unit}`, name];
}}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Top5 + 区域占比 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Top5 加氢站 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700"> Top5</span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{top5.map((_, i) => (
<Cell key={i} fill="url(#topBarGrad)" />
))}
<LabelList
dataKey="kg"
position="right"
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
fill="#475569"
fontSize={11}
fontWeight={700}
/>
</Bar>
<defs>
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
{/* 区域占比 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<span className="text-sm font-bold text-slate-700"></span>
<div className="flex items-center gap-2">
<div className="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold"></div>
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600 truncate">{r.region}</span>
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* 加氢站加氢汇总(全量) */}
{stations.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> {stations.length} </span>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 hidden sm:table-cell"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{stations.map((s, i) => {
const kgFmt = fmtKg(s.kg);
const revFmt = fmtYuan(s.revenue);
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
</div>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* 客户账单汇总 Top */}
{customers.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-center py-1.5 w-14 hidden sm:table-cell"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{customers.map((c2, i) => {
const kgFmt = fmtKg(c2.kg);
const costFmt = fmtYuan(c2.cost);
const revFmt = fmtYuan(c2.revenue);
return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 text-center hidden sm:table-cell">
{c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold"></span>
) : (
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold"></span>
)}
</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<RotatingFooterHint />
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
<AnimatePresence>
{refreshing && data && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function formatRelative(ts: number): string {
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 5) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
export type HydrogenSubTab = 'daily' | 'overview';
interface Props {
sub: HydrogenSubTab;
}
export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
}

View File

@@ -1,39 +0,0 @@
import type { ComponentType } from 'react';
interface SubTab<T extends string> {
id: T;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
}
interface Props<T extends string> {
tabs: readonly SubTab<T>[];
active: T;
onChange: (id: T) => void;
}
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
return (
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="p-1 flex gap-1">
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = active === id;
return (
<button
key={id}
onClick={() => onChange(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
export interface TrendBadgeProps {
value: number; // -1..+1, 0 表示持平
className?: string;
}
export default function TrendBadge({ value, className = '' }: TrendBadgeProps) {
const isUp = value > 0.0001;
const isDown = value < -0.0001;
const cls = isUp
? 'bg-emerald-50 text-emerald-600'
: isDown
? 'bg-red-50 text-red-600'
: 'bg-slate-100 text-slate-500';
const Icon = isUp ? ArrowUp : isDown ? ArrowDown : Minus;
const sign = isUp ? '+' : '';
return (
<span className={`inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-bold ${cls} ${className}`}>
<Icon size={11} />
{sign}{(value * 100).toFixed(2)}%
</span>
);
}

View File

@@ -1,47 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick,
} from './types';
const BASE = '/api/energy';
export interface HydrogenOverviewResponse {
kpi: HydrogenKpi;
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
monthly: HydrogenMonthlyPoint[];
customers: HydrogenCustomerRow[];
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
}
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams();
if (year) params.set('year', String(year));
if (force) params.set('force', '1');
const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
}
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ range, customer });
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export interface ElectricOverviewResponse {
kpi: ElectricKpi;
trend: ElectricDailyRow[];
}
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
}
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer, range });
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
}

View File

@@ -1,99 +0,0 @@
export type CustomerType = 'external' | 'lingniu';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi {
yearKg: number;
yearFee: number;
yearRevenue: number;
yearProfit: number;
ourYearKg: number;
ourYearFee: number;
customerYearKg: number;
monthKg: number;
monthFee: number;
monthRevenue: number;
monthProfit: number;
todayKg: number;
todayFee: number;
todayRevenue: number;
todayProfit: number;
lingniuBornKg: number;
lingniuBornFee: number;
}
export interface HydrogenStationTop {
rank: number;
name: string;
kg: number;
fee: number;
share: number;
}
export interface HydrogenRegionShare {
region: string;
kg: number;
share: number;
}
export interface HydrogenMonthlyPoint {
month: string; // YYYY-MM
kg: number;
fee: number;
revenue: number;
profit: number;
}
export interface HydrogenCustomerRow {
name: string;
payer: 'lingniu' | 'customer';
kg: number;
cost: number;
revenue: number;
}
export interface HydrogenStationFull {
name: string;
kg: number;
revenue: number;
share: number; // 加氢量占比
revenueShare: number;// 收入占比
}
export interface HydrogenStationRow {
name: string;
pricePerKg: number;
kg: number;
chainPct: number;
}
export interface HydrogenDailyRow {
date: string;
totalKg: number;
chainPct: number;
customerType: CustomerType;
stations: HydrogenStationRow[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
monthKwh: number;
monthFee: number;
todayKwh: number;
todayFee: number;
todayChainPct: number;
}
export interface ElectricDailyRow {
date: string;
kwh: number;
fee: number;
chainPct: number;
}
export interface ElectricMonthGroup {
month: string;
kwh: number;
fee: number;
rows: ElectricDailyRow[];
}

View File

@@ -1,38 +0,0 @@
import { useEffect, useState } from 'react';
/**
* 把模块内子 tab 状态同步到 URL hash 二级段。
* hash 形如 `#<moduleId>`= 默认 sub或 `#<moduleId>/<sub>`。
* 默认值不写入 hash刷新页面可恢复。
*/
export function useHashSubTab<T extends string>(
moduleId: string,
subs: readonly T[],
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => {
const hash = window.location.hash.slice(1);
const [first, second] = hash.split('/');
if (first !== moduleId) return defaultSub;
if (second && (subs as readonly string[]).includes(second)) return second as T;
return defaultSub;
};
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, [moduleId]);
const setSub = (next: T) => {
const { pathname, search } = window.location;
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
return [sub, setSub];
}

View File

@@ -4,7 +4,6 @@ import { motion } from 'motion/react';
import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView';
import RotatingFooterHint from '../../components/RotatingFooterHint';
export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
@@ -53,7 +52,6 @@ export default function MileageModule() {
) : (
<DailyReportView />
)}
<RotatingFooterHint />
</div>
</div>
);

View File

@@ -1,22 +1,13 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Truck, Filter, ChevronDown,
Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp, Download, Check,
ArrowUp, ArrowDown, ChevronsUp,
} from 'lucide-react';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api';
import Blur from '../../components/Blur';
import PlateMultiSelect from './PlateMultiSelect';
import { exportMileageXlsx } from './xlsx-export';
import VehicleDetailModal from './VehicleDetailModal';
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投40辆4.5T普货',
'交投190辆4.5T冷链车',
]);
const HIGH_MILEAGE_ALERT_KM = 800;
const SearchableSelect = ({
options,
@@ -98,129 +89,6 @@ const SearchableSelect = ({
);
};
const BatchMultiSelect = ({
options,
selected,
onChange,
placeholder,
}: {
options: string[],
selected: string[],
onChange: (val: string[]) => void,
placeholder: string
}) => {
const rootRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const selectedSet = useMemo(() => new Set(selected), [selected]);
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
const label = selected.length === 0
? placeholder
: selected.length === options.length
? '全部批次'
: selected.length === 1
? selected[0]
: `已选 ${selected.length} 个批次`;
const toggle = (opt: string) => {
if (selectedSet.has(opt)) {
onChange(selected.filter(item => item !== opt));
} else {
onChange([...selected, opt]);
}
};
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
onClick={() => setIsOpen(open => !open)}
>
<span className="block truncate">{label}</span>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-slate-50">
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder="搜索批次"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
onClick={() => onChange(options)}
>
</button>
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
onClick={() => onChange([])}
>
</button>
</div>
<div className="max-h-44 overflow-y-auto">
{filtered.map((opt: string) => {
const checked = selectedSet.has(opt);
return (
<button
type="button"
key={opt}
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
onClick={() => toggle(opt)}
>
<span className="truncate">{opt}</span>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
<Check size={10} />
</span>
</button>
);
})}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All');
@@ -234,18 +102,15 @@ export default function MonitoringView() {
const [fullscreenLoading, setFullscreenLoading] = useState(false);
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>([]);
const [filterPlate, setFilterPlate] = useState('All');
const [filterCustomer, setFilterCustomer] = useState('All');
const [filterProject, setFilterProject] = useState('All');
const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All');
const [filterTargetName, setFilterTargetName] = useState('All');
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [exporting, setExporting] = useState(false);
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [filterDate, setFilterDate] = useState(() => {
const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
@@ -254,7 +119,7 @@ export default function MonitoringView() {
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] });
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
@@ -266,12 +131,6 @@ export default function MonitoringView() {
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
// 加载首页数据
const loadFirstPage = useCallback(() => {
setPageLoading(true);
@@ -287,9 +146,8 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined,
@@ -301,7 +159,7 @@ export default function MonitoringView() {
setPage(1);
setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]);
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
// 加载更多
const loadMore = useCallback(() => {
@@ -320,9 +178,8 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined,
@@ -331,53 +188,13 @@ export default function MonitoringView() {
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载
useEffect(() => {
loadFirstPage();
}, [loadFirstPage]);
// 区域级联plate 选项收窄后,剔除已选但已不属于该区域的车牌
useEffect(() => {
if (filterPlates.length === 0) return;
const valid = new Set(filterOptions.plates);
const next = filterPlates.filter(p => valid.has(p));
if (next.length !== filterPlates.length) setFilterPlates(next);
}, [filterOptions.plates, filterPlates]);
// 下载当前筛选结果为 xlsx
const handleDownload = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
const d = await fetchMonitoring({
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined,
});
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
} catch (err) {
console.error('export failed', err);
} finally {
setExporting(false);
}
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]);
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(loadFirstPage, 60 * 1000);
@@ -442,16 +259,15 @@ export default function MonitoringView() {
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
date: filterDate || undefined,
}).then(d => {
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]);
// 全屏时禁止背景滚动
useEffect(() => {
@@ -538,14 +354,14 @@ export default function MonitoringView() {
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
<button
onClick={() => setFilterTargetNames([])}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
onClick={() => setFilterTargetName('All')}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === 'All' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
></button>
{filterOptions.targetNames.map(n => (
<button
key={n}
onClick={() => setFilterTargetNames(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : n)}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === n ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
))}
</div>
@@ -575,9 +391,14 @@ export default function MonitoringView() {
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<span className="text-[9px] text-slate-500 font-normal">
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
</span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterPlate}
onChange={(e) => setFilterPlate(e.target.value)}
>
<option value="All"></option>
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
@@ -660,30 +481,27 @@ export default function MonitoringView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
return (
{fullscreenVehicles.map((v) => (
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-2 text-center">
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : v.isDataSynced ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
</td>
<td className="px-3 py-2 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-blue-400' : 'text-amber-400'}`}>
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span>
</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
{v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
{v.isDataSynced && v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span>
</td>
</tr>
);
})}
))}
</tbody>
</table>
</div>
@@ -708,18 +526,10 @@ export default function MonitoringView() {
>
<Maximize2 size={14} />
</button>
<button
onClick={handleDownload}
disabled={exporting}
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
title="下载当前筛选结果"
>
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight"> 15</span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight"> </span>
</div>
</div>
</div>
@@ -749,32 +559,32 @@ export default function MonitoringView() {
</div>
</div>
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
<div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5">
<BatchMultiSelect
options={filterOptions.targetNames}
selected={filterTargetNames}
onChange={setFilterTargetNames}
placeholder="批次型号"
<SearchableSelect
options={['__EMPTY__', ...departments]}
value={filterDept}
onChange={setFilterDept}
placeholder="按部门"
/>
<SearchableSelect
options={filterOptions.regions}
value={filterRegion}
onChange={setFilterRegion}
placeholder="运营区域"
options={['__EMPTY__', ...filterOptions.customers]}
value={filterCustomer}
onChange={setFilterCustomer}
placeholder="按客户"
/>
<PlateMultiSelect
allPlates={plateNumbers}
selected={filterPlates}
onChange={setFilterPlates}
<SearchableSelect
options={plateNumbers}
value={filterPlate}
onChange={setFilterPlate}
placeholder="按车牌"
/>
</div>
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
>
<Filter size={16} />
</button>
@@ -802,10 +612,23 @@ export default function MonitoringView() {
/>
</div>
{/* Project */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterProject}
onChange={(e) => setFilterProject(e.target.value)}
>
<option value="All"></option>
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Department */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDept}
@@ -817,35 +640,6 @@ export default function MonitoringView() {
</select>
</div>
{/* Customer */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterCustomer}
onChange={(e) => setFilterCustomer(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Project */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterProject}
onChange={(e) => setFilterProject(e.target.value)}
>
<option value="All"></option>
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
{/* Rent Status */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
@@ -875,6 +669,19 @@ export default function MonitoringView() {
</div>
</div>
{/* Target Name */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterTargetName}
onChange={(e) => setFilterTargetName(e.target.value)}
>
<option value="All"></option>
{filterOptions.targetNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
{/* Plate Prefix */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
@@ -917,13 +724,11 @@ export default function MonitoringView() {
onClick={() => {
setSearchTerm('');
setFilterDept('All');
setFilterPlates([]);
setFilterPlate('All');
setFilterCustomer('All');
setFilterProject('All');
setFilterEntity('All');
setFilterPlatePrefix('All');
setFilterTargetNames([]);
setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' });
setAppliedMileageRange({ min: '', max: '' });
}}
@@ -949,26 +754,22 @@ export default function MonitoringView() {
{/* Active Filter Tags */}
{(() => {
const tags: { label: string; onClear: () => void }[] = [];
if (filterTargetNames.length > 0) tags.push({
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]}${filterTargetNames.length}`}`,
onClear: () => setFilterTargetNames([])
});
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]}${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
if (filterPlatePrefix !== 'All') tags.push({ label: `区域: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
if (tags.length === 0) return null;
const clearAll = () => {
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All');
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterDate('');
};
@@ -1037,15 +838,15 @@ export default function MonitoringView() {
)}
<div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
return (
{filteredVehicles.map((v) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
key={v.plate}
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
onClick={() => setDetailVehicle(v)}
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all"
onClick={() => {
navigator.clipboard.writeText(v.plate);
}}
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
@@ -1069,24 +870,23 @@ export default function MonitoringView() {
</div>
<div className="text-right flex-shrink-0 ml-2 flex flex-col items-end">
<div className="flex items-center gap-1 mb-0.5">
{!v.isDataSynced && v.totalKm == null && (
{!v.isDataSynced && (
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
)}
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none"></span>
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70"></span>}
<div className={`text-sm font-black leading-none ${v.isDataSynced ? 'text-blue-600' : 'text-amber-600'}`}>
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70"></span>}
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none"></span>
<span className="text-[8px] font-bold text-slate-300">
{v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
{v.isDataSynced && v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
</span>
</div>
</div>
</motion.div>
);
})}
))}
</div>
{filteredVehicles.length === 0 && !loadingMore && (
@@ -1110,8 +910,6 @@ export default function MonitoringView() {
<div ref={sentinelRef} className="h-1" />
</div>
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
{/* 回到顶部按钮 */}
<AnimatePresence>
{showBackToTop && (

View File

@@ -1,197 +0,0 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { ChevronDown, X, AlertTriangle } from 'lucide-react';
interface Props {
allPlates: string[];
selected: string[];
onChange: (plates: string[]) => void;
placeholder?: string;
}
function parseInput(text: string): string[] {
return text
.split(/[\s,;,;、]+/)
.map(s => s.trim())
.filter(Boolean);
}
export default function PlateMultiSelect({ allPlates, selected, onChange, placeholder = '按车牌(可多选/粘贴)' }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [text, setText] = useState('');
const [search, setSearch] = useState('');
const [unmatched, setUnmatched] = useState<string[]>([]);
const wrapRef = useRef<HTMLDivElement>(null);
const allSet = useMemo(() => new Set(allPlates), [allPlates]);
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setIsOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen]);
const filtered = useMemo(() => {
if (!search) return allPlates.slice(0, 200);
const q = search.toLowerCase();
return allPlates.filter(p => p.toLowerCase().includes(q)).slice(0, 200);
}, [allPlates, search]);
const selectedSet = useMemo(() => new Set(selected), [selected]);
const apply = (input: string) => {
const tokens = parseInput(input);
if (tokens.length === 0) return;
const matched: string[] = [];
const missed: string[] = [];
const seen = new Set(selected);
for (const t of tokens) {
if (allSet.has(t)) {
if (!seen.has(t)) {
matched.push(t);
seen.add(t);
}
} else {
missed.push(t);
}
}
if (matched.length > 0) onChange([...selected, ...matched]);
setUnmatched(missed);
setText('');
};
const togglePlate = (plate: string) => {
if (selectedSet.has(plate)) {
onChange(selected.filter(p => p !== plate));
} else {
onChange([...selected, plate]);
}
};
const removePlate = (plate: string) => {
onChange(selected.filter(p => p !== plate));
};
const clearAll = () => {
onChange([]);
setUnmatched([]);
setText('');
};
const display = selected.length === 0
? placeholder
: selected.length === 1
? selected[0]
: `${selected[0]}${selected.length} 个车牌`;
return (
<div className="relative" ref={wrapRef}>
<div
onClick={() => setIsOpen(o => !o)}
className={`w-full bg-slate-50 rounded-lg py-1.5 px-2 text-[10px] font-bold cursor-pointer flex items-center justify-between gap-1 ${selected.length > 0 ? 'text-blue-600 ring-1 ring-blue-200' : 'text-slate-600'}`}
>
<span className="truncate">{display}</span>
<ChevronDown size={10} className="text-slate-400 flex-shrink-0" />
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl"
style={{ width: 'min(280px, calc(100vw - 24px))', minWidth: '100%' }}
>
<div className="p-2 space-y-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="粘贴或输入车牌&#10;支持换行/逗号/空格分隔,回车或点添加确认"
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
apply(text);
}
}}
/>
<div className="flex justify-between items-center">
<span className="text-[10px] text-slate-400"> <span className="font-bold text-blue-600">{selected.length}</span> </span>
<div className="flex gap-1">
<button
onClick={() => apply(text)}
disabled={!text.trim()}
className="px-2 py-1 bg-blue-600 text-white rounded-md text-[10px] font-bold disabled:bg-slate-200 disabled:text-slate-400"
></button>
<button
onClick={clearAll}
className="px-2 py-1 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold"
></button>
</div>
</div>
{unmatched.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 space-y-1">
<div className="flex items-center gap-1 text-amber-700">
<AlertTriangle size={10} />
<span className="text-[10px] font-bold">{unmatched.length} </span>
<button
onClick={() => setUnmatched([])}
className="ml-auto text-amber-500 hover:text-amber-700"
><X size={10} /></button>
</div>
<div className="text-[10px] text-amber-600 break-all max-h-16 overflow-y-auto leading-relaxed">
{unmatched.join('')}
</div>
</div>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto p-1 bg-slate-50 rounded-lg">
{selected.map(p => (
<span key={p} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white border border-blue-100 text-blue-600 rounded text-[10px] font-bold">
{p}
<button onClick={() => removePlate(p)} className="text-blue-400 hover:text-blue-700"><X size={9} /></button>
</span>
))}
</div>
)}
<div className="border-t border-slate-100 pt-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索车牌"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30"
/>
<div className="mt-1 max-h-40 overflow-y-auto">
{filtered.map(p => (
<div
key={p}
onClick={() => togglePlate(p)}
className={`px-2 py-1 text-[10px] font-bold cursor-pointer flex items-center gap-1.5 rounded ${selectedSet.has(p) ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
>
<span className={`w-3 h-3 rounded border ${selectedSet.has(p) ? 'bg-blue-600 border-blue-600' : 'border-slate-300'} flex items-center justify-center`}>
{selectedSet.has(p) && <span className="text-white text-[8px] leading-none"></span>}
</span>
{p}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-1 text-[10px] text-slate-300 italic"></div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -9,7 +9,7 @@ import {
Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
@@ -19,31 +19,11 @@ function getDefaultDate(): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentDateLabel(): string {
const now = new Date();
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
}
function fmtPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
if (target.yearlyAssessments.length === 0) return null;
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
}
function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
}
function shortTargetName(name: string): string {
// Extract the number and a short description
const match = name.match(/(\d+)[辆台](.+)/);
@@ -59,7 +39,6 @@ function shortTargetName(name: string): string {
}
export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
@@ -67,8 +46,7 @@ export default function StatisticsView() {
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
@@ -76,25 +54,12 @@ export default function StatisticsView() {
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
// Load targets on mount
useEffect(() => {
fetchTargets().then(data => {
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
const ordered = focused
? [focused, ...data.filter(item => item.id !== focused.id)]
: data;
setTargets(ordered);
if (ordered.length > 0 && !selectedTargetId) {
setSelectedTargetId(focused.id);
setExpandedTargetId(focused.id);
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
fetchTargetVehicles(focused.id).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
}).catch(() => {});
setTargets(data);
if (data.length > 0 && !selectedTargetId) {
setSelectedTargetId(data[0].id);
}
}).catch(() => {});
}, []);
@@ -115,7 +80,7 @@ export default function StatisticsView() {
}, [viewAllTargetId, viewAllDate]);
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
{/* Project Selector */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
@@ -138,7 +103,7 @@ export default function StatisticsView() {
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
{/* KPI Cards in Landscape — linked to selected target */}
{(() => {
const sel = selectedTarget;
const sel = targets.find(t => t.id === selectedTargetId);
return (
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
@@ -165,7 +130,7 @@ export default function StatisticsView() {
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedCompletion.toFixed(1)}
{(sel?.avgCompletion ?? 0).toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
@@ -259,17 +224,12 @@ export default function StatisticsView() {
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{targets.map((target, idx) => (
(() => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
return (
<div
key={idx}
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
setExpandedTargetId(expandedTargetId === target.id ? null : target.id);
const name = target.targetName;
setExpandedModel(expandedModel === name ? null : name);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
@@ -289,12 +249,12 @@ export default function StatisticsView() {
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
<span className="text-[9px] text-slate-400">:</span>
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}</span>
<span className="text-[9px] text-slate-400">:</span>
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}</span>
</div>
</div>
</div>
@@ -309,7 +269,7 @@ export default function StatisticsView() {
</div>
</div>
<motion.div
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }}
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
className="text-slate-300"
>
<ChevronDown size={14} />
@@ -317,103 +277,52 @@ export default function StatisticsView() {
</div>
</div>
<AnimatePresence initial={false} mode="wait">
{expandedTargetId === target.id && (
<AnimatePresence>
{expandedModel === target.targetName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
<span className="text-[10px] font-black text-blue-700"></span>
<select
value={assessment?.yearNumber || ''}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
}}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
>
{target.yearlyAssessments.map(item => (
<option key={item.yearNumber} value={item.yearNumber}>
{item.label}
</option>
))}
</select>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">/</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
</div>
{assessment ? (
<>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}/</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} </p>
{assessment.vehicleCount < target.vehicleCount && (
<p className="text-[8px] font-bold text-slate-400"> {target.vehicleCount - assessment.vehicleCount} {assessment.label}</p>
)}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
{assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount})</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-500">
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
</p>
</div>
</>
) : (
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
</div>
)}
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} </span>
<span className="text-[9px] font-bold text-slate-500"></span>
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} </span>
</div>
{/* Vehicle List Detail */}
@@ -454,8 +363,6 @@ export default function StatisticsView() {
)}
</AnimatePresence>
</div>
);
})()
))}
</div>
</div>
@@ -484,7 +391,7 @@ export default function StatisticsView() {
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -510,12 +417,12 @@ export default function StatisticsView() {
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">/</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">/</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14"></th>
@@ -523,22 +430,10 @@ export default function StatisticsView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, idx) => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const completion = assessment?.completionRate ?? target.avgCompletion;
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
const goal = assessment?.target ?? target.currentYearTarget;
const completed = assessment?.completed ?? target.currentYearCompleted;
const remainingMileage = assessment?.remaining ?? target.remaining;
const days = assessment?.daysLeft ?? target.daysLeft;
const daily = assessment?.dailyTarget ?? target.dailyTarget;
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
return (
{targets.map((target, idx) => (
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
@@ -546,31 +441,28 @@ export default function StatisticsView() {
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(completion, 100)}%` }}
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(completed)}</span>
<span>/ {fmtKm(goal)} km</span>
<span>{fmtKm(target.cumulativeTotal)}</span>
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
</tr>
);
})}
))}
</tbody>
</table>
</div>

View File

@@ -1,342 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { X, Truck } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts';
import type { MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
interface Props {
vehicle: MonitoringVehicle | null;
onClose: () => void;
}
type RangeKey = 'last15' | 'month' | 'quarter';
const RANGE_TABS: { key: RangeKey; label: string }[] = [
{ key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' },
];
function fmtYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = fmtYmd(today);
if (key === 'last15') {
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
}
if (key === 'month') {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
return { start: fmtYmd(start), end, rangeLabel: '本月' };
}
const q = Math.floor(today.getMonth() / 3);
const start = new Date(today.getFullYear(), q * 3, 1);
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
}
function isToday(date: string): boolean {
return date === fmtYmd(new Date());
}
function formatLabel(date: string, key: RangeKey): string {
// YYYY-MM-DD → MM-DD季度时仍展示 MM-DD
void key;
return date.slice(5);
}
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15');
const dragControls = useDragControls();
// 切换车辆时重置区间为默认
useEffect(() => {
if (vehicle) setRange('last15');
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
// 拉取数据(车辆或区间变化)
useEffect(() => {
if (!vehicle) return;
const { start, end } = rangeFor(range);
setLoading(true);
setDays([]);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end })
.then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
if (!vehicle) return;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [vehicle]);
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length;
const avg = synced > 0 ? totalKm / synced : 0;
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
return { totalKm, synced, avg, max, totalDays: historyDays.length };
}, [historyDays]);
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
if (range === 'last15') return 15;
const { start, end } = rangeFor(range);
const s = new Date(start);
const e = new Date(end);
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
}, [range]);
return (
<AnimatePresence>
{vehicle && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={onClose}
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{
y: { type: 'spring', damping: 32, stiffness: 320 },
opacity: { duration: 0.18 },
}}
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 600) onClose();
}}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
onClick={(e) => e.stopPropagation()}
>
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
<div
className="flex justify-center pt-2.5 pb-1.5 cursor-grab active:cursor-grabbing select-none"
onPointerDown={(e) => dragControls.start(e)}
style={{ touchAction: 'none' }}
>
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 pb-2 border-b border-slate-100">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
<Truck size={16} className="text-blue-600" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="text-[10px] font-bold text-slate-400 truncate">
{vehicle.rentStatus || ''}
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
{vehicle.customer ? ` · ` : ''}
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
</div>
</div>
</div>
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<X size={18} />
</button>
</div>
{/* 时间范围切换 */}
<div className="px-4 pt-3">
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
{RANGE_TABS.map(tab => (
<button
key={tab.key}
onClick={() => setRange(tab.key)}
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
>
{range === tab.key && (
<motion.div
layoutId="rangeTabBg"
className="absolute inset-0 bg-white shadow-sm rounded-md"
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
/>
)}
<span className="relative">{tab.label}</span>
</button>
))}
</div>
</div>
{/* KPI cards */}
<div className="px-4 py-3 grid grid-cols-3 gap-2">
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
</div>
</div>
</div>
{/* Bar chart */}
<div className="px-4 pb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-slate-500"></span>
<span className="text-[9px] font-bold text-slate-300"> km</span>
</div>
<div className="bg-white rounded-xl border border-slate-50">
<div className="h-[140px]">
{loading ? (
<SkeletonBars count={Math.min(skeletonCount, 30)} />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(d) => formatLabel(d, range)}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={6}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
{historyDays.map((d, i) => (
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
{/* 每日明细 */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
<div className="text-[10px] font-bold text-slate-500 mb-1.5"></div>
{loading ? (
<SkeletonList count={Math.min(skeletonCount, 15)} />
) : (
<motion.div
key={range}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-1"
>
{historyDays.slice().reverse().map((d, i) => (
<motion.div
key={d.date}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
>
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
/>
</div>
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
{d.isDataSynced
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
: <span className="text-[9px]"></span>}
</span>
</div>
</motion.div>
))}
</motion.div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function SkeletonBars({ count }: { count: number }) {
return (
<div className="flex items-end gap-1 h-full px-2 pb-2 pt-2">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex-1 bg-slate-100 rounded-t animate-pulse"
style={{
height: `${30 + Math.sin(i * 0.7) * 25 + Math.cos(i * 0.4) * 15 + 30}%`,
animationDelay: `${i * 40}ms`,
}}
/>
))}
</div>
);
}
function SkeletonList({ count }: { count: number }) {
return (
<div className="space-y-1">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2 animate-pulse" style={{ animationDelay: `${i * 30}ms` }}>
<div className="h-3 w-20 bg-slate-100 rounded" />
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
</div>
))}
</div>
);
}

View File

@@ -16,8 +16,6 @@ export async function fetchMonitoring(params?: {
rentStatus?: string;
platePrefix?: string;
targetName?: string;
targetNames?: string[];
region?: string;
plate?: string;
mileageMin?: string;
mileageMax?: string;
@@ -36,12 +34,6 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region);
if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
@@ -67,29 +59,3 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
params.set('days', String(days));
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
}
export interface VehicleRecentDay {
date: string;
dailyKm: number;
isDataSynced: boolean;
}
export interface VehicleRecentResponse {
plate: string;
start?: string;
end?: string;
days: VehicleRecentDay[];
}
export async function fetchVehicleRecent(
plate: string,
range: { days?: number; start?: string; end?: string } = { days: 15 },
): Promise<VehicleRecentResponse> {
const params = new URLSearchParams();
if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days));
return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
);
}

View File

@@ -12,8 +12,6 @@ export interface MonitoringVehicle {
rentStatus: string | null;
entity: string | null;
project: string | null;
region: string | null;
targetNames: string[];
}
export interface MonitoringStats {
@@ -32,7 +30,6 @@ export interface MonitoringFilters {
rentStatuses: string[];
platePrefixes: { prefix: string; count: number }[];
targetNames: string[];
regions: string[];
}
export interface MonitoringData {
@@ -64,37 +61,6 @@ export interface TargetSummary {
remaining: number;
daysLeft: number;
dailyTarget: number;
firstYearVehicleCount: number;
firstYearTarget: number;
firstYearCompleted: number;
firstYearRemaining: number;
firstYearCompletionRate: number;
firstYearQualifiedCount: number;
firstYearQualifiedRate: number;
firstYearHalfQualifiedCount: number;
firstYearDaysLeft: number;
firstYearDailyTarget: number;
firstYearStartDate: string | null;
firstYearEndDate: string | null;
yearlyAssessments: TargetYearlyAssessment[];
}
export interface TargetYearlyAssessment {
yearNumber: number;
label: string;
vehicleCount: number;
target: number;
completed: number;
remaining: number;
completionRate: number;
qualifiedCount: number;
qualifiedRate: number;
halfQualifiedCount: number;
daysLeft: number;
dailyTarget: number;
startDate: string | null;
endDate: string | null;
periods: string[];
}
export interface TargetVehicle {

View File

@@ -1,91 +0,0 @@
import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
interface ExportContext {
date: string;
sortBy: 'today' | 'total';
}
const HEADERS = [
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域', '今日里程(km)', '累计里程(km)',
] as const;
function statusLabel(v: MonitoringVehicle): string {
if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线';
}
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') {
// 当日未对接但有历史累计,视作今日 0只有完全无数据才标「未对接」
if (!v.isDataSynced && v.totalKm == null) return '未对接';
return Math.max(0, v.dailyKm || 0);
}
return v.totalKm != null ? v.totalKm : '未对接';
}
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
const data: (string | number)[][] = [
[...HEADERS],
...vehicles.map(v => [
statusLabel(v),
v.plate,
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
mileageCell(v, 'today'),
mileageCell(v, 'total'),
]),
];
const ws = XLSX.utils.aoa_to_sheet(data);
ws['!cols'] = [
{ wch: 8 }, // 状态
{ wch: 12 }, // 车牌号
{ wch: 28 }, // 客户
{ wch: 14 }, // 业务部门
{ wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域
{ wch: 14 }, // 今日里程
{ wch: 14 }, // 累计里程
];
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
for (let r = 1; r < data.length; r++) {
for (const c of [7, 8]) {
const ref = XLSX.utils.encode_cell({ r, c });
if (ws[ref]?.t === 'n') ws[ref].z = '0.##########';
}
}
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
for (let c = 0; c < HEADERS.length; c++) {
const ref = XLSX.utils.encode_cell({ r: 0, c });
if (ws[ref]) {
(ws[ref] as { s?: unknown }).s = {
font: { bold: true, color: { rgb: 'FFFFFF' } },
fill: { fgColor: { rgb: '2563EB' } },
alignment: { horizontal: 'center', vertical: 'center' },
};
}
}
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
XLSX.writeFile(wb, filename);
}

View File

@@ -8,7 +8,6 @@ import SuggestionDetail from './SuggestionDetail';
import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
type TypeFilter = 'all' | 'qualified' | 'hopeless';
@@ -633,7 +632,6 @@ export default function SchedulingModule() {
</div>
)}
</div>
<RotatingFooterHint className="pb-4" />
</div>
);
}

View File

@@ -52,15 +52,11 @@ app.get('/exchange', async (c) => {
// 查询 depCode 对应的部门名称
let depName = '';
if (userInfo.depCode) {
try {
const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode]
) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || '';
} catch (e: any) {
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
}
const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode]
) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || '';
}
const payload: JwtPayload = {

View File

@@ -4,8 +4,8 @@ import type { JwtPayload, AuthUser } from './types.js';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
const BYPASS_AUTH = false;
// 演示分支:跳过所有认证(保留完整逻辑便于快速恢复)
const BYPASS_AUTH = true;
export async function authMiddleware(c: Context, next: Next) {
const path = c.req.path;
@@ -14,21 +14,6 @@ export async function authMiddleware(c: Context, next: Next) {
return next();
}
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
if (process.env.DEV_BYPASS_AUTH === '1') {
const devUser: AuthUser = {
userId: 'dev-local',
userName: '本地开发',
loginName: 'dev-local',
depCode: '',
depName: '',
permissionLevel: 'full',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
};
c.set('user', devUser);
return next();
}
// 跳过不需要认证的路径
if (path === '/api/health' || path.startsWith('/api/auth/')) {
return next();

View File

@@ -28,9 +28,5 @@ export {
FULL_ACCESS_ROLES,
DEPT_ACCESS_ROLES,
SCHEDULING_ACCESS_ROLES,
FEEDBACK_ADMIN_ROLES,
ENERGY_ACCESS_ROLES,
canAccessScheduling,
canManageFeedback,
canAccessEnergy,
} from '../../shared/auth/roles.js';

View File

@@ -1,17 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const hydrogenPool = mysql.createPool({
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || 'root',
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default hydrogenPool;

View File

@@ -6,9 +6,6 @@ import dotenv from 'dotenv';
import vehiclesRouter from './routes/vehicles.js';
import mileageRouter from './routes/mileage/index.js';
import schedulingRouter from './routes/scheduling/index.js';
import energyRouter from './routes/energy/index.js';
import eleRouter from './routes/ele/index.js';
import feedbackRouter from './routes/feedback/index.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
@@ -28,9 +25,6 @@ app.use('/api/*', authMiddleware);
app.route('/api/vehicles', vehiclesRouter);
app.route('/api/mileage', mileageRouter);
app.route('/api/scheduling', schedulingRouter);
app.route('/api/energy', energyRouter);
app.route('/api/ele', eleRouter);
app.route('/api/feedback', feedbackRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));

View File

@@ -1,14 +1,11 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const mileagePool = mysql.createPool({
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
host: '101.133.130.65',
port: 3306,
user: 'bi_reader_02',
password: 'bi_reader_02_Pass',
database: 'hydrogen_energy',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,

View File

@@ -1,355 +0,0 @@
import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db.js';
import { ensureChargeRecordTable } from './migration.js';
const app = new Hono();
// 与 xlsx 列名对齐
const COL = {
orderNo: '订单编号',
stationNo: '电站编号',
stationName: '电站名称',
terminalName: '终端名称',
region: '所属大区',
city: '所属城市',
district: '市区名称',
operatingCompany:'运营公司',
stationType: '电站类型',
orderStatus: '订单状态',
chargeForm: '充电形式',
startTime: '充电开始时间',
endTime: '充电结束时间',
duration: '充电时长(分钟)',
kwh: '充电电量(度)',
eFee: '充电电费(元)',
serviceFee: '充电服务费(元)',
fee: '充电费用(元)',
plate: '车牌号',
judgedPlate: '判定车牌号',
vin: '车架号',
customerName: '真实姓名',
customerPhone: '手机号',
enterpriseName: '企业名称',
} as const;
function safeStr(v: unknown, max = 250): string | null {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
return s.slice(0, max);
}
function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function safeDt(v: unknown): string | null {
const s = safeStr(v);
if (!s) return null;
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
// 简单校验
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
}
function normalizePlate(p: unknown): string | null {
const s = safeStr(p, 32);
if (!s) return null;
// 去掉所有空白字符
const trimmed = s.replace(/\s+/g, '').toUpperCase();
return trimmed || null;
}
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
// 寻找含"订单编号"和"车牌号"的那一行
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!Array.isArray(row)) continue;
const cells = row.map(c => (c == null ? '' : String(c)));
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
return { headerIdx: i, header: cells };
}
}
return null;
}
interface ParsedRow {
orderNo: string;
raw: Record<string, unknown>;
values: {
stationNo: string | null; stationName: string | null; terminalName: string | null;
region: string | null; city: string | null; district: string | null;
operatingCompany: string | null; stationType: string | null;
orderStatus: string | null; chargeForm: string | null;
startTime: string | null; endTime: string | null;
duration: number | null; kwh: number | null;
eFee: number | null; serviceFee: number | null; fee: number | null;
plate: string | null; judgedPlate: string | null; vin: string | null;
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
};
}
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
if (!ws) return [];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
const found = findHeaderRow(rows as unknown[][]);
if (!found) return [];
const { headerIdx, header } = found;
const idx = (label: string) => header.indexOf(label);
const result: ParsedRow[] = [];
for (let r = headerIdx + 1; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
const orderNo = safeStr(row[idx(COL.orderNo)]);
if (!orderNo) continue;
const raw: Record<string, unknown> = {};
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
result.push({
orderNo,
raw,
values: {
stationNo: safeStr(row[idx(COL.stationNo)]),
stationName: safeStr(row[idx(COL.stationName)]),
terminalName: safeStr(row[idx(COL.terminalName)]),
region: safeStr(row[idx(COL.region)]),
city: safeStr(row[idx(COL.city)]),
district: safeStr(row[idx(COL.district)]),
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
stationType: safeStr(row[idx(COL.stationType)]),
orderStatus: safeStr(row[idx(COL.orderStatus)]),
chargeForm: safeStr(row[idx(COL.chargeForm)]),
startTime: safeDt(row[idx(COL.startTime)]),
endTime: safeDt(row[idx(COL.endTime)]),
duration: safeNum(row[idx(COL.duration)]),
kwh: safeNum(row[idx(COL.kwh)]),
eFee: safeNum(row[idx(COL.eFee)]),
serviceFee: safeNum(row[idx(COL.serviceFee)]),
fee: safeNum(row[idx(COL.fee)]),
plate: normalizePlate(row[idx(COL.plate)]),
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
vin: safeStr(row[idx(COL.vin)]),
customerName: safeStr(row[idx(COL.customerName)]),
customerPhone: safeStr(row[idx(COL.customerPhone)]),
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
},
});
}
return result;
}
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
if (plates.size === 0) return new Map();
const arr = Array.from(plates);
const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
arr,
);
const map = new Map<string, string>();
for (const r of rows) {
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
}
return map;
}
// =========================================================
// POST /api/ele/import — 上传 xlsx 文件
// =========================================================
app.post('/import', async (c) => {
await ensureChargeRecordTable();
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const filename = file.name || 'unnamed.xlsx';
const buf = await file.arrayBuffer();
let parsed: ParsedRow[];
try {
parsed = parseSheet(buf);
} catch (e) {
console.error('parseSheet error:', e);
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
}
if (parsed.length === 0) {
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
}
// 文件内去重
const dedupMap = new Map<string, ParsedRow>();
for (const p of parsed) dedupMap.set(p.orderNo, p);
const records = Array.from(dedupMap.values());
const fileDuplicates = parsed.length - records.length;
// 系统车辆匹配
const allPlates = new Set<string>();
for (const r of records) {
if (r.values.plate) allPlates.add(r.values.plate);
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
}
const plateMap = await buildPlateLookup(allPlates);
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const importedAt = new Date();
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
const sql = `INSERT IGNORE INTO bi_ele_charge_record
(order_no, station_no, station_name, terminal_name, region, city, district,
operating_company, station_type, order_status, charge_form,
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
matched_truck_id, matched_plate, vehicle_kind, raw_json,
batch_id, imported_at)
VALUES ?`;
const values = records.map(r => {
const plate = r.values.plate || r.values.judgedPlate;
const matchedId = plate ? plateMap.get(plate) || null : null;
// 命中系统车辆=internal其余含车牌为空一律 external
const kind = matchedId ? 'internal' : 'external';
return [
r.orderNo,
r.values.stationNo, r.values.stationName, r.values.terminalName,
r.values.region, r.values.city, r.values.district,
r.values.operatingCompany, r.values.stationType,
r.values.orderStatus, r.values.chargeForm,
r.values.startTime, r.values.endTime, r.values.duration,
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
r.values.plate, r.values.judgedPlate, r.values.vin,
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
matchedId, matchedId ? plate : null, kind,
JSON.stringify(r.raw),
batchId, importedAt,
];
});
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
const inserted = result.affectedRows;
const dbDuplicates = records.length - inserted;
// 统计内/外(无车牌也算外部)
let internal = 0, external = 0;
for (const r of records) {
const plate = r.values.plate || r.values.judgedPlate;
if (plate && plateMap.has(plate)) internal++;
else external++;
}
return c.json({
ok: true,
filename,
batchId,
parsed: parsed.length,
fileDuplicates,
inserted,
dbDuplicates,
breakdown: { internal, external },
});
});
// =========================================================
// GET /api/ele/list — 分页列表(最新优先)
// =========================================================
app.get('/list', async (c) => {
await ensureChargeRecordTable();
const page = Math.max(1, Number(c.req.query('page')) || 1);
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
const kind = c.req.query('kind') || '';
const batchId = c.req.query('batchId') || '';
const search = c.req.query('search') || '';
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (kind === 'internal' || kind === 'external') {
where.push('vehicle_kind = ?');
params.push(kind);
}
if (batchId) {
where.push('batch_id = ?');
params.push(batchId);
}
if (search) {
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
const q = `%${search}%`;
params.push(q, q, q);
}
const offset = (page - 1) * limit;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, order_no, station_name, terminal_name, region, city,
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
plate, judged_plate, customer_name, vehicle_kind,
batch_id, imported_at
FROM bi_ele_charge_record
WHERE ${where.join(' AND ')}
ORDER BY start_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
);
const [countRows] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
params,
);
const total = Number(countRows[0]?.total || 0);
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
});
// =========================================================
// GET /api/ele/batches — 批次列表
// =========================================================
app.get('/batches', async (c) => {
await ensureChargeRecordTable();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT batch_id,
MIN(imported_at) AS imported_at,
COUNT(*) AS records,
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY batch_id
ORDER BY imported_at DESC
LIMIT 50`,
);
return c.json({ items: rows });
});
// =========================================================
// GET /api/ele/aggregate — 聚合统计
// =========================================================
app.get('/aggregate', async (c) => {
await ensureChargeRecordTable();
// 全量分类汇总
const [overallRows] = await pool.query<RowDataPacket[]>(
`SELECT vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY vehicle_kind`,
);
// 近 30 日按日
const [dailyRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
ORDER BY date DESC`,
);
return c.json({ overall: overallRows, daily: dailyRows });
});
export default app;

View File

@@ -1,49 +0,0 @@
import pool from '../../db.js';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
station_no VARCHAR(64) NULL,
station_name VARCHAR(128) NULL,
terminal_name VARCHAR(64) NULL,
region VARCHAR(64) NULL,
city VARCHAR(64) NULL,
district VARCHAR(64) NULL,
operating_company VARCHAR(128) NULL,
station_type VARCHAR(32) NULL,
order_status VARCHAR(32) NULL,
charge_form VARCHAR(32) NULL,
start_time DATETIME NULL,
end_time DATETIME NULL,
duration_min INT NULL,
kwh DECIMAL(10,3) NULL,
e_fee DECIMAL(10,2) NULL,
service_fee DECIMAL(10,2) NULL,
fee DECIMAL(10,2) NULL,
plate VARCHAR(32) NULL,
judged_plate VARCHAR(32) NULL,
vin VARCHAR(64) NULL,
customer_name VARCHAR(128) NULL,
customer_phone VARCHAR(32) NULL,
enterprise_name VARCHAR(128) NULL,
matched_truck_id VARCHAR(32) NULL,
matched_plate VARCHAR(32) NULL,
vehicle_kind ENUM('internal','external','unknown') NOT NULL DEFAULT 'unknown',
raw_json JSON NULL,
batch_id VARCHAR(64) NOT NULL,
imported_at DATETIME NOT NULL,
UNIQUE KEY uk_order_no (order_no),
KEY idx_start_time (start_time),
KEY idx_batch (batch_id),
KEY idx_kind (vehicle_kind),
KEY idx_plate (plate)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
let ensured = false;
export async function ensureChargeRecordTable(): Promise<void> {
if (ensured) return;
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}

View File

@@ -1,135 +0,0 @@
/**
* SWR 缓存:始终返回热数据,后台定时刷新。
*
* 工作机制:
* - 首次请求:阻塞等待 loadercold start3-4s 不可避免)
* - 之后:每个 key 自调度刷新TTL 到期前 5s用户永远命中热缓存
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
* - 同一 key 并发请求只触发一次 loader
* - force=true手动强制刷新绕过缓存但仍参与 inflight 复用)
*/
interface Entry<T> {
value: T;
freshAt: number;
expiresAt: number;
loader: () => Promise<T>;
lastAccess: number;
timer?: NodeJS.Timeout;
}
const TTL_MS = 60 * 1000;
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
const cache = new Map<string, Entry<unknown>>();
const inflight = new Map<string, Promise<unknown>>();
function scheduleRefresh<T>(key: string, entry: Entry<T>) {
if (entry.timer) clearTimeout(entry.timer);
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
entry.timer.unref?.();
}
async function runRefresh(key: string) {
const entry = cache.get(key) as Entry<unknown> | undefined;
if (!entry) return;
// 闲置超时:停止调度
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
if (entry.timer) clearTimeout(entry.timer);
return;
}
if (inflight.has(key)) return;
const p = entry.loader()
.then(value => {
const now = Date.now();
const next: Entry<unknown> = {
value,
freshAt: now,
expiresAt: now + TTL_MS,
loader: entry.loader,
lastAccess: entry.lastAccess,
};
cache.set(key, next);
scheduleRefresh(key, next);
return value;
})
.catch(e => {
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
// 保留旧值10s 后重试
const retry: Entry<unknown> = { ...entry };
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
retry.timer.unref?.();
cache.set(key, retry);
})
.finally(() => inflight.delete(key));
inflight.set(key, p);
}
export interface CachedOpts {
force?: boolean;
}
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
const now = Date.now();
const hit = cache.get(key) as Entry<T> | undefined;
if (hit) {
hit.lastAccess = now;
hit.loader = loader;
}
// 强制刷新:等待 loader 完成
if (opts.force) {
const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing;
const p = loader()
.then(value => {
const t = Date.now();
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
cache.set(key, next);
scheduleRefresh(key, next);
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, p as Promise<unknown>);
return p;
}
// 命中且未过期 → 立即返回
if (hit && hit.expiresAt > now) {
return hit.value;
}
// 命中但过期 → 返回 stale后台刷新
if (hit) {
if (!inflight.has(key)) void runRefresh(key);
return hit.value;
}
// 完全未命中 → 阻塞等待
const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing;
const p = loader()
.then(value => {
const t = Date.now();
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
cache.set(key, entry);
scheduleRefresh(key, entry);
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, p as Promise<unknown>);
return p;
}
/** 仅用于测试或调试:清空所有缓存与定时器 */
export function _clearEnergyCache() {
for (const e of cache.values()) {
if (e.timer) clearTimeout(e.timer);
}
cache.clear();
inflight.clear();
}

View File

@@ -1,615 +0,0 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import hydrogenPool from '../../hydrogen-db.js';
import { cached } from './cache.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
const app = new Hono();
// 模块级访问守卫dev 旁路 auth 时 user 为 undefined直接放行
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
app.use('*', async (c, next) => {
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
const HYDROGEN_MIN_DATE = '2024-01-01';
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
const HYDROGEN_LOCAL = `refuel_time`;
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
const ELECTRIC_LOCAL = `charging_start_time`;
type CustomerKind = 'external' | 'lingniu' | 'all';
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
return '1=1';
}
type Range = 'thisWeek' | 'thisMonth' | 'last15';
function rangeClause(localExpr: string, range: Range): string {
switch (range) {
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
}
}
/** 列出某 range 在当前时点下的全部日期YYYY-MM-DD用于补零 */
function enumerateDates(range: Range): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
let start: Date;
if (range === 'thisWeek') {
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
const day = today.getDay() || 7; // 周日 7
start = new Date(today);
start.setDate(today.getDate() - (day - 1));
} else if (range === 'thisMonth') {
start = new Date(today.getFullYear(), today.getMonth(), 1);
} else {
start = new Date(today);
start.setDate(today.getDate() - 14);
}
const result: string[] = [];
const cur = new Date(start);
while (cur <= today) {
result.push(fmt(cur));
cur.setDate(cur.getDate() + 1);
}
return result;
}
// =========================================================
// 氢能 总览KPI + Top5 + 区域占比
// =========================================================
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
const isCurrentYear = year === todayYear;
// KPI按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_total ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN fee_total ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN amount_kg ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN cost_total ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN amount_kg ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN amount_kg ELSE 0 END) AS monthKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_total ELSE 0 END) AS monthFee,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee_total ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN amount_kg ELSE 0 END) AS todayKg,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_total ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN fee_total ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
);
const k = kpiRows[0] ?? {};
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = {
yearKg: Number(k.yearKg) || 0,
yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0,
monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0,
todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0,
};
// Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
share: (Number(r.kg) || 0) / top5KgSum,
}));
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8其余合并为"其他"
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
const top = regionRows.slice(0, TOP_REGIONS);
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
const regions = [
...top.map(r => ({
region: r.region as string,
kg: Number(r.kg) || 0,
share: (Number(r.kg) || 0) / totalKg,
})),
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
];
// 月度趋势(指定年份内 12 个月,缺失月补 0含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top指定年份按加氢量降序前 30
// payer有客户单价/收入 → 客户承担;否则 → 羚牛承担
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}, { force });
return c.json(data);
});
// =========================================================
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
// =========================================================
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
const where = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
rangeClause(`b.${HYDROGEN_LOCAL}`, range),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback站点主数据 → 账本冗余站点名 → 未关联站点
// 单价不重算:直接取账本成本价。
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg,
-- 单价直接取订单中的成本价不重算。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`,
);
// 站点环比:同站点上一条记录的 kg
// 按 stationId 分组、按日期升序计算
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
const flat: StationRow[] = stationRows.map(r => ({
date: r.d as string,
stationId: Number(r.stationId),
name: r.stationName as string,
kg: Number(r.kg) || 0,
pricePerKg: Number(r.pricePerKg) || 0,
}));
// 计算日级总量 + 日级环比
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
for (const s of flat) {
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
const e = dayMap.get(s.date)!;
e.totalKg += s.kg;
e.stations.push(s);
}
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
const dayChainPct = new Map<string, number>();
let prev = 0;
for (const d of dates) {
const cur = dayMap.get(d)!.totalKg;
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
prev = cur;
}
// 站点级环比:按 stationId 分组按日期升序
const stationPrev = new Map<number, number>();
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
// 需要按 stationId 分组排序
const byStation = new Map<number, StationRow[]>();
for (const s of flat) {
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
byStation.get(s.stationId)!.push(s);
}
for (const [, list] of byStation) {
list.sort((a, b) => a.date.localeCompare(b.date));
let p = 0;
for (const r of list) {
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
p = r.kg;
}
}
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDates(range);
const fullDays = allDates.map(date => {
const info = dayMap.get(date);
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
}))
: [],
};
});
// 全量日期重算环比含补零日0→上一日有值时显示 -100%
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
let prevKg = 0;
for (const d of ascDays) {
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
prevKg = d.totalKg;
}
// 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result;
}, { force });
return c.json(data);
});
// =========================================================
// 电能 总览KPI + 本月每日柱图数据 —— 数据源bi_ele_charge_record
// =========================================================
app.get('/electric/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('electric/overview', async () => {
const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN kwh ELSE 0 END) AS monthKwh,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
FROM bi_ele_charge_record`,
);
const k = kpiRows[0] ?? {};
const totalKwh = Number(k.totalKwh) || 0;
const totalFee = Number(k.totalFee) || 0;
const monthKwh = Number(k.monthKwh) || 0;
const monthFee = Number(k.monthFee) || 0;
const todayKwh = Number(k.todayKwh) || 0;
const todayFee = Number(k.todayFee) || 0;
// 本月每日(用于柱图)
const [trendRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
GROUP BY date
ORDER BY date ASC`,
);
// 若本月无数据,降级展示最近一个有数据的自然月
let trend = trendRows;
if (trend.length === 0) {
const [fallback] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
)
GROUP BY date
ORDER BY date ASC`,
);
trend = fallback;
}
const trendArr = trend.map(r => ({
date: r.date as string,
kwh: Math.round((Number(r.kwh) || 0) * 100) / 100,
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
chainPct: 0,
}));
for (let i = 1; i < trendArr.length; i++) {
const prev = trendArr[i - 1].kwh;
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
}
let todayChainPct = 0;
if (todayKwh > 0) {
const [prevRow] = await pool.query<RowDataPacket[]>(
`SELECT SUM(kwh) AS kwh
FROM bi_ele_charge_record
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
);
const prevKwh = Number(prevRow[0]?.kwh) || 0;
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
}
return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr,
};
}, { force });
return c.json(data);
});
// =========================================================
// 电能 每日:月份分组 + 日级行 —— 数据源bi_ele_charge_record
// 支持 range 参数thisWeek / thisMonth / last15
// 缺失日期补零
// =========================================================
app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range;
const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
// bi_ele_charge_record 用 vehicle_kind 区分internal=我司external=外部
let kindClause = '1=1';
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${kindClause}
AND ${rangeClause('start_time', range)}
GROUP BY date`,
);
// 实际数据 map
const dataMap = new Map<string, { kwh: number; fee: number }>();
for (const r of rows) {
dataMap.set(r.date as string, {
kwh: Number(r.kwh) || 0,
fee: Number(r.fee) || 0,
});
}
// 补零:枚举 range 全部日期
const allDates = enumerateDates(range);
const fullDays = allDates.map(date => {
const d = dataMap.get(date);
return {
date,
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
fee: d ? Math.round(d.fee * 100) / 100 : 0,
};
});
// 按月份分组asc 内日期倒序,但月份分组按 desc
const monthMap = new Map<string, typeof fullDays>();
for (const d of fullDays) {
const m = d.date.slice(0, 7);
if (!monthMap.has(m)) monthMap.set(m, []);
monthMap.get(m)!.push(d);
}
const months = Array.from(monthMap.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, days]) => {
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
const chain = new Map<string, number>();
let prev = 0;
for (const d of asc) {
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
prev = d.kwh;
}
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
const rowsWithChain = desc.map(d => ({
date: d.date,
kwh: d.kwh,
fee: d.fee,
chainPct: chain.get(d.date) ?? 0,
}));
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
const feeSum = days.reduce((s, d) => s + d.fee, 0);
return {
month,
kwh: Math.round(kwhSum * 100) / 100,
fee: Math.round(feeSum * 100) / 100,
rows: rowsWithChain,
};
});
return months;
}, { force });
return c.json(data);
});
export default app;

View File

@@ -1,190 +0,0 @@
import { Hono } from 'hono';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import { canManageFeedback } from '../../auth/types.js';
import { uploadFeedbackImage } from './oss.js';
const app = new Hono();
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS bi_user_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
module VARCHAR(64) NULL,
content TEXT NOT NULL,
contact VARCHAR(200) NULL,
screenshots JSON NULL,
user_id VARCHAR(64) NULL,
user_name VARCHAR(128) NULL,
user_agent VARCHAR(512) NULL,
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
reply_content TEXT NULL,
reply_user VARCHAR(128) NULL,
reply_at DATETIME NULL,
created_at DATETIME NOT NULL,
KEY idx_created_at (created_at),
KEY idx_type (type),
KEY idx_status (status),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
let ensured = false;
async function ensureTable(): Promise<void> {
if (ensured) return;
await pool.query(CREATE_TABLE_SQL);
// 兼容旧表:补齐缺失列
for (const alter of [
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
]) {
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
}
ensured = true;
}
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
// 写入时间戳一律用东八区 CST避免依赖 MySQL/容器时区设置
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
app.post('/submit', async (c) => {
await ensureTable();
const body = await c.req.json().catch(() => ({})) as {
type?: string; module?: string | null; content?: string;
contact?: string | null; userAgent?: string; screenshots?: string[];
};
const type = (body.type || '').trim();
const content = (body.content || '').trim();
if (!VALID_TYPES.has(type)) {
return c.json({ ok: false, message: '类型不合法' }, 400);
}
if (!content || content.length > 2000) {
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
}
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
const moduleVal = (body.module || '').slice(0, 64) || null;
const contact = (body.contact || '').slice(0, 200) || null;
const userAgent = (body.userAgent || '').slice(0, 512) || null;
const screenshots = Array.isArray(body.screenshots)
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
: [];
const [r] = await pool.query<ResultSetHeader>(
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
);
return c.json({ ok: true, id: r.insertId });
});
// =========================================================
// POST /api/feedback/upload — 单张截图上传multipart/form-data, field=file
// =========================================================
app.post('/upload', async (c) => {
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const mime = file.type || 'image/png';
if (!ALLOWED_MIME.has(mime)) {
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
}
if (file.size > MAX_IMAGE_SIZE) {
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB`}, 400);
}
const buf = Buffer.from(await file.arrayBuffer());
try {
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
return c.json({ ok: true, url });
} catch (e) {
console.error('feedback upload error:', e);
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
}
});
// GET /api/feedback/mine — 当前用户的反馈历史
app.get('/mine', async (c) => {
await ensureTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!user?.userId) return c.json({ items: [] });
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, type, module, content, contact, screenshots, status,
reply_content, reply_user, reply_at, created_at
FROM bi_user_feedback
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 100`,
[user.userId],
);
return c.json({ items: rows });
});
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.get('/list', async (c) => {
await ensureTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
}
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
const status = c.req.query('status') || '';
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (VALID_STATUS.has(status)) {
where.push('status = ?');
params.push(status);
}
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
reply_content, reply_user, reply_at, created_at
FROM bi_user_feedback
WHERE ${where.join(' AND ')}
ORDER BY created_at DESC
LIMIT ?`,
[...params, limit],
);
return c.json({ items: rows });
});
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.patch('/:id', async (c) => {
await ensureTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
}
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
const fields: string[] = [];
const params: (string | number | null)[] = [];
if (body.status) {
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
fields.push('status = ?');
params.push(body.status);
}
if (typeof body.reply === 'string') {
const reply = body.reply.trim().slice(0, 2000);
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
params.push(reply || null, user?.userName || user?.userId || null);
}
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
params.push(id);
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
return c.json({ ok: true });
});
export default app;

View File

@@ -1,38 +0,0 @@
import OSS from 'ali-oss';
let client: OSS | null = null;
function getClient(): OSS {
if (client) return client;
const region = process.env.OSS_REGION || 'oss-cn-shanghai';
const accessKeyId = process.env.OSS_ACCESS_KEY_ID || '';
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || '';
const bucket = process.env.OSS_BUCKET || '';
if (!accessKeyId || !accessKeySecret || !bucket) {
throw new Error('OSS 未配置OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
}
client = new OSS({ region, accessKeyId, accessKeySecret, bucket, secure: true });
return client;
}
function safeExt(filename: string, fallback = 'png'): string {
const m = /\.([a-zA-Z0-9]{1,8})$/.exec(filename);
return m ? m[1].toLowerCase() : fallback;
}
function randId(len = 8): string {
return Math.random().toString(36).slice(2, 2 + len);
}
/** 上传 buffer 到 OSS返回公开访问的 URL */
export async function uploadFeedbackImage(filename: string, buf: Buffer, mimetype: string): Promise<string> {
const c = getClient();
const baseDir = (process.env.OSS_BASE_DIR || '/dos').replace(/^\/+|\/+$/g, '');
const ymd = new Date().toISOString().slice(0, 10);
const key = `${baseDir}/feedback/${ymd}/${Date.now().toString(36)}-${randId()}.${safeExt(filename, mimetype.split('/')[1] || 'png')}`;
await c.put(key, buf, {
headers: { 'Content-Type': mimetype, 'x-oss-object-acl': 'public-read' },
});
const host = (process.env.OSS_HOST || `https://${process.env.OSS_BUCKET}.${process.env.OSS_ENDPOINT}/`).replace(/\/+$/, '/');
return host + key;
}

View File

@@ -1,17 +1,8 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const regionMap: Record<string, string> = JSON.parse(
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
);
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
@@ -47,14 +38,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
const regions = Array.from(regionSet).sort((a, b) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
}
interface MileageRow {
@@ -65,47 +49,12 @@ interface MileageRow {
source: string;
}
interface TargetRow {
id: number;
target_name: string;
plate_number: string;
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as TargetRow[]);
}
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
return targetPlatesMap;
}
function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const r of targetRows) {
const list = map.get(r.plate_number) || [];
list.push(r.target_name);
map.set(r.plate_number, list);
}
return map;
}
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 用它兜底保证 totalKm 汇总完整。
const [rows] = await pool.execute(
'SELECT plate_number, vehicle_total_mileage FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
@@ -115,42 +64,11 @@ async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
return map;
}
async function fetchLatestPgTotalMileageMap(asOf?: string): Promise<Map<string, number>> {
// 当日 ln_vehicle_day_total_pg 无记录或 total_mileage 为 NULL 时,
// 回填该车 dates <= asOf 的最近一条非空 total_mileage÷1000 转 km
// 让视图 total_km 为 NULL 的车也能显示历史累计。
// MySQL 5.7 无窗口函数,用 GROUP BY MAX(dates) + JOIN 取每车最近一条。
const sql = `
SELECT t.plate_number, t.total_mileage
FROM ln_vehicle_day_total_pg t
INNER JOIN (
SELECT plate_number, MAX(dates) AS max_dates
FROM ln_vehicle_day_total_pg
WHERE total_mileage IS NOT NULL
${asOf ? 'AND dates <= ?' : ''}
GROUP BY plate_number
) m ON m.plate_number = t.plate_number AND m.max_dates = t.dates
WHERE t.total_mileage IS NOT NULL`;
const params = asOf ? [asOf] : [];
const [rows] = await mileagePool.execute(sql, params) as [
{ plate_number: string; total_mileage: string | number | null }[],
unknown,
];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.total_mileage) / 1000;
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
}
return map;
}
function mergeVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
bizTotalMap: Map<string, number>,
latestPgTotalMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) {
@@ -165,13 +83,12 @@ function mergeVehicles(
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
const latestPgTotal = latestPgTotalMap.get(m.plate);
const bizTotal = bizTotalMap.get(m.plate);
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
totalKm: gpsTotal !== null ? gpsTotal : (bizTotal ?? null),
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
@@ -182,8 +99,6 @@ function mergeVehicles(
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
region: regionMap[m.plate] || null,
targetNames: targetNamesByPlate.get(m.plate) || [],
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
@@ -194,7 +109,7 @@ export async function refreshMonitoringCache(): Promise<void> {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap] = await Promise.all([
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
@@ -221,16 +136,24 @@ export async function refreshMonitoringCache(): Promise<void> {
return map;
})(),
fetchVehicleInfoMap(),
fetchTargetRows(),
pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(),
]);
const targetPlatesMap = buildTargetPlatesMap(targetRows);
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
@@ -249,7 +172,7 @@ export async function refreshMonitoringCache(): Promise<void> {
}
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
const [mileageRows, yesterdayRows, infoMap, bizTotalMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
@@ -259,9 +182,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
[dateStr]
).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(dateStr),
]);
const yesterdayMap = new Map<string, number>();
@@ -271,14 +192,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
if (km > existing) yesterdayMap.set(r.plate, km);
}
return mergeVehicles(
mileageRows,
infoMap,
yesterdayMap,
bizTotalMap,
latestPgTotalMap,
buildPlateTargetNamesMap(targetRows),
);
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap);
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {

View File

@@ -3,7 +3,6 @@ import { refreshMonitoringCache } from './cache.js';
import monitoringRouter from './monitoring.js';
import targetsRouter from './targets.js';
import trendRouter from './trend.js';
import vehicleRecentRouter from './vehicle-recent.js';
const app = new Hono();
@@ -11,7 +10,6 @@ app.route('/monitoring', monitoringRouter);
app.route('/targets', targetsRouter);
app.route('/target', targetsRouter);
app.route('/trend', trendRouter);
app.route('/vehicle', vehicleRecentRouter);
// 启动时立即刷新缓存,之后每分钟刷新
refreshMonitoringCache();

View File

@@ -9,7 +9,7 @@ const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
total: 0,
page: 1,
totalPages: 1,
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
targetName: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
@@ -36,15 +36,12 @@ function applyFilters(vehicles: CachedVehicle[], params: {
if (params.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
if (params.plate) {
const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean));
if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate));
}
if (params.plate) result = result.filter(v => v.plate === params.plate);
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.region) result = result.filter(v => v.region === params.region);
if (params.targetNames.length > 0) {
const selectedTargets = new Set(params.targetNames);
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
}
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
@@ -52,18 +49,6 @@ function applyFilters(vehicles: CachedVehicle[], params: {
return result;
}
function parseTargetNames(reqUrl: string): string[] {
const params = new URL(reqUrl).searchParams;
const raw = [
...params.getAll('targetName'),
...params.getAll('targetNames'),
];
const names = raw.flatMap(item => item.split(','))
.map(item => item.trim())
.filter(Boolean);
return Array.from(new Set(names));
}
app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
@@ -80,8 +65,7 @@ app.get('/', async (c) => {
rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetNames: parseTargetNames(c.req.url),
region: c.req.query('region') || '',
targetName: c.req.query('targetName') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
};
@@ -111,12 +95,6 @@ app.get('/', async (c) => {
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
}
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
if (filterParams.region) {
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
filters = buildDateFilters(regionScope);
}
const filtered = applyFilters(allVehicles, filterParams);
const stats = {

View File

@@ -1,138 +0,0 @@
{
"粤AGP2009": "华南区域",
"粤AGP2011": "华南区域",
"粤AGP2017": "华南区域",
"粤AGP2032": "华南区域",
"粤AGP2035": "华东区域",
"粤AGP3027": "华东区域",
"粤AGP3029": "华南区域",
"粤AGP3071": "华南区域",
"粤AGP3078": "华东区域",
"粤AGP3079": "华东区域",
"粤AGP3082": "西南区域",
"粤AGP3087": "西南区域",
"粤AGP3097": "华南区域",
"粤AGP3486": "西北区域",
"粤AGP3502": "华南区域",
"粤AGP3503": "华东区域",
"粤AGP3505": "西南区域",
"粤AGP3506": "华东区域",
"粤AGP3509": "西南区域",
"粤AGP3513": "华东区域",
"粤AGP3515": "华东区域",
"粤AGP3605": "华东区域",
"粤AGP3607": "华东区域",
"粤AGP3609": "华东区域",
"粤AGP3612": "华东区域",
"粤AGP3615": "华南区域",
"粤AGP3617": "西北区域",
"粤AGP3625": "华东区域",
"粤AGP3627": "华东区域",
"粤AGP3631": "西南区域",
"粤AGP3642": "华南区域",
"粤AGP3645": "华南区域",
"粤AGP3649": "华东区域",
"粤AGP3651": "华东区域",
"粤AGP3659": "华东区域",
"粤AGP3660": "华南区域",
"粤AGP3667": "华南区域",
"粤AGP3672": "华南区域",
"粤AGP3673": "西南区域",
"粤AGP3690": "华东区域",
"粤AGP3692": "华东区域",
"粤AGP3695": "华东区域",
"粤AGP4223": "华南区域",
"粤AGP4318": "华东区域",
"粤AGP4321": "华东区域",
"粤AGP4325": "华南区域",
"粤AGP4335": "华东区域",
"粤AGP4355": "华东区域",
"粤AGP4377": "华东区域",
"粤AGP4386": "华东区域",
"粤AGP4396": "西南区域",
"粤AGP4422": "华南区域",
"粤AGP4435": "华南区域",
"粤AGP4451": "华南区域",
"粤AGP4482": "华南区域",
"粤AGP4486": "华南区域",
"粤AGP4489": "华东区域",
"粤AGP4502": "华南区域",
"粤AGP4522": "华东区域",
"粤AGP4538": "华南区域",
"粤AGP4548": "华东区域",
"粤AGP4566": "华南区域",
"粤AGP4569": "华南区域",
"粤AGP4583": "华东区域",
"粤AGP4586": "华东区域",
"粤AGP4587": "西南区域",
"粤AGP4596": "华南区域",
"粤AGP4597": "华东区域",
"粤AGP4599": "华东区域",
"粤AGP4623": "华东区域",
"粤AGP4629": "华南区域",
"粤AGP5165": "华东区域",
"粤AGP5167": "华东区域",
"粤AGP5169": "华南区域",
"粤AGP5301": "华东区域",
"粤AGP5350": "华南区域",
"粤AGP5351": "华东区域",
"粤AGP5357": "华东区域",
"粤AGP5363": "华南区域",
"粤AGP5379": "华东区域",
"粤AGP5613": "华南区域",
"粤AGP5615": "华东区域",
"粤AGP5617": "华东区域",
"粤AGP5621": "西南区域",
"粤AGP5622": "华东区域",
"粤AGP5623": "华南区域",
"粤AGP5642": "华东区域",
"粤AGP5643": "西北区域",
"粤AGP5646": "华东区域",
"粤AGP5651": "华东区域",
"粤AGP5661": "华东区域",
"粤AGP5681": "华东区域",
"粤AGP5691": "华东区域",
"粤AGP5710": "华东区域",
"粤AGP5711": "西北区域",
"粤AGP5712": "华东区域",
"粤AGP5719": "华南区域",
"粤AGP5749": "华东区域",
"粤AGP5760": "华东区域",
"粤AGP5763": "华东区域",
"粤AGP5769": "华东区域",
"粤AGP5770": "华东区域",
"粤AGP5791": "西北区域",
"粤AGP5792": "华南区域",
"粤AGP5797": "华东区域",
"粤AGP7016": "华南区域",
"粤AGP7019": "西南区域",
"粤AGP7022": "华南区域",
"粤AGP7026": "华东区域",
"粤AGP7047": "华东区域",
"粤AGP9330": "华南区域",
"粤AGP9346": "华东区域",
"粤AGP9347": "华南区域",
"粤AGP9350": "华东区域",
"粤AGP9351": "华南区域",
"粤AGP9702": "华东区域",
"粤AGP9703": "西北区域",
"粤AGP9706": "华东区域",
"粤AGP9707": "华东区域",
"粤AGP9713": "华东区域",
"粤AGP9717": "华南区域",
"粤AGP9721": "华东区域",
"粤AGP9726": "华南区域",
"粤AGP9731": "华南区域",
"粤AGP9735": "华东区域",
"粤AGP9739": "华南区域",
"粤AGP9751": "华南区域",
"粤AGP9753": "华东区域",
"粤AGP9755": "华东区域",
"粤AGP9759": "华东区域",
"粤AGP9782": "华东区域",
"粤AGP9790": "华南区域",
"粤AGP9791": "华东区域",
"粤AGP9817": "华南区域",
"粤AGP9827": "华南区域",
"粤AGP9836": "华南区域"
}

View File

@@ -10,7 +10,7 @@ const app = new Hono();
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(`
@@ -25,96 +25,19 @@ app.get('/', async (c) => {
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [firstYearRows] = await pool.execute(`
SELECT
v.target_id,
COUNT(*) as first_year_total,
SUM(t.annual_mileage_per_vehicle) as first_year_target,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
WHERE v.is_deleted = 0
GROUP BY v.target_id
`) as [any[], unknown];
const firstYearMap = new Map<number, any>();
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
const [yearlyRows] = await pool.execute(`
SELECT
v.target_id,
y.year_number,
COUNT(*) as vehicle_count,
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number
ORDER BY v.target_id, y.year_number
`) as [any[], unknown];
const yearlyMap = new Map<number, any[]>();
for (const row of yearlyRows) {
const list = yearlyMap.get(row.target_id) || [];
list.push(row);
yearlyMap.set(row.target_id, list);
}
const [yearlyPeriodRows] = await pool.execute(`
SELECT
v.target_id,
y.year_number,
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number, v.assessment_start_date
ORDER BY v.target_id, y.year_number, v.assessment_start_date
`) as [any[], unknown];
const yearlyPeriodsMap = new Map<string, string[]>();
for (const row of yearlyPeriodRows) {
const key = `${row.target_id}-${row.year_number}`;
const list = yearlyPeriodsMap.get(key) || [];
list.push(`${row.start_date} ~ ${row.end_date} (${row.cnt}台)`);
yearlyPeriodsMap.set(key, list);
}
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
@@ -135,7 +58,7 @@ app.get('/', async (c) => {
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>();
@@ -148,44 +71,12 @@ app.get('/', async (c) => {
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const fy = firstYearMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now;
const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000));
const firstYearRemaining = Number(fy.first_year_remaining) || 0;
const firstYearVehicleCount = Number(fy.first_year_total) || 0;
const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0;
const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => {
const vehicleCount = Number(row.vehicle_count) || 0;
const qualifiedCount = Number(row.qualified_count) || 0;
const remainingMileage = Number(row.remaining_mileage) || 0;
const endDate = row.end_date ? new Date(row.end_date) : now;
const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
const yearNumber = Number(row.year_number) || 0;
return {
yearNumber,
label: `${yearNumber}`,
vehicleCount,
target: Number(row.target_mileage) || 0,
completed: Number(row.completed_mileage) || 0,
remaining: remainingMileage,
completionRate: (Number(row.completion_rate) || 0) * 100,
qualifiedCount,
qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0,
halfQualifiedCount: Number(row.half_qualified_count) || 0,
daysLeft: assessmentDaysLeft,
dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0,
startDate: row.start_date || null,
endDate: row.end_date || null,
periods: yearlyPeriodsMap.get(`${row.target_id}-${row.year_number}`) || [],
};
});
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
@@ -213,19 +104,6 @@ app.get('/', async (c) => {
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
firstYearVehicleCount,
firstYearTarget: Number(fy.first_year_target) || 0,
firstYearCompleted: Number(fy.first_year_completed) || 0,
firstYearRemaining,
firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100,
firstYearQualifiedCount,
firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0,
firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0,
firstYearDaysLeft,
firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0,
firstYearStartDate: fy.first_year_start_date || null,
firstYearEndDate: fy.first_year_end_date || null,
yearlyAssessments,
};
});
@@ -245,7 +123,7 @@ app.get('/:id/vehicles', async (c) => {
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM lingniu_prod.tab_mileage_assessment_vehicle
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]

View File

@@ -12,17 +12,15 @@ app.get('/', async (c) => {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;

View File

@@ -14,8 +14,6 @@ export interface CachedVehicle {
rentStatus: string | null;
entity: string | null;
project: string | null;
region: string | null;
targetNames: string[];
yesterdayKm: number;
}
@@ -35,7 +33,6 @@ export interface MonitoringFilters {
rentStatuses: string[];
platePrefixes: PlatePrefix[];
targetNames: string[];
regions: string[];
}
/** 监控缓存 */

View File

@@ -3,42 +3,24 @@ import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
COALESCE(c.business_department_name, vor.business_dept) AS department,
COALESCE(c.business_manager_name, vor.business_manager) AS manager,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
CASE vs.operation_status
WHEN '1' THEN '租赁'
WHEN '2' THEN '自营'
WHEN '3' THEN '可运营'
WHEN '4' THEN '待运营'
WHEN '5' THEN '退出运营'
ELSE vs.operation_status
END AS rent_status,
NULLIF(vi.registered_ownership, '') AS entity,
COALESCE(c.project_name, vor.project_name) AS project
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
CAST(c.bd AS CHAR) AS manager_id,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
@@ -54,7 +36,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();

View File

@@ -1,101 +0,0 @@
import { Hono } from 'hono';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
interface DayRow {
date: string;
daily_km: string | number | null;
source: string | null;
}
function fmt(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
function parseYmd(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
d.setHours(0, 0, 0, 0);
return Number.isFinite(d.getTime()) ? d : null;
}
const MAX_DAYS = 366;
app.get('/:plate/recent', async (c) => {
const plate = c.req.param('plate');
if (!plate) return c.json({ plate: '', days: [] }, 400);
const today = new Date();
today.setHours(0, 0, 0, 0);
// 区间参数:优先 start/end否则回退 days兼容旧调用
const startQ = c.req.query('start');
const endQ = c.req.query('end');
let start: Date;
let end: Date;
if (startQ) {
const ps = parseYmd(startQ);
if (!ps) return c.json({ plate, days: [] }, 400);
start = ps;
end = endQ ? (parseYmd(endQ) ?? today) : today;
} else {
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), MAX_DAYS);
end = today;
start = new Date(today);
start.setDate(today.getDate() - (days - 1));
}
if (start > end) [start, end] = [end, start];
// 限制区间长度
const span = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
if (span > MAX_DAYS) {
start = new Date(end);
start.setDate(end.getDate() - (MAX_DAYS - 1));
}
try {
const [rows] = await mileagePool.execute(
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
FROM v_vehicle_daily_stats
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
ORDER BY stat_date`,
[plate, fmt(start), fmt(end)]
) as [DayRow[], unknown];
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
const map = new Map<string, { dailyKm: number; source: string }>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const src = r.source || 'NONE';
const existing = map.get(r.date);
if (!existing || km > existing.dailyKm) {
map.set(r.date, { dailyKm: km, source: src });
}
}
// 补全:从 start 到 end 每天一条
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
const cursor = new Date(start);
while (cursor <= end) {
const key = fmt(cursor);
const hit = map.get(key);
result.push({
date: key,
dailyKm: hit?.dailyKm ?? 0,
isDataSynced: !!hit && hit.source !== 'NONE',
});
cursor.setDate(cursor.getDate() + 1);
}
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
} catch (e: unknown) {
console.error('vehicle recent error:', e);
return c.json({ plate, days: [] }, 500);
}
});
export default app;

View File

@@ -28,17 +28,16 @@ function inferTypeFromTargetName(targetName: string): string {
}
/**
* Classify vehicle type from ln_asset_management.vehicle_model.
* modelRaw is vehicle_model.vehicle_type, which is not the old dic_truck_type code.
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
*/
function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
if (t.includes('49')) return '49T';
if (t.includes('35')) return '35T';
return t || '其他';
}
@@ -55,7 +54,7 @@ app.get('/', async (c) => {
// ---- Query 1: Assessment targets ----
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
@@ -72,20 +71,21 @@ app.get('/', async (c) => {
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from vehicle_info ----
// ---- Query 4: Vehicle types from tab_truck ----
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
// but are still active in the assessment. We need their type info.
const [truckTypeRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_operation = 1
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
@@ -161,13 +161,12 @@ app.get('/', async (c) => {
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
AND truck.truck_rent_status = 0
`) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ----

View File

@@ -17,101 +17,75 @@ import type { Context } from 'hono';
const app = new Hono();
const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id,
vi.plate_number AS 车牌号,
vi.vin AS vin,
vm.brand AS 车辆品牌,
vm.model AS 车辆型号,
vi.body_color AS 车辆颜色,
vi.rental_company AS 租赁公司,
CASE vi.vehicle_source
WHEN '0' THEN '自有'
WHEN '1' THEN '挂靠'
WHEN '2' THEN '外租'
WHEN '3' THEN '自有'
ELSE vi.actual_ownership
END AS 车辆归属状态Label,
vm.model AS 车辆型号Label,
vm.vehicle_type AS 车辆类型参数,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
CAST(truck.id AS CHAR) 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 经度,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
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 客户经理,
CAST(c.bd AS CHAR) AS 经理ID
FROM tab_truck truck
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.plate_number = vi.plate_number
AND info.is_deleted = 0
LEFT JOIN common_district info_province
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
AND info_province.STATUS = 'VALID'
LEFT JOIN common_district info_city
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
AND info_city.STATUS = 'VALID'
LEFT JOIN common_district vi_province
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
AND vi_province.STATUS = 'VALID'
LEFT JOIN common_district vi_city
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
AND vi_city.STATUS = 'VALID'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
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;
@@ -174,33 +148,23 @@ function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
return counts;
}
// Map operation status to frontend status.
// ln_asset_management.vehicle_status.operation_status:
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
const op = (operationStatus || '').trim();
const vehicle = (vehicleStatus || '').trim();
if (vehicle === '4') return 'Pending';
if (vehicle === '14') return 'Abnormal';
if (op === '1' || op === '2') return 'Operating';
if (op === '3' || op === '4') return 'Inventory';
if (op === '租赁' || op === '自营') return 'Operating';
if (op === '可运营' || op === '待运营' || op === '在库') return 'Inventory';
if (op === '异动') return 'Abnormal';
// Map rental status to frontend status
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
if (!rentStatus) return 'Inventory';
const s = rentStatus.trim();
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
if (s === '在库') return 'Inventory';
if (s === '待交车') return 'Pending';
if (s === '异动') return 'Abnormal';
return 'Inventory';
}
// Map ownership from vehicle_info.vehicle_source.
// ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
// Map ownership from truck_rent_status (rentStatusLabel)
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim();
if (s === '0') return 'Self';
if (s === '1') return 'Hanging';
if (s === '2') return 'Leased';
if (s === '3') return 'Self';
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '自营') return 'Self';
if (s === '租赁') return 'Leased';
if (s === '挂靠') return 'Hanging';
@@ -215,41 +179,27 @@ function resolveCity(city: string | null, province: string | null): string {
return p || '其他';
}
// Derive page category from ln_asset_management.vehicle_model.
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
// 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();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
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 (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车';
if (label.includes('半挂车')) return '挂车';
return '其他车型';
}
function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// 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.5吨货车-白色恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-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 },
@@ -262,16 +212,13 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼安吉', order: 305 },
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼羚牛', order: 306 },
'帕力安牌-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: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风海珀特', order: 406 },
// 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
@@ -285,11 +232,9 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'万风-重型平板半挂车-红': { 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 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
};
@@ -302,9 +247,8 @@ function deriveModelTag(
rentCompany: string | null,
): string {
const brand = (brandLabel || '').trim();
const model = (normalizeModelLabel(modelLabel) || '').trim();
const model = (modelLabel || '').trim();
const c = (color || '').trim();
if (model === '公务用车/小客车') return '公务车/挂靠车';
const isRented = ownershipLabel?.trim() === '外租';
const company = isRented ? (rentCompany || '').trim() : '';
@@ -328,16 +272,15 @@ function transformRow(row: VehicleRow): Vehicle {
id: row.id,
plateNumber: row.车牌号 || '',
vin: row.vin || '',
type: deriveType(row.Label, row.),
type: deriveType(row.Label, row.Label),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '',
location: region,
region,
province: row.省,
city: row.市,
status: mapStatus(row.Label, row.),
operationStatus: row.车辆租赁状态Label,
ownership: mapOwnership(row.Label),
status: mapStatus(row.Label),
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
@@ -375,7 +318,7 @@ async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
return maskCustomerNames(list);
}
// 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg
function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null;
@@ -411,27 +354,24 @@ async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
}
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM vehicle_status
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM delivery_vehicle
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
AND delivery_status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM return_vehicle_task
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
AND status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
FROM vehicle_replacement
WHERE del_flag='0'
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`),
pool.query<any[]>(`SELECT CAST(id AS CHAR) AS truck_id FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id 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
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
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id 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
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id 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
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
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]);
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
@@ -479,54 +419,59 @@ interface WeeklyStats {
// 交车单 SQL
const DELIVERED_SQL = `SELECT
dv.id, DATE(dv.delivery_time) AS handover_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.delivery_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)`;
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.arrival_time) AS handover_date,
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.arrival_time IS NOT NULL
AND r.status IN (2,3,5)`;
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
vr.id, DATE(vr.replace_time) AS handover_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
c.contract_type,
c.customer_name
FROM vehicle_replacement vr
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.replace_time IS NOT NULL
AND vr.status = 20`;
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;
@@ -538,18 +483,23 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
}
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
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 tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
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
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
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
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]);
cachedWeeklyStats = {
@@ -570,13 +520,13 @@ app.get('/summary', async (c) => {
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
const summary: SummaryData = {
totalAssets: vehicles.length,
operating: {
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: 0,
},
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' || v.status === 'Abnormal').length,
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
@@ -745,8 +695,7 @@ app.get('/dept-stats', async (c) => {
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const isPublicServiceVehicle = v.model === '公务车/挂靠车';
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
const dept = v.departmentName || '公务车';
const mgr = v.customerManager || '未分配';
if (EXCLUDED_MANAGERS.has(mgr)) continue;
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
@@ -755,6 +704,29 @@ app.get('/dept-stats', async (c) => {
mgrMap.get(mgr)!.push(v);
}
// 补齐:业务部门内所有在职用户,即使当前无车辆也需显示
const deptNames = Array.from(deptMap.keys()).filter((d) => d !== '公务车');
if (deptNames.length > 0) {
const placeholders = deptNames.map(() => '?').join(',');
const [userRows] = await pool.query<any[]>(
`SELECT u.user_name, dep.dep_name
FROM tab_user u
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
WHERE u.is_deleted = 0
AND dep.dep_name IN (${placeholders})`,
deptNames,
);
for (const r of userRows as any[]) {
const dept = r.dep_name as string | null;
const mgr = r.user_name as string | null;
if (!dept || !mgr) continue;
if (EXCLUDED_MANAGERS.has(mgr)) continue;
const mgrMap = deptMap.get(dept);
if (!mgrMap) continue;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
}
}
// Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
@@ -821,21 +793,11 @@ app.get('/region-stats', async (c) => {
cityMap.get(city)!.push(v);
}
const getTypeBreakdown = (vList: Vehicle[]) => {
const KNOWN = ['4.5T', '18T', '49T'] as const;
const make = (label: string, tv: Vehicle[]) => ({
type: label,
total: tv.length,
operating: tv.filter((v) => v.status === 'Operating').length,
inventory: tv.filter((v) => v.status === 'Inventory').length,
pending: tv.filter((v) => v.status === 'Pending').length,
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
});
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
if (other.length > 0) known.push(make('其他', other));
return known.filter((t) => t.total > 0);
};
const getTypeBreakdown = (vList: Vehicle[]) =>
['4.5T', '18T', '49T'].map((type) => {
const tv = vList.filter((v) => v.type === type);
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
}).filter((t) => t.total > 0);
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder
@@ -918,21 +880,6 @@ app.get('/customer-stats', async (c) => {
return c.json(result);
});
// Location 过滤器:支持展示区域(嘉兴/广东/北京/新疆/其他)、库存区域(江浙沪/其它)、
// 城市(嘉兴市)、宏观区域(华东/华南/...)。
// '其他' 在两个体系里都存在(资产表的"库存-其他" vs 区域表的"其他"宏观区域),
// 用 source 区分source==='asset' 时按 v.location 匹配,其它情况按宏观区域匹配。
function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
if (isMacro) {
return vehicles.filter((v) => mapMacroRegion(v.province, v.city) === location);
}
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
return vehicles.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
}
// Vehicle type filter map (same logic as /by-type)
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
@@ -978,7 +925,15 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => v.model === model);
}
if (location && location !== 'All') {
filtered = filterByLocation(filtered, location, c.req.query('source'));
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
if (macroRegions.includes(location) || location === '其他') {
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
} else {
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
}
}
if (status && status !== 'All') {
filtered = filtered.filter((v) => v.status === status);
@@ -988,8 +943,6 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
} else if (category === 'Operating') {
filtered = filtered.filter((v) => v.status === 'Operating');
} else if (category === 'Pending') {
filtered = filtered.filter((v) => v.status === 'Pending');
}
}
if (manager) {
@@ -999,11 +952,7 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
}
if (department) {
filtered = filtered.filter((v) => {
if (department === '公务车') return v.model === '公务车/挂靠车';
if (department === '未分配部门') return v.model !== '公务车/挂靠车' && !v.departmentName;
return v.departmentName === department;
});
filtered = filtered.filter((v) => department === '公务车' ? !v.departmentName : v.departmentName === department);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
@@ -1024,10 +973,9 @@ app.get('/list', async (c) => {
location: v.location,
province: v.province,
city: v.city,
status: v.status,
ownership: v.ownership,
rentCompany: v.rentCompany,
contractNo: v.contractNo,
status: v.status,
ownership: v.ownership,
contractNo: v.contractNo,
customerName: v.customerName,
subjectOrg: v.subjectOrg,
departmentName: v.departmentName,
@@ -1075,50 +1023,27 @@ app.get('/inventory-stats', async (c) => {
});
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
// Optional filters: model, batch, location, source — 按缓存车辆集合的 truck_id 交集过滤
app.get('/weekly-detail', async (c) => {
const type = c.req.query('type');
const { model, batch, location } = c.req.query();
const source = c.req.query('source');
let sql: string;
if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`;
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.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`;
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 vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`;
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 CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`;
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 if (type === 'new') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`;
sql = `SELECT truck.id AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
} else {
return c.json([]);
}
const [rows] = await pool.query<any[]>(sql);
let result = rows as any[];
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
const hasModelFilter = model && model !== 'All';
const hasBatchFilter = batch && batch !== 'All';
const hasLocationFilter = location && location !== 'All';
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
const vehicles = await getVehiclesForUser(c);
let pool2 = vehicles;
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
if (hasLocationFilter) pool2 = filterByLocation(pool2, location, source);
const truckSet = new Set(pool2.map((v) => String(v.id)));
result = result.filter((r: any) => truckSet.has(String(r.truck_id)));
}
const masked = result.map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
const masked = (rows as any[]).map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
return c.json(masked);
});
@@ -1161,18 +1086,20 @@ app.get('/debug', async (c) => {
${WEEK_END_SQL} AS week_end,
CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(r.return_date) AS latest FROM tab_truck_rent_return r WHERE r.is_deleted=0 AND r.return_date IS NOT NULL`);
return c.json({
weekRange: dateRange,

View File

@@ -8,7 +8,6 @@ export interface VehicleRow {
租赁公司: string;
车辆归属状态Label: string | null;
车辆型号Label: string | null;
车辆类型参数: string | null;
库存区域: string | null;
车辆租赁状态: string | null;
车辆租赁状态Label: string | null;
@@ -41,7 +40,6 @@ export interface Vehicle {
province: string | null;
city: string | null;
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
operationStatus: string | null;
ownership: string;
rentCompany: string;
contractNo: string | null;

View File

@@ -10,27 +10,8 @@ export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
/** 智能调度模块访问角色 */
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
/** 反馈管理(管理员)访问角色 */
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
/** 能源管理模块访问角色 */
export const ENERGY_ACCESS_ROLES = ['BI-LEADER-ENERGY'];
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
}
/** 用户是否可管理反馈。仅 BI-ADMIN-FEEDBACK 或全量权限角色可访问。 */
export function canManageFeedback(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r));
}
/** 用户是否可访问能源管理模块。仅 BI-LEADER-ENERGY 或「所有权限」可访问。 */
const ENERGY_FULL_ACCESS = '所有权限';
export function canAccessEnergy(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS);
}

1
src/vite-env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />