Compare commits
2 Commits
4153f329b8
...
demo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b75437423 | ||
|
|
cf8f7cf969 |
File diff suppressed because it is too large
Load Diff
@@ -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` 时同名 export,UI 切到 `useEffect + fetch` 即可。
|
|
||||||
5. **icon 选型**:`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`。
|
|
||||||
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`。
|
|
||||||
|
|
||||||
## 验收标准
|
|
||||||
|
|
||||||
- [ ] 底部 nav 多出「能源管理」Tab,icon Zap,登录后可见
|
|
||||||
- [ ] 进入后默认在「氢能 → 总览」
|
|
||||||
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
|
|
||||||
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
|
|
||||||
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
|
|
||||||
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
|
|
||||||
- [ ] 切到电能 Tab,3 张 mini KPI + 月份分组表,月份能展开日级行
|
|
||||||
- [ ] 横屏不出现底部 nav 遮挡内容
|
|
||||||
- [ ] `npm run lint` 通过
|
|
||||||
- [ ] 不引入新依赖(recharts / lucide-react / motion 已有)
|
|
||||||
1048
package-lock.json
generated
1048
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -24,12 +23,10 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0"
|
||||||
"xlsx": "^0.18.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
35
src/App.tsx
35
src/App.tsx
@@ -1,50 +1,25 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
import { Truck, Route, Activity } from 'lucide-react';
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from './components/Shell';
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from './modules/assets/AssetsModule';
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from './modules/mileage/MileageModule';
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||||
import EnergyModule from './modules/energy/EnergyModule';
|
|
||||||
import EleImportPage from './modules/ele/EleImportPage';
|
|
||||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
|
||||||
import AuthProvider from './auth/AuthProvider';
|
import AuthProvider from './auth/AuthProvider';
|
||||||
import { useAuth } from './auth/useAuth';
|
import { useAuth } from './auth/useAuth';
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||||
import { canAccessScheduling } from './shared/auth/roles';
|
import { canAccessScheduling } from './shared/auth/roles';
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const BASE_MODULES: ModuleConfig[] = [
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRouteKey(): string {
|
|
||||||
if (typeof window === 'undefined') return '';
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
|
||||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
|
||||||
|
|
||||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
|
||||||
useEffect(() => {
|
|
||||||
const update = () => setRouteKey(getRouteKey());
|
|
||||||
window.addEventListener('hashchange', update);
|
|
||||||
window.addEventListener('popstate', update);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('hashchange', update);
|
|
||||||
window.removeEventListener('popstate', update);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
const modules = useMemo(() => {
|
||||||
if (canAccessScheduling(user?.roles)) {
|
if (canAccessScheduling(user?.roles)) {
|
||||||
@@ -68,10 +43,6 @@ function AuthGate() {
|
|||||||
return <UnauthorizedPage message={error || undefined} />;
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
|
||||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
|
||||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
return <Shell modules={modules} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,23 +36,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function authenticate() {
|
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'],
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 检查 sessionStorage 中是否有 JWT
|
// 1. 检查 sessionStorage 中是否有 JWT
|
||||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
@@ -82,7 +65,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const jumpToken = params.get('jumpToken');
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
if (!jumpToken) {
|
if (!jumpToken) {
|
||||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
|
// 演示模式:无 token 时直接放行
|
||||||
|
setState({ isLoading: false, isAuthenticated: true, user: null, error: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
import FeedbackFab from './FeedbackFab';
|
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +15,6 @@ const PATH_MAP: Record<string, string> = {
|
|||||||
'/assets': 'assets',
|
'/assets': 'assets',
|
||||||
'/mileage': 'mileage',
|
'/mileage': 'mileage',
|
||||||
'/scheduling': 'scheduling',
|
'/scheduling': 'scheduling',
|
||||||
'/energy': 'energy',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
@@ -73,7 +71,8 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={false}>
|
<DemoModeProvider enabled={true}>
|
||||||
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* 全局水印 */}
|
{/* 全局水印 */}
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
@@ -107,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' }}>
|
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
{ActiveComponent && <ActiveComponent />}
|
||||||
<FeedbackFab module={activeModule} />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@ import type { WeeklyDetailItem } from './api';
|
|||||||
import { SearchSelect } from '../../components/SearchSelect';
|
import { SearchSelect } from '../../components/SearchSelect';
|
||||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
||||||
|
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
@@ -224,18 +223,11 @@ export default function AssetsModule() {
|
|||||||
setModalLoading(true);
|
setModalLoading(true);
|
||||||
const cat = showPlateNumbers.category;
|
const cat = showPlateNumbers.category;
|
||||||
|
|
||||||
// Weekly categories use the dedicated weekly-detail endpoint.
|
// Weekly categories use the dedicated weekly-detail endpoint
|
||||||
// Pending 不属于 weekly:weekly-detail 不支持 model/batch/location 过滤,
|
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced', Pending: 'pending' };
|
||||||
// 走下面的 /list 路径才能按型号/区域等维度过滤。
|
|
||||||
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced' };
|
|
||||||
if (cat && weeklyTypes[cat]) {
|
if (cat && weeklyTypes[cat]) {
|
||||||
setModalVehicles([]);
|
setModalVehicles([]);
|
||||||
fetchWeeklyDetail(weeklyTypes[cat], {
|
fetchWeeklyDetail(weeklyTypes[cat])
|
||||||
model: showPlateNumbers.model,
|
|
||||||
batch: showPlateNumbers.batch,
|
|
||||||
location: showPlateNumbers.location,
|
|
||||||
source: showPlateNumbers.source,
|
|
||||||
})
|
|
||||||
.then(setModalWeeklyDetail)
|
.then(setModalWeeklyDetail)
|
||||||
.catch(() => setModalWeeklyDetail([]))
|
.catch(() => setModalWeeklyDetail([]))
|
||||||
.finally(() => setModalLoading(false));
|
.finally(() => setModalLoading(false));
|
||||||
@@ -249,10 +241,8 @@ export default function AssetsModule() {
|
|||||||
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
||||||
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
||||||
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
||||||
if (showPlateNumbers.source) params.source = showPlateNumbers.source;
|
|
||||||
if (cat === 'Inventory') params.category = 'Inventory';
|
if (cat === 'Inventory') params.category = 'Inventory';
|
||||||
if (cat === 'Operating') params.category = 'Operating';
|
if (cat === 'Operating') params.category = 'Operating';
|
||||||
if (cat === 'Pending') params.category = 'Pending';
|
|
||||||
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
||||||
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
||||||
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
|
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
|
||||||
@@ -2190,7 +2180,7 @@ export default function AssetsModule() {
|
|||||||
</td>
|
</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-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-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>
|
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -2252,9 +2242,9 @@ export default function AssetsModule() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-bold text-orange-600 cursor-pointer"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2839,7 +2829,7 @@ export default function AssetsModule() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<RotatingFooterHint className="pb-4" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export async function fetchVehicleList(params: {
|
|||||||
department?: string;
|
department?: string;
|
||||||
attendance?: string;
|
attendance?: string;
|
||||||
subject?: string | null;
|
subject?: string | null;
|
||||||
source?: string;
|
|
||||||
}): Promise<VehicleListItem[]> {
|
}): Promise<VehicleListItem[]> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params.batch) query.set('batch', params.batch);
|
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.department) query.set('department', params.department);
|
||||||
if (params.attendance) query.set('attendance', params.attendance);
|
if (params.attendance) query.set('attendance', params.attendance);
|
||||||
if (params.subject) query.set('subject', params.subject);
|
if (params.subject) query.set('subject', params.subject);
|
||||||
if (params.source) query.set('source', params.source);
|
|
||||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +112,6 @@ export async function fetchRegionChart(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeeklyDetail(
|
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||||
type: string,
|
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
||||||
filters?: { model?: string; batch?: string; location?: string; source?: string },
|
|
||||||
): Promise<WeeklyDetailItem[]> {
|
|
||||||
const params = new URLSearchParams({ type });
|
|
||||||
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
|
|
||||||
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
|
|
||||||
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
|
|
||||||
if (filters?.source) params.set('source', filters.source);
|
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export interface RegionTypeBreakdown {
|
|||||||
total: number;
|
total: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
pending: number;
|
|
||||||
customers: string[];
|
customers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,151 +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';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pick: DateQuickPick;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ElectricDaily({ pick }: Props) {
|
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
|
||||||
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="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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import ElectricOverview from './ElectricOverview';
|
|
||||||
import ElectricDaily from './ElectricDaily';
|
|
||||||
import type { DateQuickPick } from './types';
|
|
||||||
|
|
||||||
export type ElectricSubTab = 'daily' | 'overview';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
sub: ElectricSubTab;
|
|
||||||
pick: DateQuickPick;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ElectricView({ sub, pick }: Props) {
|
|
||||||
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily pick={pick} />;
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
|
||||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
|
||||||
import ETCView from './ETCView';
|
|
||||||
import type { DateQuickPick } from './types';
|
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
|
||||||
{ id: 'thisWeek', label: '本周' },
|
|
||||||
{ id: 'thisMonth', label: '本月' },
|
|
||||||
{ id: 'last15', label: '近 15 天' },
|
|
||||||
];
|
|
||||||
|
|
||||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
|
||||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
|
||||||
|
|
||||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
|
||||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
|
||||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
|
||||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
|
||||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnergyModule() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
|
||||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
|
||||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
|
||||||
const [hydroPick, setHydroPick] = useState<DateQuickPick>('last15');
|
|
||||||
const [electricPick, setElectricPick] = useState<DateQuickPick>('last15');
|
|
||||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
|
||||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
|
||||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
|
||||||
// 是否在 daily 模式(需要在 sticky 头部展示日期速选)
|
|
||||||
const showQuickPick = (activeTab === 'hydrogen' && hydroSub === 'daily')
|
|
||||||
|| (activeTab === 'electric' && electricSub === 'daily');
|
|
||||||
const currentPick: DateQuickPick = activeTab === 'electric' ? electricPick : hydroPick;
|
|
||||||
const setPick = (id: DateQuickPick) => activeTab === 'electric' ? setElectricPick(id) : setHydroPick(id);
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
|
||||||
|
|
||||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
|
||||||
{/* 背景不透明(页面色),避免下方快捷选按钮在滚动时透过来"半截露脸" */}
|
|
||||||
<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-2 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
|
||||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
const active = activeTab === tab.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
|
||||||
{active && (
|
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
|
||||||
{showSubTabs && (
|
|
||||||
<div className="p-1 flex gap-1">
|
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
|
||||||
const active = currentSub === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setSub(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 日期速选:daily 模式时跟着 sticky,避免滚动后被遮挡 */}
|
|
||||||
{showQuickPick && (
|
|
||||||
<div className="px-2 pb-2 pt-1 border-t border-slate-50 flex items-center gap-2 overflow-x-auto">
|
|
||||||
{QUICK_PICK_OPTIONS.map(opt => {
|
|
||||||
const active = currentPick === opt.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
onClick={() => setPick(opt.id)}
|
|
||||||
className={`shrink-0 rounded-lg px-3 py-1 text-[11px] font-bold border transition-colors ${
|
|
||||||
active
|
|
||||||
? '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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} pick={hydroPick} />}
|
|
||||||
{activeTab === 'electric' && <ElectricView sub={electricSub} pick={electricPick} />}
|
|
||||||
{activeTab === 'etc' && <ETCView />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,208 +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';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pick: DateQuickPick;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HydrogenDaily({ pick }: Props) {
|
|
||||||
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">
|
|
||||||
{/* 客户类型 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
|
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const REGION_COLORS = [
|
|
||||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
|
||||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
|
||||||
'#94a3b8',
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
export default function HydrogenOverview() {
|
|
||||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
fetchHydrogenOverview()
|
|
||||||
.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 <HydrogenOverviewSkeleton />;
|
|
||||||
}
|
|
||||||
const k = data.kpi;
|
|
||||||
const top5 = data.top5;
|
|
||||||
const regions = data.regions;
|
|
||||||
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 起,每 1 分钟更新
|
|
||||||
</div>
|
|
||||||
<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-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-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" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
|
||||||
<span className="text-slate-600">{r.region}</span>
|
|
||||||
<span className="text-slate-400 ml-auto font-bold">{(r.share * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<RotatingFooterHint />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import HydrogenOverview from './HydrogenOverview';
|
|
||||||
import HydrogenDaily from './HydrogenDaily';
|
|
||||||
import type { DateQuickPick } from './types';
|
|
||||||
|
|
||||||
export type HydrogenSubTab = 'daily' | 'overview';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
sub: HydrogenSubTab;
|
|
||||||
pick: DateQuickPick;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HydrogenView({ sub, pick }: Props) {
|
|
||||||
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily pick={pick} />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { fetchJson } from '../../auth/api-client';
|
|
||||||
import type {
|
|
||||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenDailyRow,
|
|
||||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
|
||||||
CustomerType, DateQuickPick,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const BASE = '/api/energy';
|
|
||||||
|
|
||||||
export interface HydrogenOverviewResponse {
|
|
||||||
kpi: HydrogenKpi;
|
|
||||||
top5: HydrogenStationTop[];
|
|
||||||
regions: HydrogenRegionShare[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchHydrogenOverview(): Promise<HydrogenOverviewResponse> {
|
|
||||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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()}`);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
export type CustomerType = 'external' | 'lingniu';
|
|
||||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
|
||||||
|
|
||||||
export interface HydrogenKpi {
|
|
||||||
yearKg: number;
|
|
||||||
yearFee: number;
|
|
||||||
ourYearKg: number;
|
|
||||||
ourYearFee: number;
|
|
||||||
customerYearKg: number;
|
|
||||||
monthKg: number;
|
|
||||||
monthFee: number;
|
|
||||||
todayKg: number;
|
|
||||||
todayFee: 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 HydrogenStationRow {
|
|
||||||
name: string;
|
|
||||||
pricePerKg: number;
|
|
||||||
kg: number;
|
|
||||||
chainPct: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HydrogenDailyRow {
|
|
||||||
date: string;
|
|
||||||
totalKg: number;
|
|
||||||
chainPct: number;
|
|
||||||
customerType: CustomerType;
|
|
||||||
stations: HydrogenStationRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectricKpi {
|
|
||||||
totalKwh: number;
|
|
||||||
totalFee: number;
|
|
||||||
monthKwh: number;
|
|
||||||
monthFee: number;
|
|
||||||
todayKwh: number;
|
|
||||||
todayFee: number;
|
|
||||||
todayChainPct: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectricDailyRow {
|
|
||||||
date: string;
|
|
||||||
kwh: number;
|
|
||||||
fee: number;
|
|
||||||
chainPct: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectricMonthGroup {
|
|
||||||
month: string;
|
|
||||||
kwh: number;
|
|
||||||
fee: number;
|
|
||||||
rows: ElectricDailyRow[];
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { motion } from 'motion/react';
|
|||||||
import MonitoringView from './MonitoringView';
|
import MonitoringView from './MonitoringView';
|
||||||
import StatisticsView from './StatisticsView';
|
import StatisticsView from './StatisticsView';
|
||||||
import DailyReportView from './DailyReportView';
|
import DailyReportView from './DailyReportView';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
||||||
|
|
||||||
export default function MileageModule() {
|
export default function MileageModule() {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||||
@@ -53,7 +52,6 @@ export default function MileageModule() {
|
|||||||
) : (
|
) : (
|
||||||
<DailyReportView />
|
<DailyReportView />
|
||||||
)}
|
)}
|
||||||
<RotatingFooterHint />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
Truck, Filter, ChevronDown,
|
Truck, Search, Filter, ChevronDown,
|
||||||
Maximize2, Minimize2, RotateCcw,
|
Maximize2, Minimize2, RotateCcw,
|
||||||
ArrowUp, ArrowDown, ChevronsUp, Download,
|
ArrowUp, ArrowDown, ChevronsUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||||
import { fetchMonitoring } from './api';
|
import { fetchMonitoring } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import PlateMultiSelect from './PlateMultiSelect';
|
|
||||||
import { exportMileageXlsx } from './xlsx-export';
|
|
||||||
import VehicleDetailModal from './VehicleDetailModal';
|
|
||||||
|
|
||||||
const SearchableSelect = ({
|
const SearchableSelect = ({
|
||||||
options,
|
options,
|
||||||
@@ -105,18 +102,15 @@ export default function MonitoringView() {
|
|||||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||||
|
|
||||||
// New filters from image
|
// New filters from image
|
||||||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
const [filterPlate, setFilterPlate] = useState('All');
|
||||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||||
const [filterProject, setFilterProject] = useState('All');
|
const [filterProject, setFilterProject] = useState('All');
|
||||||
const [filterEntity, setFilterEntity] = useState('All');
|
const [filterEntity, setFilterEntity] = useState('All');
|
||||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||||
const [filterRegion, setFilterRegion] = useState('All');
|
|
||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = 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 [filterDate, setFilterDate] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||||
@@ -125,7 +119,7 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
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 [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
@@ -153,8 +147,7 @@ export default function MonitoringView() {
|
|||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
date: filterDate || undefined,
|
date: filterDate || undefined,
|
||||||
@@ -166,7 +159,7 @@ export default function MonitoringView() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(d.page < d.totalPages);
|
setHasMore(d.page < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -186,8 +179,7 @@ export default function MonitoringView() {
|
|||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
date: filterDate || undefined,
|
date: filterDate || undefined,
|
||||||
@@ -196,53 +188,13 @@ export default function MonitoringView() {
|
|||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setHasMore(nextPage < d.totalPages);
|
setHasMore(nextPage < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||||
|
|
||||||
// 筛选/排序变化时重新加载
|
// 筛选/排序变化时重新加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFirstPage();
|
loadFirstPage();
|
||||||
}, [loadFirstPage]);
|
}, [loadFirstPage]);
|
||||||
|
|
||||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
|
||||||
useEffect(() => {
|
|
||||||
if (filterPlates.length === 0) return;
|
|
||||||
const valid = new Set(filterOptions.plates);
|
|
||||||
const next = filterPlates.filter(p => valid.has(p));
|
|
||||||
if (next.length !== filterPlates.length) setFilterPlates(next);
|
|
||||||
}, [filterOptions.plates, filterPlates]);
|
|
||||||
|
|
||||||
// 下载当前筛选结果为 xlsx
|
|
||||||
const handleDownload = useCallback(async () => {
|
|
||||||
if (exporting) return;
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const d = await fetchMonitoring({
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
limit: 9999,
|
|
||||||
page: 1,
|
|
||||||
search: searchTerm || undefined,
|
|
||||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
|
||||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
|
||||||
project: filterProject !== 'All' ? filterProject : undefined,
|
|
||||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
|
||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
|
||||||
date: filterDate || undefined,
|
|
||||||
});
|
|
||||||
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('export failed', err);
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
|
||||||
|
|
||||||
// 每分钟自动刷新
|
// 每分钟自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||||
@@ -308,15 +260,14 @@ export default function MonitoringView() {
|
|||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
date: filterDate || undefined,
|
date: filterDate || undefined,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]);
|
||||||
|
|
||||||
// 全屏时禁止背景滚动
|
// 全屏时禁止背景滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -440,9 +391,14 @@ export default function MonitoringView() {
|
|||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span>车牌号</span>
|
<span>车牌号</span>
|
||||||
<span className="text-[9px] text-slate-500 font-normal">
|
<select
|
||||||
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
|
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"
|
||||||
</span>
|
value={filterPlate}
|
||||||
|
onChange={(e) => setFilterPlate(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="All">全部车牌</option>
|
||||||
|
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
@@ -570,14 +526,6 @@ export default function MonitoringView() {
|
|||||||
>
|
>
|
||||||
<Maximize2 size={14} />
|
<Maximize2 size={14} />
|
||||||
</button>
|
</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>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<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="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||||
@@ -611,32 +559,32 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={filterOptions.targetNames}
|
options={['__EMPTY__', ...departments]}
|
||||||
value={filterTargetName}
|
value={filterDept}
|
||||||
onChange={setFilterTargetName}
|
onChange={setFilterDept}
|
||||||
placeholder="批次型号"
|
placeholder="按部门"
|
||||||
/>
|
/>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={filterOptions.regions}
|
options={['__EMPTY__', ...filterOptions.customers]}
|
||||||
value={filterRegion}
|
value={filterCustomer}
|
||||||
onChange={setFilterRegion}
|
onChange={setFilterCustomer}
|
||||||
placeholder="运营区域"
|
placeholder="按客户"
|
||||||
/>
|
/>
|
||||||
<PlateMultiSelect
|
<SearchableSelect
|
||||||
allPlates={plateNumbers}
|
options={plateNumbers}
|
||||||
selected={filterPlates}
|
value={filterPlate}
|
||||||
onChange={setFilterPlates}
|
onChange={setFilterPlate}
|
||||||
placeholder="按车牌"
|
placeholder="按车牌"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
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' ? '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} />
|
<Filter size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -664,10 +612,23 @@ export default function MonitoringView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Department */}
|
{/* Department */}
|
||||||
<div className="space-y-1.5">
|
<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
|
<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"
|
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}
|
value={filterDept}
|
||||||
@@ -679,35 +640,6 @@ export default function MonitoringView() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Rent Status */}
|
||||||
<div className="space-y-1.5">
|
<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>
|
||||||
@@ -737,6 +669,19 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Plate Prefix */}
|
||||||
<div className="space-y-1.5">
|
<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>
|
||||||
@@ -779,13 +724,11 @@ export default function MonitoringView() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
setFilterDept('All');
|
setFilterDept('All');
|
||||||
setFilterPlates([]);
|
setFilterPlate('All');
|
||||||
setFilterCustomer('All');
|
setFilterCustomer('All');
|
||||||
setFilterProject('All');
|
setFilterProject('All');
|
||||||
setFilterEntity('All');
|
setFilterEntity('All');
|
||||||
setFilterPlatePrefix('All');
|
setFilterPlatePrefix('All');
|
||||||
setFilterTargetName('All');
|
|
||||||
setFilterRegion('All');
|
|
||||||
setFilterMileageRange({ min: '', max: '' });
|
setFilterMileageRange({ min: '', max: '' });
|
||||||
setAppliedMileageRange({ min: '', max: '' });
|
setAppliedMileageRange({ min: '', max: '' });
|
||||||
}}
|
}}
|
||||||
@@ -811,23 +754,22 @@ export default function MonitoringView() {
|
|||||||
{/* Active Filter Tags */}
|
{/* Active Filter Tags */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const tags: { label: string; onClear: () => void }[] = [];
|
const tags: { label: string; onClear: () => void }[] = [];
|
||||||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
|
||||||
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
|
|
||||||
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
|
|
||||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('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 (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('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 (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.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 (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 (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||||||
if (tags.length === 0) return null;
|
if (tags.length === 0) return null;
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
|
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
|
||||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||||
setFilterDate('');
|
setFilterDate('');
|
||||||
};
|
};
|
||||||
@@ -901,8 +843,10 @@ export default function MonitoringView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
key={v.plate}
|
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"
|
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={() => setDetailVehicle(v)}
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(v.plate);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -966,8 +910,6 @@ export default function MonitoringView() {
|
|||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showBackToTop && (
|
{showBackToTop && (
|
||||||
|
|||||||
@@ -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="粘贴或输入车牌 支持换行/逗号/空格分隔,回车或点添加确认"
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ export async function fetchMonitoring(params?: {
|
|||||||
rentStatus?: string;
|
rentStatus?: string;
|
||||||
platePrefix?: string;
|
platePrefix?: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
region?: string;
|
|
||||||
plate?: string;
|
plate?: string;
|
||||||
mileageMin?: string;
|
mileageMin?: string;
|
||||||
mileageMax?: string;
|
mileageMax?: string;
|
||||||
@@ -35,7 +34,6 @@ export async function fetchMonitoring(params?: {
|
|||||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||||
if (params?.targetName) query.set('targetName', params.targetName);
|
if (params?.targetName) query.set('targetName', params.targetName);
|
||||||
if (params?.region) query.set('region', params.region);
|
|
||||||
if (params?.plate) query.set('plate', params.plate);
|
if (params?.plate) query.set('plate', params.plate);
|
||||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||||
@@ -61,29 +59,3 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
|
|||||||
params.set('days', String(days));
|
params.set('days', String(days));
|
||||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleRecentDay {
|
|
||||||
date: string;
|
|
||||||
dailyKm: number;
|
|
||||||
isDataSynced: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VehicleRecentResponse {
|
|
||||||
plate: string;
|
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
days: VehicleRecentDay[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchVehicleRecent(
|
|
||||||
plate: string,
|
|
||||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
|
||||||
): Promise<VehicleRecentResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (range.start) params.set('start', range.start);
|
|
||||||
if (range.end) params.set('end', range.end);
|
|
||||||
if (range.days != null) params.set('days', String(range.days));
|
|
||||||
return fetchJson<VehicleRecentResponse>(
|
|
||||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface MonitoringVehicle {
|
|||||||
rentStatus: string | null;
|
rentStatus: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringStats {
|
export interface MonitoringStats {
|
||||||
@@ -31,7 +30,6 @@ export interface MonitoringFilters {
|
|||||||
rentStatuses: string[];
|
rentStatuses: string[];
|
||||||
platePrefixes: { prefix: string; count: number }[];
|
platePrefixes: { prefix: string; count: number }[];
|
||||||
targetNames: string[];
|
targetNames: string[];
|
||||||
regions: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringData {
|
export interface MonitoringData {
|
||||||
|
|||||||
@@ -1,81 +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 (!v.isDataSynced) return '未对接';
|
|
||||||
if (kind === 'today') return Math.max(0, Math.round(v.dailyKm || 0));
|
|
||||||
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
|
||||||
const data: (string | number)[][] = [
|
|
||||||
[...HEADERS],
|
|
||||||
...vehicles.map(v => [
|
|
||||||
statusLabel(v),
|
|
||||||
v.plate,
|
|
||||||
v.customer || '',
|
|
||||||
v.department || '',
|
|
||||||
v.project || '',
|
|
||||||
v.rentStatus || '',
|
|
||||||
v.region || '',
|
|
||||||
mileageCell(v, 'today'),
|
|
||||||
mileageCell(v, 'total'),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
|
||||||
|
|
||||||
ws['!cols'] = [
|
|
||||||
{ wch: 8 }, // 状态
|
|
||||||
{ wch: 12 }, // 车牌号
|
|
||||||
{ wch: 28 }, // 客户
|
|
||||||
{ wch: 14 }, // 业务部门
|
|
||||||
{ wch: 16 }, // 项目
|
|
||||||
{ wch: 10 }, // 租赁状态
|
|
||||||
{ wch: 12 }, // 运营区域
|
|
||||||
{ wch: 14 }, // 今日里程
|
|
||||||
{ wch: 14 }, // 累计里程
|
|
||||||
];
|
|
||||||
|
|
||||||
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
|
||||||
|
|
||||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
|
||||||
for (let c = 0; c < HEADERS.length; c++) {
|
|
||||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
|
||||||
if (ws[ref]) {
|
|
||||||
(ws[ref] as { s?: unknown }).s = {
|
|
||||||
font: { bold: true, color: { rgb: 'FFFFFF' } },
|
|
||||||
fill: { fgColor: { rgb: '2563EB' } },
|
|
||||||
alignment: { horizontal: 'center', vertical: 'center' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getFullYear();
|
|
||||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
const d = String(now.getDate()).padStart(2, '0');
|
|
||||||
const hh = String(now.getHours()).padStart(2, '0');
|
|
||||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
|
|
||||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
|
|
||||||
XLSX.writeFile(wb, filename);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import SuggestionDetail from './SuggestionDetail';
|
|||||||
import NotificationHistory from './NotificationHistory';
|
import NotificationHistory from './NotificationHistory';
|
||||||
import { exportSuggestionsCsv } from './csv-export';
|
import { exportSuggestionsCsv } from './csv-export';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
@@ -633,7 +632,6 @@ export default function SchedulingModule() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RotatingFooterHint className="pb-4" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { JwtPayload, AuthUser } from './types.js';
|
|||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
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) {
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
const path = c.req.path;
|
const path = c.req.path;
|
||||||
@@ -14,21 +14,6 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
return 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'],
|
|
||||||
};
|
|
||||||
c.set('user', devUser);
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳过不需要认证的路径
|
// 跳过不需要认证的路径
|
||||||
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -28,7 +28,5 @@ export {
|
|||||||
FULL_ACCESS_ROLES,
|
FULL_ACCESS_ROLES,
|
||||||
DEPT_ACCESS_ROLES,
|
DEPT_ACCESS_ROLES,
|
||||||
SCHEDULING_ACCESS_ROLES,
|
SCHEDULING_ACCESS_ROLES,
|
||||||
FEEDBACK_ADMIN_ROLES,
|
|
||||||
canAccessScheduling,
|
canAccessScheduling,
|
||||||
canManageFeedback,
|
|
||||||
} from '../../shared/auth/roles.js';
|
} from '../../shared/auth/roles.js';
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import dotenv from 'dotenv';
|
|||||||
import vehiclesRouter from './routes/vehicles.js';
|
import vehiclesRouter from './routes/vehicles.js';
|
||||||
import mileageRouter from './routes/mileage/index.js';
|
import mileageRouter from './routes/mileage/index.js';
|
||||||
import schedulingRouter from './routes/scheduling/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 { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -28,9 +25,6 @@ app.use('/api/*', authMiddleware);
|
|||||||
app.route('/api/vehicles', vehiclesRouter);
|
app.route('/api/vehicles', vehiclesRouter);
|
||||||
app.route('/api/mileage', mileageRouter);
|
app.route('/api/mileage', mileageRouter);
|
||||||
app.route('/api/scheduling', schedulingRouter);
|
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() }));
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
@@ -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 tab_truck
|
|
||||||
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
|
|
||||||
arr,
|
|
||||||
);
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const r of rows) {
|
|
||||||
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// POST /api/ele/import — 上传 xlsx 文件
|
|
||||||
// =========================================================
|
|
||||||
app.post('/import', async (c) => {
|
|
||||||
await ensureChargeRecordTable();
|
|
||||||
const form = await c.req.formData();
|
|
||||||
const file = form.get('file');
|
|
||||||
if (!(file instanceof File)) {
|
|
||||||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
|
||||||
}
|
|
||||||
const filename = file.name || 'unnamed.xlsx';
|
|
||||||
const buf = await file.arrayBuffer();
|
|
||||||
let parsed: ParsedRow[];
|
|
||||||
try {
|
|
||||||
parsed = parseSheet(buf);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('parseSheet error:', e);
|
|
||||||
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
|
|
||||||
}
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件内去重
|
|
||||||
const dedupMap = new Map<string, ParsedRow>();
|
|
||||||
for (const p of parsed) dedupMap.set(p.orderNo, p);
|
|
||||||
const records = Array.from(dedupMap.values());
|
|
||||||
const fileDuplicates = parsed.length - records.length;
|
|
||||||
|
|
||||||
// 系统车辆匹配
|
|
||||||
const allPlates = new Set<string>();
|
|
||||||
for (const r of records) {
|
|
||||||
if (r.values.plate) allPlates.add(r.values.plate);
|
|
||||||
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
|
|
||||||
}
|
|
||||||
const plateMap = await buildPlateLookup(allPlates);
|
|
||||||
|
|
||||||
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const importedAt = new Date();
|
|
||||||
|
|
||||||
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
|
|
||||||
const sql = `INSERT IGNORE INTO bi_ele_charge_record
|
|
||||||
(order_no, station_no, station_name, terminal_name, region, city, district,
|
|
||||||
operating_company, station_type, order_status, charge_form,
|
|
||||||
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
|
|
||||||
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
|
|
||||||
matched_truck_id, matched_plate, vehicle_kind, raw_json,
|
|
||||||
batch_id, imported_at)
|
|
||||||
VALUES ?`;
|
|
||||||
|
|
||||||
const values = records.map(r => {
|
|
||||||
const plate = r.values.plate || r.values.judgedPlate;
|
|
||||||
const matchedId = plate ? plateMap.get(plate) || null : null;
|
|
||||||
// 命中系统车辆=internal;其余(含车牌为空)一律 external
|
|
||||||
const kind = matchedId ? 'internal' : 'external';
|
|
||||||
return [
|
|
||||||
r.orderNo,
|
|
||||||
r.values.stationNo, r.values.stationName, r.values.terminalName,
|
|
||||||
r.values.region, r.values.city, r.values.district,
|
|
||||||
r.values.operatingCompany, r.values.stationType,
|
|
||||||
r.values.orderStatus, r.values.chargeForm,
|
|
||||||
r.values.startTime, r.values.endTime, r.values.duration,
|
|
||||||
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
|
|
||||||
r.values.plate, r.values.judgedPlate, r.values.vin,
|
|
||||||
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
|
|
||||||
matchedId, matchedId ? plate : null, kind,
|
|
||||||
JSON.stringify(r.raw),
|
|
||||||
batchId, importedAt,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
|
|
||||||
const inserted = result.affectedRows;
|
|
||||||
const dbDuplicates = records.length - inserted;
|
|
||||||
|
|
||||||
// 统计内/外(无车牌也算外部)
|
|
||||||
let internal = 0, external = 0;
|
|
||||||
for (const r of records) {
|
|
||||||
const plate = r.values.plate || r.values.judgedPlate;
|
|
||||||
if (plate && plateMap.has(plate)) internal++;
|
|
||||||
else external++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
ok: true,
|
|
||||||
filename,
|
|
||||||
batchId,
|
|
||||||
parsed: parsed.length,
|
|
||||||
fileDuplicates,
|
|
||||||
inserted,
|
|
||||||
dbDuplicates,
|
|
||||||
breakdown: { internal, external },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// GET /api/ele/list — 分页列表(最新优先)
|
|
||||||
// =========================================================
|
|
||||||
app.get('/list', async (c) => {
|
|
||||||
await ensureChargeRecordTable();
|
|
||||||
const page = Math.max(1, Number(c.req.query('page')) || 1);
|
|
||||||
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
|
||||||
const kind = c.req.query('kind') || '';
|
|
||||||
const batchId = c.req.query('batchId') || '';
|
|
||||||
const search = c.req.query('search') || '';
|
|
||||||
|
|
||||||
const where: string[] = ['1=1'];
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
if (kind === 'internal' || kind === 'external') {
|
|
||||||
where.push('vehicle_kind = ?');
|
|
||||||
params.push(kind);
|
|
||||||
}
|
|
||||||
if (batchId) {
|
|
||||||
where.push('batch_id = ?');
|
|
||||||
params.push(batchId);
|
|
||||||
}
|
|
||||||
if (search) {
|
|
||||||
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
|
|
||||||
const q = `%${search}%`;
|
|
||||||
params.push(q, q, q);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT id, order_no, station_name, terminal_name, region, city,
|
|
||||||
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
|
|
||||||
plate, judged_plate, customer_name, vehicle_kind,
|
|
||||||
batch_id, imported_at
|
|
||||||
FROM bi_ele_charge_record
|
|
||||||
WHERE ${where.join(' AND ')}
|
|
||||||
ORDER BY start_time DESC, id DESC
|
|
||||||
LIMIT ? OFFSET ?`,
|
|
||||||
[...params, limit, offset],
|
|
||||||
);
|
|
||||||
const [countRows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
const total = Number(countRows[0]?.total || 0);
|
|
||||||
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// GET /api/ele/batches — 批次列表
|
|
||||||
// =========================================================
|
|
||||||
app.get('/batches', async (c) => {
|
|
||||||
await ensureChargeRecordTable();
|
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT batch_id,
|
|
||||||
MIN(imported_at) AS imported_at,
|
|
||||||
COUNT(*) AS records,
|
|
||||||
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
|
|
||||||
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
|
|
||||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
|
||||||
ROUND(SUM(fee), 2) AS total_fee
|
|
||||||
FROM bi_ele_charge_record
|
|
||||||
GROUP BY batch_id
|
|
||||||
ORDER BY imported_at DESC
|
|
||||||
LIMIT 50`,
|
|
||||||
);
|
|
||||||
return c.json({ items: rows });
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// GET /api/ele/aggregate — 聚合统计
|
|
||||||
// =========================================================
|
|
||||||
app.get('/aggregate', async (c) => {
|
|
||||||
await ensureChargeRecordTable();
|
|
||||||
// 全量分类汇总
|
|
||||||
const [overallRows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT vehicle_kind,
|
|
||||||
COUNT(*) AS records,
|
|
||||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
|
||||||
ROUND(SUM(fee), 2) AS total_fee
|
|
||||||
FROM bi_ele_charge_record
|
|
||||||
GROUP BY vehicle_kind`,
|
|
||||||
);
|
|
||||||
// 近 30 日按日
|
|
||||||
const [dailyRows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
|
||||||
vehicle_kind,
|
|
||||||
COUNT(*) AS records,
|
|
||||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
|
||||||
ROUND(SUM(fee), 2) AS total_fee
|
|
||||||
FROM bi_ele_charge_record
|
|
||||||
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
|
||||||
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
|
|
||||||
ORDER BY date DESC`,
|
|
||||||
);
|
|
||||||
return c.json({ overall: overallRows, daily: dailyRows });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* 简单 TTL 内存缓存。
|
|
||||||
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。
|
|
||||||
* 同一 key 并发请求只会触发一次 loader(共享 in-flight Promise)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Entry<T> {
|
|
||||||
value: T;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TTL_MS = 60 * 1000;
|
|
||||||
|
|
||||||
const cache = new Map<string, Entry<unknown>>();
|
|
||||||
const inflight = new Map<string, Promise<unknown>>();
|
|
||||||
|
|
||||||
export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> {
|
|
||||||
const now = Date.now();
|
|
||||||
const hit = cache.get(key);
|
|
||||||
if (hit && hit.expiresAt > now) {
|
|
||||||
return hit.value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同一 key 并发只跑一次 loader
|
|
||||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
|
||||||
if (ongoing) return ongoing;
|
|
||||||
|
|
||||||
const p = loader()
|
|
||||||
.then(value => {
|
|
||||||
cache.set(key, { value, expiresAt: Date.now() + TTL_MS });
|
|
||||||
return value;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflight.delete(key);
|
|
||||||
});
|
|
||||||
inflight.set(key, p as Promise<unknown>);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 仅用于测试或调试:清空所有缓存 */
|
|
||||||
export function _clearEnergyCache() {
|
|
||||||
cache.clear();
|
|
||||||
inflight.clear();
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
import { Hono } from 'hono';
|
|
||||||
import type { RowDataPacket } from 'mysql2';
|
|
||||||
import pool from '../../db.js';
|
|
||||||
import { cached } from './cache.js';
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
|
||||||
|
|
||||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
|
||||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
|
||||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
|
||||||
|
|
||||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
|
||||||
function customerClause(field: string, customer: CustomerKind): string {
|
|
||||||
if (customer === 'external') return `${field} IS NULL`;
|
|
||||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
|
||||||
return '1=1';
|
|
||||||
}
|
|
||||||
|
|
||||||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
|
||||||
|
|
||||||
function rangeClause(localExpr: string, range: Range): string {
|
|
||||||
switch (range) {
|
|
||||||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
|
||||||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
|
||||||
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
|
||||||
function enumerateDates(range: Range): string[] {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
let start: Date;
|
|
||||||
if (range === 'thisWeek') {
|
|
||||||
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
|
||||||
const day = today.getDay() || 7; // 周日 7
|
|
||||||
start = new Date(today);
|
|
||||||
start.setDate(today.getDate() - (day - 1));
|
|
||||||
} else if (range === 'thisMonth') {
|
|
||||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
||||||
} else {
|
|
||||||
start = new Date(today);
|
|
||||||
start.setDate(today.getDate() - 14);
|
|
||||||
}
|
|
||||||
const result: string[] = [];
|
|
||||||
const cur = new Date(start);
|
|
||||||
while (cur <= today) {
|
|
||||||
result.push(fmt(cur));
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// 氢能 总览:KPI + Top5 + 区域占比
|
|
||||||
// =========================================================
|
|
||||||
app.get('/hydrogen/overview', async (c) => {
|
|
||||||
const data = await cached('hydrogen/overview', async () => {
|
|
||||||
// KPI(年/月/日 + 我方/客户分解 + 累计羚牛承担)
|
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
|
||||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NOT NULL
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NOT NULL
|
|
||||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NULL
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
|
||||||
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
|
||||||
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
|
||||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
|
||||||
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
|
||||||
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
|
||||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
|
||||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
|
||||||
FROM tab_energy_hydrogen_bill
|
|
||||||
WHERE is_deleted = 0 AND hydrogen_time >= ?`,
|
|
||||||
[HYDROGEN_MIN_DATE],
|
|
||||||
);
|
|
||||||
const k = kpiRows[0] ?? {};
|
|
||||||
const kpi = {
|
|
||||||
yearKg: Number(k.yearKg) || 0,
|
|
||||||
yearFee: Number(k.yearFee) || 0,
|
|
||||||
ourYearKg: Number(k.ourYearKg) || 0,
|
|
||||||
ourYearFee: Number(k.ourYearFee) || 0,
|
|
||||||
customerYearKg: Number(k.customerYearKg) || 0,
|
|
||||||
monthKg: Number(k.monthKg) || 0,
|
|
||||||
monthFee: Number(k.monthFee) || 0,
|
|
||||||
todayKg: Number(k.todayKg) || 0,
|
|
||||||
todayFee: Number(k.todayFee) || 0,
|
|
||||||
lingniuBornKg: Number(k.lingniuBornKg) || 0,
|
|
||||||
lingniuBornFee: Number(k.lingniuBornFee) || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Top5 加氢站(本年)
|
|
||||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
|
||||||
MAX(i.hydrogen_station_name),
|
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
|
||||||
SUM(b.cost_expense) AS fee
|
|
||||||
FROM tab_energy_hydrogen_bill b
|
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
|
||||||
WHERE b.is_deleted = 0
|
|
||||||
AND b.hydrogen_time >= ?
|
|
||||||
AND YEAR(b.hydrogen_time) = YEAR(CURDATE())
|
|
||||||
GROUP BY b.hydrogen_station_id
|
|
||||||
ORDER BY kg DESC
|
|
||||||
LIMIT 5`,
|
|
||||||
[HYDROGEN_MIN_DATE],
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 区域占比(按城市,本年)— 取前 8,其余合并为"其他"
|
|
||||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT region, SUM(kg) AS kg FROM (
|
|
||||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
|
||||||
b.hydrogen_quantity AS kg
|
|
||||||
FROM tab_energy_hydrogen_bill b
|
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
|
||||||
WHERE b.is_deleted = 0
|
|
||||||
AND b.hydrogen_time >= ?
|
|
||||||
AND YEAR(b.hydrogen_time) = YEAR(CURDATE())
|
|
||||||
) r
|
|
||||||
GROUP BY region
|
|
||||||
ORDER BY kg DESC`,
|
|
||||||
[HYDROGEN_MIN_DATE],
|
|
||||||
);
|
|
||||||
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 }] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return { kpi, top5, regions };
|
|
||||||
});
|
|
||||||
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 data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
|
||||||
|
|
||||||
const where = [
|
|
||||||
'b.is_deleted = 0',
|
|
||||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
|
||||||
rangeClause(`b.hydrogen_time`, range),
|
|
||||||
customerClause('b.truck_id', customer),
|
|
||||||
].join(' AND ');
|
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
|
||||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
|
||||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
|
||||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
|
||||||
b.hydrogen_station_id AS stationId,
|
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
|
||||||
MAX(i.hydrogen_station_name),
|
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
|
||||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
|
||||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
|
||||||
MAX(b.cost_price) AS pricePerKg
|
|
||||||
FROM tab_energy_hydrogen_bill b
|
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
|
||||||
WHERE ${where}
|
|
||||||
GROUP BY d, b.hydrogen_station_id
|
|
||||||
ORDER BY d DESC, kg DESC`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 站点环比:同站点上一条记录的 kg
|
|
||||||
// 按 stationId 分组、按日期升序计算
|
|
||||||
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
|
|
||||||
const flat: StationRow[] = stationRows.map(r => ({
|
|
||||||
date: r.d as string,
|
|
||||||
stationId: Number(r.stationId),
|
|
||||||
name: r.stationName as string,
|
|
||||||
kg: Number(r.kg) || 0,
|
|
||||||
pricePerKg: Number(r.pricePerKg) || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 计算日级总量 + 日级环比
|
|
||||||
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
|
|
||||||
for (const s of flat) {
|
|
||||||
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
|
|
||||||
const e = dayMap.get(s.date)!;
|
|
||||||
e.totalKg += s.kg;
|
|
||||||
e.stations.push(s);
|
|
||||||
}
|
|
||||||
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
|
|
||||||
const dayChainPct = new Map<string, number>();
|
|
||||||
let prev = 0;
|
|
||||||
for (const d of dates) {
|
|
||||||
const cur = dayMap.get(d)!.totalKg;
|
|
||||||
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
|
|
||||||
prev = cur;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 站点级环比:按 stationId 分组按日期升序
|
|
||||||
const stationPrev = new Map<number, number>();
|
|
||||||
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
|
|
||||||
// 需要按 stationId 分组排序
|
|
||||||
const byStation = new Map<number, StationRow[]>();
|
|
||||||
for (const s of flat) {
|
|
||||||
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
|
|
||||||
byStation.get(s.stationId)!.push(s);
|
|
||||||
}
|
|
||||||
for (const [, list] of byStation) {
|
|
||||||
list.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
let p = 0;
|
|
||||||
for (const r of list) {
|
|
||||||
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
|
|
||||||
p = r.kg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
|
||||||
const allDates = enumerateDates(range);
|
|
||||||
const fullDays = allDates.map(date => {
|
|
||||||
const info = dayMap.get(date);
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
|
||||||
chainPct: dayChainPct.get(date) ?? 0,
|
|
||||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
|
||||||
stations: info
|
|
||||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
|
||||||
name: s.name,
|
|
||||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
|
||||||
kg: Math.round(s.kg * 100) / 100,
|
|
||||||
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
|
||||||
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
let prevKg = 0;
|
|
||||||
for (const d of ascDays) {
|
|
||||||
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
|
||||||
prevKg = d.totalKg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按日期降序返回
|
|
||||||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
return c.json(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
|
||||||
// =========================================================
|
|
||||||
app.get('/electric/overview', async (c) => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
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 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;
|
|
||||||
});
|
|
||||||
return c.json(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
import mileagePool from '../../mileage-db.js';
|
||||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.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;
|
let monitoringCache: MonitoringCache | null = null;
|
||||||
|
|
||||||
export function getCache(): MonitoringCache | null {
|
export function getCache(): MonitoringCache | null {
|
||||||
@@ -47,14 +38,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
|
|||||||
.map(([prefix, count]) => ({ prefix, count }))
|
.map(([prefix, count]) => ({ prefix, count }))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
|
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
|
||||||
const regions = Array.from(regionSet).sort((a, b) => {
|
|
||||||
const ai = REGION_ORDER.indexOf(a);
|
|
||||||
const bi = REGION_ORDER.indexOf(b);
|
|
||||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MileageRow {
|
interface MileageRow {
|
||||||
@@ -115,7 +99,6 @@ function mergeVehicles(
|
|||||||
rentStatus: info?.rent_status || null,
|
rentStatus: info?.rent_status || null,
|
||||||
entity: info?.entity || null,
|
entity: info?.entity || null,
|
||||||
project: info?.project || null,
|
project: info?.project || null,
|
||||||
region: regionMap[m.plate] || null,
|
|
||||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { refreshMonitoringCache } from './cache.js';
|
|||||||
import monitoringRouter from './monitoring.js';
|
import monitoringRouter from './monitoring.js';
|
||||||
import targetsRouter from './targets.js';
|
import targetsRouter from './targets.js';
|
||||||
import trendRouter from './trend.js';
|
import trendRouter from './trend.js';
|
||||||
import vehicleRecentRouter from './vehicle-recent.js';
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -11,7 +10,6 @@ app.route('/monitoring', monitoringRouter);
|
|||||||
app.route('/targets', targetsRouter);
|
app.route('/targets', targetsRouter);
|
||||||
app.route('/target', targetsRouter);
|
app.route('/target', targetsRouter);
|
||||||
app.route('/trend', trendRouter);
|
app.route('/trend', trendRouter);
|
||||||
app.route('/vehicle', vehicleRecentRouter);
|
|
||||||
|
|
||||||
// 启动时立即刷新缓存,之后每分钟刷新
|
// 启动时立即刷新缓存,之后每分钟刷新
|
||||||
refreshMonitoringCache();
|
refreshMonitoringCache();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const app = new Hono();
|
|||||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||||
vehicles: [],
|
vehicles: [],
|
||||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
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,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
|||||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||||
search: string; dept: string; customer: string; project: string;
|
search: string; dept: string; customer: string; project: string;
|
||||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||||
targetName: string; region: string; mileageMin: string; mileageMax: string;
|
targetName: string; mileageMin: string; mileageMax: string;
|
||||||
}): CachedVehicle[] {
|
}): CachedVehicle[] {
|
||||||
let result = vehicles;
|
let result = vehicles;
|
||||||
|
|
||||||
@@ -36,12 +36,8 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
|||||||
if (params.project) result = result.filter(v => v.project === params.project);
|
if (params.project) result = result.filter(v => v.project === params.project);
|
||||||
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
||||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||||
if (params.plate) {
|
if (params.plate) result = result.filter(v => v.plate === params.plate);
|
||||||
const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean));
|
|
||||||
if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate));
|
|
||||||
}
|
|
||||||
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
||||||
if (params.region) result = result.filter(v => v.region === params.region);
|
|
||||||
if (params.targetName) {
|
if (params.targetName) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||||
@@ -70,7 +66,6 @@ app.get('/', async (c) => {
|
|||||||
plate: c.req.query('plate') || '',
|
plate: c.req.query('plate') || '',
|
||||||
platePrefix: c.req.query('platePrefix') || '',
|
platePrefix: c.req.query('platePrefix') || '',
|
||||||
targetName: c.req.query('targetName') || '',
|
targetName: c.req.query('targetName') || '',
|
||||||
region: c.req.query('region') || '',
|
|
||||||
mileageMin: c.req.query('mileageMin') || '',
|
mileageMin: c.req.query('mileageMin') || '',
|
||||||
mileageMax: c.req.query('mileageMax') || '',
|
mileageMax: c.req.query('mileageMax') || '',
|
||||||
};
|
};
|
||||||
@@ -100,12 +95,6 @@ app.get('/', async (c) => {
|
|||||||
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
||||||
}
|
}
|
||||||
|
|
||||||
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
|
|
||||||
if (filterParams.region) {
|
|
||||||
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
|
|
||||||
filters = buildDateFilters(regionScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = applyFilters(allVehicles, filterParams);
|
const filtered = applyFilters(allVehicles, filterParams);
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
|
|||||||
@@ -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": "华南区域"
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ export interface CachedVehicle {
|
|||||||
rentStatus: string | null;
|
rentStatus: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
|
||||||
yesterdayKm: number;
|
yesterdayKm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ export interface MonitoringFilters {
|
|||||||
rentStatuses: string[];
|
rentStatuses: string[];
|
||||||
platePrefixes: PlatePrefix[];
|
platePrefixes: PlatePrefix[];
|
||||||
targetNames: string[];
|
targetNames: string[];
|
||||||
regions: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 监控缓存 */
|
/** 监控缓存 */
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -420,7 +420,7 @@ interface WeeklyStats {
|
|||||||
// 交车单 SQL
|
// 交车单 SQL
|
||||||
const DELIVERED_SQL = `SELECT
|
const DELIVERED_SQL = `SELECT
|
||||||
take.id, DATE(take.handover_date) AS handover_date,
|
take.id, DATE(take.handover_date) AS handover_date,
|
||||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
truck.id AS truck_id, truck.plate_number,
|
||||||
dic_contract_type.dic_name AS contract_type,
|
dic_contract_type.dic_name AS contract_type,
|
||||||
customer.customer_name
|
customer.customer_name
|
||||||
FROM tab_truck_rent_take take
|
FROM tab_truck_rent_take take
|
||||||
@@ -439,7 +439,7 @@ WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
|||||||
// 还车单 SQL
|
// 还车单 SQL
|
||||||
const RETURNED_SQL = `SELECT
|
const RETURNED_SQL = `SELECT
|
||||||
r.id, DATE(r.return_date) AS handover_date,
|
r.id, DATE(r.return_date) AS handover_date,
|
||||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
truck.id AS truck_id, truck.plate_number,
|
||||||
dic_contract_type.dic_name AS contract_type,
|
dic_contract_type.dic_name AS contract_type,
|
||||||
customer.customer_name
|
customer.customer_name
|
||||||
FROM tab_truck_rent_return r
|
FROM tab_truck_rent_return r
|
||||||
@@ -457,7 +457,7 @@ WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
|||||||
// 替换车单 SQL
|
// 替换车单 SQL
|
||||||
const REPLACED_SQL = `SELECT
|
const REPLACED_SQL = `SELECT
|
||||||
take.id, DATE(take.handover_date) AS handover_date,
|
take.id, DATE(take.handover_date) AS handover_date,
|
||||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
truck.id AS truck_id, truck.plate_number,
|
||||||
dic_contract_type.dic_name AS contract_type,
|
dic_contract_type.dic_name AS contract_type,
|
||||||
customer.customer_name
|
customer.customer_name
|
||||||
FROM tab_truck_rent_take take
|
FROM tab_truck_rent_take take
|
||||||
@@ -793,21 +793,11 @@ app.get('/region-stats', async (c) => {
|
|||||||
cityMap.get(city)!.push(v);
|
cityMap.get(city)!.push(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeBreakdown = (vList: Vehicle[]) => {
|
const getTypeBreakdown = (vList: Vehicle[]) =>
|
||||||
const KNOWN = ['4.5T', '18T', '49T'] as const;
|
['4.5T', '18T', '49T'].map((type) => {
|
||||||
const make = (label: string, tv: Vehicle[]) => ({
|
const tv = vList.filter((v) => v.type === type);
|
||||||
type: label,
|
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[] };
|
||||||
total: tv.length,
|
}).filter((t) => t.total > 0);
|
||||||
operating: tv.filter((v) => v.status === 'Operating').length,
|
|
||||||
inventory: tv.filter((v) => v.status === 'Inventory').length,
|
|
||||||
pending: tv.filter((v) => v.status === 'Pending').length,
|
|
||||||
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
|
|
||||||
});
|
|
||||||
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
|
|
||||||
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
|
|
||||||
if (other.length > 0) known.push(make('其他', other));
|
|
||||||
return known.filter((t) => t.total > 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||||
const result = regionOrder
|
const result = regionOrder
|
||||||
@@ -890,21 +880,6 @@ app.get('/customer-stats', async (c) => {
|
|||||||
return c.json(result);
|
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)
|
// Vehicle type filter map (same logic as /by-type)
|
||||||
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
||||||
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
|
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
|
||||||
@@ -950,7 +925,15 @@ app.get('/list', async (c) => {
|
|||||||
filtered = filtered.filter((v) => v.model === model);
|
filtered = filtered.filter((v) => v.model === model);
|
||||||
}
|
}
|
||||||
if (location && location !== 'All') {
|
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') {
|
if (status && status !== 'All') {
|
||||||
filtered = filtered.filter((v) => v.status === status);
|
filtered = filtered.filter((v) => v.status === status);
|
||||||
@@ -960,8 +943,6 @@ app.get('/list', async (c) => {
|
|||||||
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||||
} else if (category === 'Operating') {
|
} else if (category === 'Operating') {
|
||||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||||
} else if (category === 'Pending') {
|
|
||||||
filtered = filtered.filter((v) => v.status === 'Pending');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (manager) {
|
if (manager) {
|
||||||
@@ -1042,11 +1023,8 @@ app.get('/inventory-stats', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||||
// Optional filters: model, batch, location, source — 按缓存车辆集合的 truck_id 交集过滤
|
|
||||||
app.get('/weekly-detail', async (c) => {
|
app.get('/weekly-detail', async (c) => {
|
||||||
const type = c.req.query('type');
|
const type = c.req.query('type');
|
||||||
const { model, batch, location } = c.req.query();
|
|
||||||
const source = c.req.query('source');
|
|
||||||
let sql: string;
|
let sql: string;
|
||||||
if (type === 'delivered') {
|
if (type === 'delivered') {
|
||||||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||||
@@ -1055,33 +1033,17 @@ app.get('/weekly-detail', async (c) => {
|
|||||||
} else if (type === 'replaced') {
|
} else if (type === 'replaced') {
|
||||||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
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') {
|
} else if (type === 'pending') {
|
||||||
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
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`;
|
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||||
} else if (type === 'new') {
|
} else if (type === 'new') {
|
||||||
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
|
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
|
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`;
|
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
||||||
} else {
|
} else {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
const [rows] = await pool.query<any[]>(sql);
|
const [rows] = await pool.query<any[]>(sql);
|
||||||
let result = rows as any[];
|
const masked = (rows as any[]).map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
|
||||||
|
|
||||||
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
|
|
||||||
const hasModelFilter = model && model !== 'All';
|
|
||||||
const hasBatchFilter = batch && batch !== 'All';
|
|
||||||
const hasLocationFilter = location && location !== 'All';
|
|
||||||
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
|
|
||||||
const vehicles = await getVehiclesForUser(c);
|
|
||||||
let pool2 = vehicles;
|
|
||||||
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
|
|
||||||
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
|
|
||||||
if (hasLocationFilter) pool2 = filterByLocation(pool2, location, source);
|
|
||||||
const truckSet = new Set(pool2.map((v) => String(v.id)));
|
|
||||||
result = result.filter((r: any) => truckSet.has(String(r.truck_id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const masked = result.map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
|
|
||||||
return c.json(masked);
|
return c.json(masked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,8 @@ export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
|||||||
/** 智能调度模块访问角色 */
|
/** 智能调度模块访问角色 */
|
||||||
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
||||||
|
|
||||||
/** 反馈管理(管理员)访问角色 */
|
|
||||||
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
|
|
||||||
|
|
||||||
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
|
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
|
||||||
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
|
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
|
||||||
if (!roles || roles.length === 0) return false;
|
if (!roles || roles.length === 0) return false;
|
||||||
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
|
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));
|
|
||||||
}
|
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
Reference in New Issue
Block a user