Compare commits
81 Commits
a472e543ce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
433a75f9d1 | ||
|
|
0193e78f18 | ||
|
|
2a851fc243 | ||
|
|
6142af7617 | ||
|
|
26f7d7ab3f | ||
|
|
f06b0d21eb | ||
|
|
6ad4b5e2a4 | ||
|
|
ad8ec50038 | ||
|
|
dc6f541c8b | ||
|
|
034654265c | ||
|
|
5958bb581e | ||
|
|
4153f329b8 | ||
|
|
ee981639eb | ||
|
|
fe70ec389b | ||
|
|
c3b463d9ca | ||
|
|
f2acb73033 | ||
|
|
015ff9bc7e | ||
|
|
d24ce55a59 | ||
|
|
e0183986ee | ||
|
|
234b44ea03 | ||
|
|
8d861538af | ||
|
|
bdefb878a5 | ||
|
|
2aeff0c2f4 | ||
|
|
9bbd11cc86 | ||
|
|
1a3d48b2d1 | ||
|
|
c5541fbbf5 | ||
|
|
90b34b681e | ||
|
|
90b1266fe5 | ||
|
|
20ebb16e08 | ||
|
|
e8f1604c11 | ||
|
|
08f21b7e24 | ||
|
|
57207debfb | ||
|
|
d3fa2fd4d6 | ||
|
|
8b4fb6563f | ||
|
|
e187c0d02e | ||
|
|
3d4d862d73 | ||
|
|
23a7722583 | ||
|
|
213037c2ac | ||
|
|
3efa701395 | ||
|
|
e775acb8fe | ||
|
|
c788dd4577 | ||
|
|
3851335843 | ||
|
|
d0a644cf18 | ||
|
|
0d30ee2df5 | ||
|
|
9a20a7cb79 | ||
|
|
d1d79f1c7c | ||
|
|
5217e19b25 | ||
|
|
57fdd346cf | ||
|
|
d8189329ac | ||
|
|
e2d04db06d | ||
|
|
5493e27e49 | ||
|
|
355c45a2e4 | ||
|
|
66779a98e3 | ||
|
|
97ac92a0da | ||
|
|
7ca8ef24dc | ||
|
|
f9c6155ea7 | ||
|
|
cab86556f3 | ||
|
|
e0c609168e | ||
|
|
ebd82893bc | ||
|
|
3809e785c1 | ||
|
|
d1acdafa7e | ||
|
|
c3b43837fb | ||
|
|
c02c1aa62c | ||
|
|
9a4f1945d9 | ||
|
|
7de2d1ecd5 | ||
|
|
42ec6e1c01 | ||
|
|
313325553d | ||
|
|
d9b9ff495e | ||
|
|
bdd039a2c4 | ||
|
|
2a92d991b0 | ||
|
|
ccf76cba79 | ||
|
|
a40fd2be34 | ||
|
|
c8a1e8506e | ||
|
|
dc1f0326fc | ||
|
|
e6880cba17 | ||
|
|
09b9862f1f | ||
|
|
deb2f2d5da | ||
|
|
ccd97d3aae | ||
|
|
61db692980 | ||
|
|
cfe79cace2 | ||
|
|
9ea2f306c4 |
1242
docs/superpowers/plans/2026-04-28-energy-module.md
Normal file
1242
docs/superpowers/plans/2026-04-28-energy-module.md
Normal file
File diff suppressed because it is too large
Load Diff
264
docs/superpowers/specs/2026-04-28-energy-module-design.md
Normal file
264
docs/superpowers/specs/2026-04-28-energy-module-design.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 能源管理模块设计
|
||||
|
||||
在底部导航增加「能源管理」入口,集中展示加氢/充电的成本与用量数据。当前阶段**只做前端原型,数据全部走前端 mock**,后端接入留作下一阶段。
|
||||
|
||||
## 业务背景
|
||||
|
||||
参考 FineBI 既有的三张大屏:
|
||||
|
||||
| BI 链接 | 标题 | 内容 |
|
||||
|---------|------|------|
|
||||
| `link/0iqP` | 氢气管理(氢费统计) | 年/月/日加氢量与加氢费 KPI、加氢业务区域分布、Top5 加氢站、各区域占比 |
|
||||
| `link/GBSp` | 加氢站氢量每日汇总 | 每日加氢量与环比,下钻到站点级带单价 |
|
||||
| `link/TPqB` | 龙王路停车场充电站每日充电汇总 | 每日充电量(度) + 充电费用(元) |
|
||||
|
||||
这些大屏目前只在桌面浏览器里好看,移动端体验较差,且与现有应用风格割裂。新模块的目标是把核心信息按「移动优先 + 双端响应」重新组织进当前 BI App 的底部导航,让一线运营在手机上也能秒级抓取关键能耗指标。
|
||||
|
||||
## 用户故事
|
||||
|
||||
- 作为运营人员,进 App 底部 Tab 「能源管理」,第一屏立刻能看到本年/本月/今日的加氢量与加氢费。
|
||||
- 作为运营人员,能切换查看「氢能」和「电能」两类业务。
|
||||
- 作为氢能业务方,能看到每日加氢明细,并下钻到站点级别(站名 + 单价)。
|
||||
- 作为电能业务方,能看到龙王路充电站每日充电量与充电费。
|
||||
|
||||
## 范围
|
||||
|
||||
### 在范围内
|
||||
- 新模块 `src/modules/energy/`,遵循 mileage 模块的目录骨架
|
||||
- `EnergyModule` 顶级组件 + 二级 Tab(氢能 / 电能)
|
||||
- 氢能下:总览(KPI + Top5 横柱 + 区域占比环)+ 每日(明细表带下钻)
|
||||
- 电能下:mini KPI 头 + 每日明细表
|
||||
- 全前端 mock 数据,数据值从 BI 截图取真实样本,文件 `mock.ts`
|
||||
- 双端响应(mobile + web)
|
||||
- 在 `App.tsx` 的 `BASE_MODULES` 注册(登录即可见)
|
||||
|
||||
### 不在范围内
|
||||
- 后端接入、真实数据库表设计(下一期)
|
||||
- 中国地图(加氢业务区域分布)— 工作量与依赖(高德/echarts-geo)大,后续单独立项
|
||||
- iframe 嵌入 FineBI(已否决)
|
||||
- 角色权限门禁(暂不需要)
|
||||
- 数据导出 / CSV
|
||||
- 站点详情页 / 区域详情页
|
||||
|
||||
## 模块注册(App.tsx)
|
||||
|
||||
```ts
|
||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
|
||||
];
|
||||
```
|
||||
|
||||
并在 `Shell.tsx` 的 `PATH_MAP` 加 `'/energy': 'energy'`。`#energy` hash 由 Shell 已有逻辑兜底。
|
||||
|
||||
## 页面结构
|
||||
|
||||
```
|
||||
EnergyModule
|
||||
├── 顶部 sticky sub-nav: [氢能] [电能] ← motion layoutId 滑块动画
|
||||
│
|
||||
├── 氢能 view (HydrogenView)
|
||||
│ ├── 内层 sticky tab: [总览] [每日]
|
||||
│ ├── HydrogenOverview
|
||||
│ │ ├── 数据时间提示条:「数据自 2025-01-01 起,每 5 分钟更新」
|
||||
│ │ ├── KPI 网格(移动 2×2 / 桌面 1×4)
|
||||
│ │ ├── Top5 加氢站横柱图
|
||||
│ │ └── 各区域加氢占比环形图
|
||||
│ └── HydrogenDaily
|
||||
│ ├── 日期速选 6 选 1:当天 / 本周 / 本月 / 本季度 / 最近7天 / 最近30天
|
||||
│ ├── 客户类型 2 选 1:外部 / 羚牛
|
||||
│ ├── 合计行(pin 在表头下)
|
||||
│ └── 表格:日期 → 加氢量(Kg) → 环比%(日期行可展开为站点级)
|
||||
│
|
||||
└── 电能 view (ElectricView)
|
||||
├── 数据时间提示条:「龙王路停车场充电站,期初 2025-01-01,手工导入每日更新」
|
||||
├── 横向 mini KPI 头(3 列:累计 / 本月 / 今日)
|
||||
├── 客户类型 2 选 1:外部 / 羚牛
|
||||
└── 表格:月份/日期 → 充电电量(度) → 充电费用(元)(月份组可展开为日级)
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/energy/
|
||||
├── EnergyModule.tsx # 顶级容器 + 氢能/电能 切换
|
||||
├── HydrogenView.tsx # 含「总览/每日」二级 Tab
|
||||
├── HydrogenOverview.tsx # KPI + Top5 + 区域占比
|
||||
├── HydrogenDaily.tsx # 加氢量明细表
|
||||
├── ElectricView.tsx # 充电汇总表
|
||||
├── mock.ts # 所有 mock 数据
|
||||
└── types.ts # 共享类型
|
||||
```
|
||||
|
||||
参照 `src/modules/mileage/` 风格。**没有 api.ts**(后端先不接)。
|
||||
|
||||
## 数据形状(mock.ts / types.ts)
|
||||
|
||||
```ts
|
||||
// types.ts
|
||||
export type CustomerType = 'external' | 'lingniu'; // 外部 / 羚牛
|
||||
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
||||
|
||||
export interface HydrogenKpi {
|
||||
yearKg: number; // 年加氢量 Kg
|
||||
yearFee: number; // 年加氢费 元
|
||||
ourYearKg: number; // 我方年加氢量
|
||||
ourYearFee: number; // 我方年加氢费
|
||||
customerYearKg: number; // 客户产生年加氢量
|
||||
monthKg: number;
|
||||
monthFee: number;
|
||||
todayKg: number;
|
||||
todayFee: number;
|
||||
lingniuBornKg: number; // 累计羚牛承担量
|
||||
lingniuBornFee: number; // 累计羚牛承担费
|
||||
}
|
||||
|
||||
export interface HydrogenStation {
|
||||
name: string;
|
||||
kg: number;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
export interface HydrogenRegionShare {
|
||||
region: string;
|
||||
kg: number;
|
||||
share: number; // 0-1
|
||||
}
|
||||
|
||||
export interface HydrogenStationRow {
|
||||
name: string;
|
||||
pricePerKg: number; // 单价 元/Kg
|
||||
kg: number;
|
||||
chainPct: number; // 环比 -1..+1
|
||||
}
|
||||
|
||||
export interface HydrogenDailyRow {
|
||||
date: string; // 'YYYY-MM-DD'
|
||||
totalKg: number;
|
||||
chainPct: number;
|
||||
stations: HydrogenStationRow[];
|
||||
}
|
||||
|
||||
export interface ElectricKpi {
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
monthKwh: number;
|
||||
monthFee: number;
|
||||
todayKwh: number;
|
||||
todayFee: number;
|
||||
todayChainPct: number;
|
||||
}
|
||||
|
||||
export interface ElectricDailyRow {
|
||||
date: string; // 'YYYY-MM-DD'
|
||||
kwh: number;
|
||||
fee: number;
|
||||
chainPct: number; // 环比,用于趋势箭头
|
||||
}
|
||||
|
||||
export interface ElectricMonthGroup {
|
||||
month: string; // 'YYYY-MM'
|
||||
kwh: number;
|
||||
fee: number;
|
||||
rows: ElectricDailyRow[];
|
||||
}
|
||||
```
|
||||
|
||||
`mock.ts` 提供:
|
||||
- `hydrogenKpi`:取自 0iqP 的 362.43T / ¥1066.46 万 / 10.03 万 等真实样本
|
||||
- `hydrogenStationsTop5`:5 家站,名字取自 GBSp 截图
|
||||
- `hydrogenRegionShare`:约 8-12 个区域条目
|
||||
- `hydrogenDaily`:约 30 天数据,前 7 天每天 3-4 个站点;剩余天只有汇总 + 1-2 站点
|
||||
- `electricKpi`:取自 TPqB 合计 817,632.24 度 / ¥151,542.92
|
||||
- `electricMonthly`:以月为顶级 group,含每日明细,至少覆盖 2026-04 起若干天
|
||||
|
||||
数字均做轻微抖动,但保留量级与百分比,避免与 BI 大屏数字"完全一致"误导。
|
||||
|
||||
## 视觉规范
|
||||
|
||||
### 通用
|
||||
- 容器:`min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6` + `max-w-6xl mx-auto`
|
||||
- 横屏:保留 `landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden`
|
||||
- 卡片:`bg-white rounded-2xl border border-slate-100 shadow-sm`
|
||||
- sub-nav:`bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm` + sticky top 0
|
||||
- Tab active 用 `motion.div layoutId="..."` 下划线动画(沿用 mileage 模式)
|
||||
- 主色:`text-blue-600 / bg-blue-50`
|
||||
- 正向:`text-emerald-500 / bg-emerald-50`
|
||||
- 负向:`text-red-500 / bg-red-50`
|
||||
- 警示:`text-amber-500 / bg-amber-50`
|
||||
|
||||
### 氢能总览
|
||||
- KPI 卡:4 张主角卡,顺序固定
|
||||
1. 年加氢量(青蓝渐变 `from-cyan-50 to-blue-50`)+ 主数 `362.43T` + 分解两行(我方 / 客户产生)
|
||||
2. 年加氢费(蓝紫渐变 `from-blue-50 to-violet-50`)+ 主数 `¥1066.46万` + 分解(我方)
|
||||
3. 累计羚牛承担(蜜橙渐变 `from-amber-50 to-orange-50`)+ 主数 `¥10.03万` + 分解(量 / 费)
|
||||
4. 本月/今日合并卡(浅灰)+ 左半月度 + 右半今日,文字主数 `text-2xl md:text-3xl`
|
||||
- 卡左上 lucide icon:`Fuel` / `Wallet` / `Coins` / `CalendarClock`
|
||||
- KPI 主数移动端 `text-2xl font-bold`,桌面 `md:text-3xl`,分解行 `text-[11px] text-slate-500 font-bold`
|
||||
- Top5 加氢站:横向柱状(recharts BarChart `layout="vertical"`),柱子蓝→青渐变,柱左侧带数字徽章 1-5,柱右端贴 `XX,XXX Kg · XX%`,`<ResponsiveContainer width="100%" height={240}>`
|
||||
- 区域占比环形:recharts PieChart,外环切片,中心圆心放年合计 `362.43T`,下方两列图例(移动单列、桌面双列)
|
||||
|
||||
### 氢能每日表
|
||||
- 日期速选:6 个 pill 一行,可横向滚动,激活态 `bg-blue-50 text-blue-600 border-blue-200`
|
||||
- 客户类型:2 列等宽 segmented control(背景 `bg-slate-100 rounded-xl`,激活态白底+阴影)
|
||||
- 合计行:`bg-blue-50/50 text-blue-600` 粗体
|
||||
- 主行 = 日期:左侧 `▶` 折叠图标 + 日期;展开后子行内缩进 `pl-6`,子行内容 = 站名 - 单价 元/Kg
|
||||
- 环比 pill 双端共用样式:
|
||||
- 上 `↑` 绿底 `bg-emerald-50 text-emerald-600`
|
||||
- 下 `↓` 红底 `bg-red-50 text-red-600`
|
||||
- 持平 `–` 灰底 `bg-slate-100 text-slate-500`
|
||||
- 圆角 `rounded-full px-2 py-0.5 text-[11px] font-bold`
|
||||
- 移动 3 列(日期 / 量 / 环比),桌面 4 列加站价列(站点级行用网格而非缩进)
|
||||
|
||||
### 电能
|
||||
- mini KPI 头:3 卡横排(移动也保持横排,不堆叠),每卡上排 `¥金额` 主数(`text-xl md:text-2xl`),下排 `XXX 度` 副数 + 今日卡再加 pill 环比
|
||||
- 月份组:`▶` + `2026-04` + 该月合计 `度 / 元`;展开后是日级行
|
||||
- 月份组 active 时背景 `bg-blue-50/30`
|
||||
- 行的趋势图标 → 与氢能页用同一 pill 组件(保证视觉一致)
|
||||
|
||||
## 响应式行为
|
||||
|
||||
| 区域 | 移动 (<md) | 桌面 (≥md) |
|
||||
|------|-----------|-----------|
|
||||
| 顶部 sub-nav (氢/电) | sticky 满宽 | sticky 满宽,左对齐 |
|
||||
| 氢能内层 sub-tab | 紧贴 sub-nav 下 | 同 |
|
||||
| 氢能总览 KPI 网格 | `grid-cols-2` | `md:grid-cols-4` |
|
||||
| Top5 横柱 + 区域占比 | 上下叠 `grid-cols-1` | `md:grid-cols-2 gap-4` |
|
||||
| 每日氢能表 | 3 列:日期/量/环比 | 4 列:日期/站价/量/环比,站点级行同样 4 列 |
|
||||
| 电能 mini KPI 头 | 横排 3 卡(gap 紧凑) | 3 卡(gap 宽松) |
|
||||
| 电能表格 | 3 列 | 4-5 列(可加客户类型 / 趋势火花线,留作后期) |
|
||||
| 容器宽度上限 | 100% | `max-w-6xl mx-auto` |
|
||||
| recharts 图 | `<ResponsiveContainer width="100%">` | 同 |
|
||||
|
||||
## 组件复用
|
||||
|
||||
- 内层 sub-nav 抄 `MileageModule.tsx` 的 sticky tab 实现(含 motion 滑块)
|
||||
- segmented control(客户类型 / 日期速选)抄 mileage 已有的实现
|
||||
- 表格行 chevron 折叠抄 assets 模块里现有展开行的写法
|
||||
- 环比 pill 单独抽 `<TrendBadge value={number} />` 共用组件,放在 `src/modules/energy/HydrogenDaily.tsx` 顶端 export
|
||||
|
||||
## 边界与开放点
|
||||
|
||||
1. **`氢费`字面口径**:用户原始描述「每日氢费」,但 BI GBSp 实际只有加氢量+环比,单价在站点级。本期采纳 BI 的口径——表格只展示量+环比+单价(嵌入站名),费汇总放氢能总览的 KPI 卡。
|
||||
2. **数据时间**:mock 用 2026-04-28 为「今天」(`currentDate` 取自 user 自动 memory)。`今日加氢` 在 0Kg 时显示 `0` 而非 `--`,避免误判为缺失。
|
||||
3. **空态**:mock 数据已写满,UI 仍要支持 `rows.length === 0` 时显示「暂无数据」灰文 + 图标。
|
||||
4. **未来后端接入**:mock 文件命名 `mock.ts`,不放在 `api.ts`;后期添加 `api.ts` 时同名 export,UI 切到 `useEffect + fetch` 即可。
|
||||
5. **icon 选型**:`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`。
|
||||
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 底部 nav 多出「能源管理」Tab,icon Zap,登录后可见
|
||||
- [ ] 进入后默认在「氢能 → 总览」
|
||||
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
|
||||
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
|
||||
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
|
||||
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
|
||||
- [ ] 切到电能 Tab,3 张 mini KPI + 月份分组表,月份能展开日级行
|
||||
- [ ] 横屏不出现底部 nav 遮挡内容
|
||||
- [ ] `npm run lint` 通过
|
||||
- [ ] 不引入新依赖(recharts / lucide-react / motion 已有)
|
||||
1048
package-lock.json
generated
1048
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -23,10 +24,12 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tsx": "^4.21.0"
|
||||
"tsx": "^4.21.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
46
src/App.tsx
46
src/App.tsx
@@ -1,31 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Truck, Route, Activity } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import { Shell, type ModuleConfig } from './components/Shell';
|
||||
import AssetsModule from './modules/assets/AssetsModule';
|
||||
import MileageModule from './modules/mileage/MileageModule';
|
||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
import EleImportPage from './modules/ele/EleImportPage';
|
||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||
import { canAccessScheduling } from './shared/auth/roles';
|
||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
||||
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
const ENERGY_MODULE: ModuleConfig = {
|
||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
||||
};
|
||||
|
||||
function getRouteKey(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
||||
return '';
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, 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(() => {
|
||||
if (canAccessScheduling(user?.roles)) {
|
||||
return [...BASE_MODULES, SCHEDULING_MODULE];
|
||||
}
|
||||
return BASE_MODULES;
|
||||
const result = [...BASE_MODULES];
|
||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [user?.roles]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -43,6 +71,10 @@ function AuthGate() {
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
||||
|
||||
return <Shell modules={modules} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,23 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 检查 sessionStorage 中是否有 JWT
|
||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||
if (savedToken) {
|
||||
|
||||
511
src/components/FeedbackFab.tsx
Normal file
511
src/components/FeedbackFab.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
||||
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { canManageFeedback } from '../shared/auth/roles';
|
||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||
import RotatingFooterHint from './RotatingFooterHint';
|
||||
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
const MAX_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch('/api/feedback/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||
return json.url as string;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result || ''));
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
interface TypeOption {
|
||||
key: FeedbackType;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sub: string;
|
||||
iconBg: string;
|
||||
iconFg: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: TypeOption[] = [
|
||||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||
];
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
'': '通用',
|
||||
};
|
||||
|
||||
function detectModule(): string {
|
||||
const hash = (window.location.hash || '').slice(1);
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/ele/')) return 'ele';
|
||||
if (hash.includes('ele')) return 'ele';
|
||||
if (hash.startsWith('/')) return hash.split('/')[1] || '';
|
||||
return hash || '';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 显式覆盖当前模块(否则自动从 URL 检测) */
|
||||
module?: string;
|
||||
}
|
||||
|
||||
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = canManageFeedback(user?.roles);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
const [shots, setShots] = useState<UploadedImg[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||||
if (list.length === 0) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const f of list) {
|
||||
if (shots.length >= MAX_SCREENSHOTS) break;
|
||||
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
||||
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
||||
continue;
|
||||
}
|
||||
const thumbDataUrl = await readAsDataUrl(f);
|
||||
const url = await uploadImage(f);
|
||||
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
setMod(moduleProp ?? detectModule());
|
||||
}
|
||||
}, [open, step, moduleProp]);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [open]);
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
setType(null);
|
||||
setContent('');
|
||||
setShots([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!type || !content.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchJson('/api/feedback/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
module: mod || null,
|
||||
content: content.trim(),
|
||||
screenshots: shots.map(s => s.url),
|
||||
userAgent: navigator.userAgent.slice(0, 500),
|
||||
}),
|
||||
});
|
||||
setStep(3);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (step === 1) {
|
||||
if (!type) return;
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
if (step === 2) {
|
||||
if (!content.trim()) { taRef.current?.focus(); return; }
|
||||
submit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||||
|
||||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={() => setMenuOpen(m => !m)}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="反馈"
|
||||
title="提建议 / 我的反馈"
|
||||
>
|
||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||
>
|
||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||
>
|
||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="h-px bg-slate-100 my-0.5" />
|
||||
<a
|
||||
href="#/admin/feedback"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||
>
|
||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 点击外面关菜单 */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-1]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Header + progress */}
|
||||
<div className="px-4 pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||
{step === 3 ? '收到啦~' : '提个建议'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{step === 1 && '第一步 / 共 2 步'}
|
||||
{step === 2 && '第二步 / 共 2 步'}
|
||||
{step === 3 && '感谢你的反馈'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
||||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||
<RotatingFooterHint className="justify-start mb-4" />
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{TYPE_OPTIONS.map((opt, i) => {
|
||||
const Icon = opt.icon;
|
||||
const selected = type === opt.key;
|
||||
return (
|
||||
<motion.button
|
||||
key={opt.key}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||
whileHover={{ y: -1 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
onClick={() => { setType(opt.key); setStep(2); }}
|
||||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||
</div>
|
||||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imgs: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile();
|
||||
if (f) imgs.push(f);
|
||||
}
|
||||
}
|
||||
if (imgs.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(imgs);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder={
|
||||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
||||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
||||
: '随便聊聊你的想法'
|
||||
}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||
</div>
|
||||
|
||||
{/* 截图上传 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shots.map((s, i) => (
|
||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shots.length < MAX_SCREENSHOTS && (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||
>
|
||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setMod(k)}
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||
>
|
||||
<Check size={28} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step < 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={back}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一步
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={!canNext || submitting}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100">
|
||||
<button
|
||||
onClick={close}
|
||||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||
module: string | null;
|
||||
content: string;
|
||||
contact: string | null;
|
||||
screenshots: string[] | string | null;
|
||||
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||
reply_content: string | null;
|
||||
reply_user: string | null;
|
||||
reply_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
dimension: '💡 新维度',
|
||||
bug: '🐛 Bug',
|
||||
ux: '🎨 体验',
|
||||
other: '📝 其他',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: '待处理',
|
||||
in_progress: '处理中',
|
||||
done: '已完成',
|
||||
rejected: '已忽略',
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
open: 'bg-slate-100 text-slate-500',
|
||||
in_progress: 'bg-amber-100 text-amber-600',
|
||||
done: 'bg-emerald-100 text-emerald-600',
|
||||
rejected: 'bg-rose-100 text-rose-500',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s)) return s;
|
||||
try { return JSON.parse(String(s)); } catch { return []; }
|
||||
}
|
||||
|
||||
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
|
||||
const [items, setItems] = useState<FeedbackItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems(null);
|
||||
setError(null);
|
||||
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
|
||||
.then(d => setItems(d.items))
|
||||
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-lg md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||
<MailOpen size={14} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">我的反馈</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">查看进展与回复</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{error ? (
|
||||
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
|
||||
) : items === null ? (
|
||||
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
|
||||
<Loader2 size={14} className="animate-spin" /> 加载中…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-slate-300 text-[12px] font-bold">还没有提过反馈</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
return (
|
||||
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
|
||||
{STATUS_LABEL[it.status]}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 ml-auto">
|
||||
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{it.content}
|
||||
</div>
|
||||
{shots.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{shots.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
|
||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
|
||||
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
|
||||
{it.reply_user || '产品同学'} 回复
|
||||
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{it.reply_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
51
src/components/RotatingFooterHint.tsx
Normal file
51
src/components/RotatingFooterHint.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const FOOTER_HINTS = [
|
||||
'想看哪个角度的数据?告诉我们一下嘛',
|
||||
'更多统计维度接入中,欢迎您的建议 ~',
|
||||
'下一个图表,可能就是您建议的那个',
|
||||
'数据科学家正在深夜挖掘新维度…',
|
||||
'维度灵感正在路上,钉一下产品同学也行',
|
||||
'数字背后还有故事,等下一次上线揭晓',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
/** 自定义提示词集合,默认使用通用文案 */
|
||||
hints?: string[];
|
||||
/** 切换间隔,默认 4 秒 */
|
||||
intervalMs?: number;
|
||||
/** 额外类名 */
|
||||
className?: string;
|
||||
/** 点击时回调(一般用来打开反馈弹窗) */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
useEffect(() => {
|
||||
if (hints.length <= 1) return;
|
||||
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
}, [hints, intervalMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className || 'mt-1 justify-center'}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span
|
||||
key={idx}
|
||||
style={{ animation: 'rotatingHintFade 0.5s ease' }}
|
||||
>
|
||||
{hints[idx]}
|
||||
</span>
|
||||
<style>{`
|
||||
@keyframes rotatingHintFade {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
@@ -15,6 +16,7 @@ const PATH_MAP: Record<string, string> = {
|
||||
'/assets': 'assets',
|
||||
'/mileage': 'mileage',
|
||||
'/scheduling': 'scheduling',
|
||||
'/energy': 'energy',
|
||||
};
|
||||
|
||||
function getInitialModule(modules: ModuleConfig[]): string {
|
||||
@@ -105,6 +107,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
{/* 内容区 */}
|
||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
<FeedbackFab module={activeModule} />
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
|
||||
341
src/modules/admin/FeedbackAdminPage.tsx
Normal file
341
src/modules/admin/FeedbackAdminPage.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||
module: string | null;
|
||||
content: string;
|
||||
contact: string | null;
|
||||
screenshots: string[] | string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||
reply_content: string | null;
|
||||
reply_user: string | null;
|
||||
reply_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
dimension: '💡 新维度',
|
||||
bug: '🐛 Bug',
|
||||
ux: '🎨 体验',
|
||||
other: '📝 其他',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
|
||||
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
|
||||
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
|
||||
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
|
||||
];
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
};
|
||||
|
||||
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s)) return s;
|
||||
try { return JSON.parse(String(s)); } catch { return []; }
|
||||
}
|
||||
|
||||
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch(`/api/feedback/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export default function FeedbackAdminPage() {
|
||||
const [items, setItems] = useState<FeedbackItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
|
||||
const [active, setActive] = useState<FeedbackItem | null>(null);
|
||||
const [replyDraft, setReplyDraft] = useState('');
|
||||
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('limit', '200');
|
||||
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
|
||||
setItems(d.items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const open = (it: FeedbackItem) => {
|
||||
setActive(it);
|
||||
setReplyDraft(it.reply_content || '');
|
||||
setReplyStatus(it.status === 'open' ? 'done' : it.status);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!active) return;
|
||||
setSaving(true);
|
||||
setHint(null);
|
||||
try {
|
||||
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
|
||||
setHint('已保存');
|
||||
setActive(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setHint(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setHint(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
|
||||
try {
|
||||
await patchItem(it.id, { status });
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setHint(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setHint(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const counters = items.reduce<Record<string, number>>((m, it) => {
|
||||
m[it.status] = (m[it.status] || 0) + 1;
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
|
||||
<Inbox size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
|
||||
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
|
||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 状态过滤 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>全部 {items.length}</button>
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>
|
||||
{o.label} {counters[o.key] ?? 0}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||
<AlertCircle size={14} /> {error}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{hint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
|
||||
>
|
||||
<CheckCircle2 size={14} /> {hint}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="space-y-2">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
||||
) : items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
|
||||
onClick={() => open(it)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
|
||||
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
|
||||
{statusOpt?.label || it.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 ml-auto">
|
||||
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
|
||||
{(shots.length > 0 || it.contact) && (
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||
{it.contact && <span>📞 {it.contact}</span>}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
|
||||
回复: {it.reply_content}
|
||||
</div>
|
||||
)}
|
||||
{it.status === 'open' && (
|
||||
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100">标记处理中</button>
|
||||
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100">忽略</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情 / 回复弹窗 */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-xl md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">反馈详情 #{active.id}</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setActive(null)} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
|
||||
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
|
||||
{active.module && <span className="text-slate-400">板块: {MODULE_LABELS[active.module] || active.module}</span>}
|
||||
{active.contact && <span className="text-slate-400">联系: {active.contact}</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
|
||||
{parseScreenshots(active.screenshots).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{parseScreenshots(active.screenshots).map((u, i) => (
|
||||
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
|
||||
<img src={u} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">状态</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setReplyStatus(o.key)}
|
||||
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">回复内容</p>
|
||||
<textarea
|
||||
value={replyDraft}
|
||||
onChange={(e) => setReplyDraft(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{replyDraft.length} / 2000</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setActive(null)}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
|
||||
>取消</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type { WeeklyDetailItem } from './api';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
|
||||
// --- Constants ---
|
||||
@@ -223,11 +224,18 @@ export default function AssetsModule() {
|
||||
setModalLoading(true);
|
||||
const cat = showPlateNumbers.category;
|
||||
|
||||
// Weekly categories use the dedicated weekly-detail endpoint
|
||||
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced', Pending: 'pending' };
|
||||
// Weekly categories use the dedicated weekly-detail endpoint.
|
||||
// Pending 不属于 weekly:weekly-detail 不支持 model/batch/location 过滤,
|
||||
// 走下面的 /list 路径才能按型号/区域等维度过滤。
|
||||
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced' };
|
||||
if (cat && weeklyTypes[cat]) {
|
||||
setModalVehicles([]);
|
||||
fetchWeeklyDetail(weeklyTypes[cat])
|
||||
fetchWeeklyDetail(weeklyTypes[cat], {
|
||||
model: showPlateNumbers.model,
|
||||
batch: showPlateNumbers.batch,
|
||||
location: showPlateNumbers.location,
|
||||
source: showPlateNumbers.source,
|
||||
})
|
||||
.then(setModalWeeklyDetail)
|
||||
.catch(() => setModalWeeklyDetail([]))
|
||||
.finally(() => setModalLoading(false));
|
||||
@@ -241,8 +249,10 @@ export default function AssetsModule() {
|
||||
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
||||
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
||||
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
||||
if (showPlateNumbers.source) params.source = showPlateNumbers.source;
|
||||
if (cat === 'Inventory') params.category = 'Inventory';
|
||||
if (cat === 'Operating') params.category = 'Operating';
|
||||
if (cat === 'Pending') params.category = 'Pending';
|
||||
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
||||
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
||||
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
|
||||
@@ -2180,7 +2190,7 @@ export default function AssetsModule() {
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
|
||||
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
|
||||
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 库存` }); }}>{tb.inventory}</td>
|
||||
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}</td>
|
||||
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -2242,9 +2252,9 @@ export default function AssetsModule() {
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-orange-600 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 库存` })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })}
|
||||
>
|
||||
待:{tb.inventory}
|
||||
待:{tb.pending}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2829,7 +2839,7 @@ export default function AssetsModule() {
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function fetchVehicleList(params: {
|
||||
department?: string;
|
||||
attendance?: string;
|
||||
subject?: string | null;
|
||||
source?: string;
|
||||
}): Promise<VehicleListItem[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.batch) query.set('batch', params.batch);
|
||||
@@ -65,6 +66,7 @@ export async function fetchVehicleList(params: {
|
||||
if (params.department) query.set('department', params.department);
|
||||
if (params.attendance) query.set('attendance', params.attendance);
|
||||
if (params.subject) query.set('subject', params.subject);
|
||||
if (params.source) query.set('source', params.source);
|
||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -112,6 +114,14 @@ export async function fetchRegionChart(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
||||
export async function fetchWeeklyDetail(
|
||||
type: string,
|
||||
filters?: { model?: string; batch?: string; location?: string; source?: string },
|
||||
): Promise<WeeklyDetailItem[]> {
|
||||
const params = new URLSearchParams({ type });
|
||||
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
|
||||
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
|
||||
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
|
||||
if (filters?.source) params.set('source', filters.source);
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface RegionTypeBreakdown {
|
||||
total: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
pending: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
|
||||
392
src/modules/ele/EleImportPage.tsx
Normal file
392
src/modules/ele/EleImportPage.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import FeedbackFab from '../../components/FeedbackFab';
|
||||
|
||||
function getJwt(): string | null {
|
||||
return sessionStorage.getItem('bi_jwt');
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
ok: boolean;
|
||||
filename: string;
|
||||
batchId: string;
|
||||
parsed: number;
|
||||
fileDuplicates: number;
|
||||
inserted: number;
|
||||
dbDuplicates: number;
|
||||
breakdown: { internal: number; external: number };
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
station_name: string | null;
|
||||
terminal_name: string | null;
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_min: number | null;
|
||||
kwh: number | null;
|
||||
fee: number | null;
|
||||
e_fee: number | null;
|
||||
service_fee: number | null;
|
||||
plate: string | null;
|
||||
judged_plate: string | null;
|
||||
customer_name: string | null;
|
||||
vehicle_kind: 'internal' | 'external' | 'unknown';
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
}
|
||||
|
||||
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
||||
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
internal: '内部',
|
||||
external: '外部',
|
||||
};
|
||||
const KIND_STYLE: Record<string, string> = {
|
||||
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||
};
|
||||
|
||||
async function uploadFile(file: File): Promise<UploadResult> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = getJwt();
|
||||
const res = await fetch('/api/ele/import', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||
return json as UploadResult;
|
||||
}
|
||||
|
||||
export default function EleImportPage() {
|
||||
const { user } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<UploadResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<ListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: '1', limit: '50' });
|
||||
if (filter) params.set('kind', filter);
|
||||
if (batchFilter) params.set('batchId', batchFilter);
|
||||
if (search) params.set('search', search);
|
||||
const [list, agg, b] = await Promise.all([
|
||||
fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`),
|
||||
fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`),
|
||||
fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`),
|
||||
]);
|
||||
setItems(list.items);
|
||||
setTotal(list.total);
|
||||
setOverall(agg.overall);
|
||||
setBatches(b.items);
|
||||
}, [filter, batchFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
reload().catch(e => console.error(e));
|
||||
}, [reload]);
|
||||
|
||||
const handleUpload = async (f: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await uploadFile(f);
|
||||
setResult(r);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPick = (f: File | null) => {
|
||||
setFile(f);
|
||||
if (f) handleUpload(f);
|
||||
};
|
||||
|
||||
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
|
||||
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
|
||||
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
|
||||
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<Zap size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
||||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
||||
</header>
|
||||
|
||||
{/* 上传区 */}
|
||||
<section
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 上传结果提示 */}
|
||||
<AnimatePresence>
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 聚合卡 */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||
</section>
|
||||
|
||||
{/* 批次 */}
|
||||
{batches.length > 0 && (
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||
{batchFilter && (
|
||||
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">导入时间</th>
|
||||
<th className="px-3 py-2 text-right">总数</th>
|
||||
<th className="px-3 py-2 text-right">内部</th>
|
||||
<th className="px-3 py-2 text-right">外部</th>
|
||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right">批次</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map(b => (
|
||||
<tr
|
||||
key={b.batch_id}
|
||||
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
|
||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 列表 */}
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||
<div className="flex-1" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
placeholder="搜索订单/车牌/电站"
|
||||
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||
/>
|
||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k as typeof filter)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(it => (
|
||||
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
||||
{KIND_LABEL[it.vehicle_kind]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||
尚无记录,先上传一份 xlsx 试试
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
<FeedbackFab module="ele" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||
const accentMap: Record<string, string> = {
|
||||
slate: 'text-slate-700',
|
||||
blue: 'text-blue-600',
|
||||
amber: 'text-amber-600',
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||
<span className={accentMap[accent]}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/modules/energy/ETCView.tsx
Normal file
79
src/modules/energy/ETCView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Construction, Hammer } from 'lucide-react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
|
||||
export default function ETCView() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<motion.div
|
||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
||||
>
|
||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
||||
>
|
||||
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
||||
通行费明细、按车按月统计、运营成本拆分
|
||||
<br />
|
||||
这些数据都在路上啦
|
||||
</div>
|
||||
|
||||
{/* 简单的里程碑进度感 */}
|
||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
||||
{[
|
||||
{ label: '需求评审', done: true },
|
||||
{ label: '数据对接', done: true },
|
||||
{ label: '页面开发', done: false, current: true },
|
||||
{ label: '正式上线', done: false },
|
||||
].map((m, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
||||
className="flex items-center gap-2.5 text-[11px]"
|
||||
>
|
||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
m.done ? 'bg-emerald-400'
|
||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
||||
: 'bg-slate-200'
|
||||
}`} />
|
||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/modules/energy/ElectricDaily.tsx
Normal file
171
src/modules/energy/ElectricDaily.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Plug } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchElectricMonthly(customer, pick)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
setMonths(m);
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(m) ? next.delete(m) : next.add(m);
|
||||
return next;
|
||||
});
|
||||
|
||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 客户类型 */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 外部车辆 数据未就绪 */}
|
||||
{showExternalEmpty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||
新系统的外部车辆充电数据还在准备中
|
||||
<br />
|
||||
上线后此处将展示完整明细
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 月份分组表 */}
|
||||
{!showExternalEmpty && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>月份 / 日期</span>
|
||||
<span className="text-right">充电量 (度)</span>
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||||
) : months === null ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||||
) : months.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||||
) : months.map(m => {
|
||||
const open = openMonths.has(m.month);
|
||||
return (
|
||||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||||
<button
|
||||
onClick={() => toggleMonth(m.month)}
|
||||
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
|
||||
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
{m.month}
|
||||
</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span />
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{m.rows.map(d => {
|
||||
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
|
||||
const abnormalBg = isAbnormal
|
||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: 'bg-slate-50/50';
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
||||
>
|
||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/modules/energy/ElectricOverview.tsx
Normal file
105
src/modules/energy/ElectricOverview.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
function fmtYuan(yuan: number) {
|
||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
function fmtKwh(kwh: number) {
|
||||
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
|
||||
}
|
||||
|
||||
export default function ElectricOverview() {
|
||||
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchElectricOverview()
|
||||
.then(d => { if (!cancelled) setData(d); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const trendData = data.trend;
|
||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${trendMonthLabel} 每日充电`
|
||||
: '本月每日充电';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||
<Wallet size={11} className="text-blue-600" />累计
|
||||
</div>
|
||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
|
||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||
<CalendarClock size={11} className="text-blue-600" />本月
|
||||
</div>
|
||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本月每日充电柱图 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={8}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/modules/energy/ElectricView.tsx
Normal file
12
src/modules/energy/ElectricView.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import ElectricOverview from './ElectricOverview';
|
||||
import ElectricDaily from './ElectricDaily';
|
||||
|
||||
export type ElectricSubTab = 'daily' | 'overview';
|
||||
|
||||
interface Props {
|
||||
sub: ElectricSubTab;
|
||||
}
|
||||
|
||||
export default function ElectricView({ sub }: Props) {
|
||||
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />;
|
||||
}
|
||||
86
src/modules/energy/EnergyModule.tsx
Normal file
86
src/modules/energy/EnergyModule.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||
import ETCView from './ETCView';
|
||||
|
||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
||||
|
||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
||||
];
|
||||
|
||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
export default function EnergyModule() {
|
||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||
|
||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
||||
{TABS.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const active = activeTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
||||
{active && (
|
||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
||||
{showSubTabs && (
|
||||
<div className="p-1 flex gap-1">
|
||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
||||
const active = currentSub === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSub(id)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
||||
{activeTab === 'etc' && <ETCView />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/modules/energy/HydrogenDaily.tsx
Normal file
228
src/modules/energy/HydrogenDaily.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Plug } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchHydrogenDaily(pick, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||
|
||||
const toggle = (date: string) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(date) ? next.delete(date) : next.add(date);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 客户类型 segmented */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||
新系统的外部车辆加氢数据还在准备中
|
||||
<br />
|
||||
上线后此处将展示完整明细
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={8}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>日期 / 加氢站</span>
|
||||
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
||||
<span className="text-right">加氢量 (Kg)</span>
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{/* 合计行 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
||||
<span>合计</span>
|
||||
<span className="hidden md:block" />
|
||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||
<span />
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{error ? (
|
||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||||
) : rows === null ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||||
) : rows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
const abnormalBg = isAbnormal
|
||||
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: '';
|
||||
return (
|
||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
||||
<button
|
||||
onClick={() => toggle(r.date)}
|
||||
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
{r.date}
|
||||
</span>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden bg-slate-50/50"
|
||||
>
|
||||
{r.stations.map(s => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||
{s.name}
|
||||
</div>
|
||||
{s.pricePerKg > 0 && (
|
||||
<div className="md:hidden mt-1">
|
||||
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
|
||||
单价 {s.pricePerKg} 元/Kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
641
src/modules/energy/HydrogenOverview.tsx
Normal file
641
src/modules/energy/HydrogenOverview.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} from 'recharts';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 数字格式化 ----------
|
||||
function fmtKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||||
const abs = Math.abs(yuan);
|
||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
if (abs >= 10_000) {
|
||||
const w = yuan / 10_000;
|
||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
||||
}
|
||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
||||
}
|
||||
|
||||
// ---------- KPI 卡 ----------
|
||||
interface KpiCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export default function HydrogenOverview() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<number | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
||||
const refreshSeq = useRef(0);
|
||||
|
||||
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
|
||||
const seq = ++refreshSeq.current;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
||||
if (seq !== refreshSeq.current) return; // outdated
|
||||
setData(d);
|
||||
setError(null);
|
||||
setLastRefreshAt(Date.now());
|
||||
} catch (e) {
|
||||
if (seq !== refreshSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (seq === refreshSeq.current) setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载 + 年份切换:用 force=false 命中热缓存
|
||||
useEffect(() => { void load(year, false); }, [year, load]);
|
||||
|
||||
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||||
return () => clearInterval(t);
|
||||
}, [year, load]);
|
||||
|
||||
if (error && !data) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <HydrogenOverviewSkeleton />;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const top5 = data.top5;
|
||||
const regions = data.regions;
|
||||
const monthly = data.monthly;
|
||||
const customers = data.customers;
|
||||
const stations = data.stations;
|
||||
const availableYears = data.availableYears;
|
||||
const activeYear = data.year;
|
||||
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||
const monthKgFmt = fmtKg(k.monthKg);
|
||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||
const todayKgFmt = fmtKg(k.todayKg);
|
||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
// 月度收支组合数据(推算"年内每月"图)
|
||||
const monthlyDual = monthly.map(m => ({
|
||||
...m,
|
||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load(year, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 5 卡 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-cyan-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢量"
|
||||
hero={yearKgFmt}
|
||||
rows={[
|
||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-blue-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢费"
|
||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||
rows={[
|
||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-emerald-50"
|
||||
accentClass={profitColor}
|
||||
label="时享加氢获利"
|
||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||
rows={[
|
||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-amber-50"
|
||||
accentClass="text-amber-600"
|
||||
label="本月加氢"
|
||||
hero={monthKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-violet-50"
|
||||
accentClass="text-violet-600"
|
||||
label="本日加氢"
|
||||
hero={todayKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 月度趋势:年内每月加氢量 */}
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||
{monthlyDual.map((_, i) => (
|
||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 月度收支对比 */}
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top5 + 区域占比 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Top5 加氢站 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 区域占比 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加氢站加氢汇总(全量) */}
|
||||
{stations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">加氢站</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 客户账单汇总 Top */}
|
||||
{customers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">客户</th>
|
||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c2, i) => {
|
||||
const kgFmt = fmtKg(c2.kg);
|
||||
const costFmt = fmtYuan(c2.cost);
|
||||
const revFmt = fmtYuan(c2.revenue);
|
||||
return (
|
||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RotatingFooterHint />
|
||||
|
||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||||
<AnimatePresence>
|
||||
{refreshing && data && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (s < 5) return '刚刚';
|
||||
if (s < 60) return `${s} 秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
{/* 5 卡占位 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 月度柱图占位 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-[120px]">
|
||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
正在加载氢能总览…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/modules/energy/HydrogenView.tsx
Normal file
12
src/modules/energy/HydrogenView.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import HydrogenOverview from './HydrogenOverview';
|
||||
import HydrogenDaily from './HydrogenDaily';
|
||||
|
||||
export type HydrogenSubTab = 'daily' | 'overview';
|
||||
|
||||
interface Props {
|
||||
sub: HydrogenSubTab;
|
||||
}
|
||||
|
||||
export default function HydrogenView({ sub }: Props) {
|
||||
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
|
||||
}
|
||||
24
src/modules/energy/TrendBadge.tsx
Normal file
24
src/modules/energy/TrendBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
|
||||
|
||||
export interface TrendBadgeProps {
|
||||
value: number; // -1..+1, 0 表示持平
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TrendBadge({ value, className = '' }: TrendBadgeProps) {
|
||||
const isUp = value > 0.0001;
|
||||
const isDown = value < -0.0001;
|
||||
const cls = isUp
|
||||
? 'bg-emerald-50 text-emerald-600'
|
||||
: isDown
|
||||
? 'bg-red-50 text-red-600'
|
||||
: 'bg-slate-100 text-slate-500';
|
||||
const Icon = isUp ? ArrowUp : isDown ? ArrowDown : Minus;
|
||||
const sign = isUp ? '+' : '';
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-bold ${cls} ${className}`}>
|
||||
<Icon size={11} />
|
||||
{sign}{(value * 100).toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
47
src/modules/energy/api.ts
Normal file
47
src/modules/energy/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
CustomerType, DateQuickPick,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
|
||||
export interface HydrogenOverviewResponse {
|
||||
kpi: HydrogenKpi;
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
customers: HydrogenCustomerRow[];
|
||||
stations: HydrogenStationFull[];
|
||||
availableYears: number[];
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.set('year', String(year));
|
||||
if (force) params.set('force', '1');
|
||||
const q = params.toString();
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ range, customer });
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
export interface ElectricOverviewResponse {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
}
|
||||
|
||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer, range });
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
99
src/modules/energy/types.ts
Normal file
99
src/modules/energy/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type CustomerType = 'external' | 'lingniu';
|
||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
|
||||
export interface HydrogenKpi {
|
||||
yearKg: number;
|
||||
yearFee: number;
|
||||
yearRevenue: number;
|
||||
yearProfit: number;
|
||||
ourYearKg: number;
|
||||
ourYearFee: number;
|
||||
customerYearKg: number;
|
||||
monthKg: number;
|
||||
monthFee: number;
|
||||
monthRevenue: number;
|
||||
monthProfit: number;
|
||||
todayKg: number;
|
||||
todayFee: number;
|
||||
todayRevenue: number;
|
||||
todayProfit: number;
|
||||
lingniuBornKg: number;
|
||||
lingniuBornFee: number;
|
||||
}
|
||||
|
||||
export interface HydrogenStationTop {
|
||||
rank: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
fee: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface HydrogenRegionShare {
|
||||
region: string;
|
||||
kg: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface HydrogenMonthlyPoint {
|
||||
month: string; // YYYY-MM
|
||||
kg: number;
|
||||
fee: number;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
export interface HydrogenCustomerRow {
|
||||
name: string;
|
||||
payer: 'lingniu' | 'customer';
|
||||
kg: number;
|
||||
cost: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface HydrogenStationFull {
|
||||
name: string;
|
||||
kg: number;
|
||||
revenue: number;
|
||||
share: number; // 加氢量占比
|
||||
revenueShare: number;// 收入占比
|
||||
}
|
||||
|
||||
export interface HydrogenStationRow {
|
||||
name: string;
|
||||
pricePerKg: number;
|
||||
kg: number;
|
||||
chainPct: number;
|
||||
}
|
||||
|
||||
export interface HydrogenDailyRow {
|
||||
date: string;
|
||||
totalKg: number;
|
||||
chainPct: number;
|
||||
customerType: CustomerType;
|
||||
stations: HydrogenStationRow[];
|
||||
}
|
||||
|
||||
export interface ElectricKpi {
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
monthKwh: number;
|
||||
monthFee: number;
|
||||
todayKwh: number;
|
||||
todayFee: number;
|
||||
todayChainPct: number;
|
||||
}
|
||||
|
||||
export interface ElectricDailyRow {
|
||||
date: string;
|
||||
kwh: number;
|
||||
fee: number;
|
||||
chainPct: number;
|
||||
}
|
||||
|
||||
export interface ElectricMonthGroup {
|
||||
month: string;
|
||||
kwh: number;
|
||||
fee: number;
|
||||
rows: ElectricDailyRow[];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { motion } from 'motion/react';
|
||||
import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
export default function MileageModule() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||
@@ -52,6 +53,7 @@ export default function MileageModule() {
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Truck, Search, Filter, ChevronDown,
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download,
|
||||
} from 'lucide-react';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
import PlateMultiSelect from './PlateMultiSelect';
|
||||
import { exportMileageXlsx } from './xlsx-export';
|
||||
import VehicleDetailModal from './VehicleDetailModal';
|
||||
|
||||
const SearchableSelect = ({
|
||||
options,
|
||||
@@ -102,15 +105,18 @@ export default function MonitoringView() {
|
||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||
|
||||
// New filters from image
|
||||
const [filterPlate, setFilterPlate] = useState('All');
|
||||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||
const [filterProject, setFilterProject] = useState('All');
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||
const [filterRegion, setFilterRegion] = useState('All');
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||
const [filterDate, setFilterDate] = useState(() => {
|
||||
const now = new Date();
|
||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||
@@ -119,7 +125,7 @@ export default function MonitoringView() {
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -147,7 +153,8 @@ export default function MonitoringView() {
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
@@ -159,7 +166,7 @@ export default function MonitoringView() {
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -179,7 +186,8 @@ export default function MonitoringView() {
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
@@ -188,13 +196,53 @@ export default function MonitoringView() {
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
useEffect(() => {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||||
useEffect(() => {
|
||||
if (filterPlates.length === 0) return;
|
||||
const valid = new Set(filterOptions.plates);
|
||||
const next = filterPlates.filter(p => valid.has(p));
|
||||
if (next.length !== filterPlates.length) setFilterPlates(next);
|
||||
}, [filterOptions.plates, filterPlates]);
|
||||
|
||||
// 下载当前筛选结果为 xlsx
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const d = await fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: 9999,
|
||||
page: 1,
|
||||
search: searchTerm || undefined,
|
||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
project: filterProject !== 'All' ? filterProject : undefined,
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
});
|
||||
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
|
||||
} catch (err) {
|
||||
console.error('export failed', err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||
@@ -260,14 +308,15 @@ export default function MonitoringView() {
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
date: filterDate || undefined,
|
||||
}).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]);
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
@@ -391,14 +440,9 @@ export default function MonitoringView() {
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>车牌号</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterPlate}
|
||||
onChange={(e) => setFilterPlate(e.target.value)}
|
||||
>
|
||||
<option value="All">全部车牌</option>
|
||||
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<span className="text-[9px] text-slate-500 font-normal">
|
||||
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
@@ -526,6 +570,14 @@ export default function MonitoringView() {
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={exporting}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
|
||||
title="下载当前筛选结果"
|
||||
>
|
||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
@@ -559,32 +611,32 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
|
||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<SearchableSelect
|
||||
options={['__EMPTY__', ...departments]}
|
||||
value={filterDept}
|
||||
onChange={setFilterDept}
|
||||
placeholder="按部门"
|
||||
options={filterOptions.targetNames}
|
||||
value={filterTargetName}
|
||||
onChange={setFilterTargetName}
|
||||
placeholder="批次型号"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={['__EMPTY__', ...filterOptions.customers]}
|
||||
value={filterCustomer}
|
||||
onChange={setFilterCustomer}
|
||||
placeholder="按客户"
|
||||
options={filterOptions.regions}
|
||||
value={filterRegion}
|
||||
onChange={setFilterRegion}
|
||||
placeholder="运营区域"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={plateNumbers}
|
||||
value={filterPlate}
|
||||
onChange={setFilterPlate}
|
||||
<PlateMultiSelect
|
||||
allPlates={plateNumbers}
|
||||
selected={filterPlates}
|
||||
onChange={setFilterPlates}
|
||||
placeholder="按车牌"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
@@ -612,6 +664,37 @@ export default function MonitoringView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Department */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按部门</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterDept}
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Customer */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按客户</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterCustomer}
|
||||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Project */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">项目</label>
|
||||
@@ -625,21 +708,6 @@ export default function MonitoringView() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Department */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">业务部门</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterDept}
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rent Status */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">租赁状态</label>
|
||||
@@ -669,19 +737,6 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">型号批次</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterTargetName}
|
||||
onChange={(e) => setFilterTargetName(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.targetNames.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Plate Prefix */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">车牌区域</label>
|
||||
@@ -724,11 +779,13 @@ export default function MonitoringView() {
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setFilterDept('All');
|
||||
setFilterPlate('All');
|
||||
setFilterPlates([]);
|
||||
setFilterCustomer('All');
|
||||
setFilterProject('All');
|
||||
setFilterEntity('All');
|
||||
setFilterPlatePrefix('All');
|
||||
setFilterTargetName('All');
|
||||
setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
}}
|
||||
@@ -754,22 +811,23 @@ export default function MonitoringView() {
|
||||
{/* Active Filter Tags */}
|
||||
{(() => {
|
||||
const tags: { label: string; onClear: () => void }[] = [];
|
||||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||||
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
|
||||
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
|
||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
||||
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
|
||||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
|
||||
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
|
||||
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
|
||||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||||
if (filterPlatePrefix !== 'All') tags.push({ label: `区域: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
|
||||
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
|
||||
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||||
if (tags.length === 0) return null;
|
||||
const clearAll = () => {
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
|
||||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterDate('');
|
||||
};
|
||||
@@ -843,10 +901,8 @@ export default function MonitoringView() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key={v.plate}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(v.plate);
|
||||
}}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
|
||||
onClick={() => setDetailVehicle(v)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="relative flex-shrink-0">
|
||||
@@ -910,6 +966,8 @@ export default function MonitoringView() {
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<AnimatePresence>
|
||||
{showBackToTop && (
|
||||
|
||||
197
src/modules/mileage/PlateMultiSelect.tsx
Normal file
197
src/modules/mileage/PlateMultiSelect.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { ChevronDown, X, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
allPlates: string[];
|
||||
selected: string[];
|
||||
onChange: (plates: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function parseInput(text: string): string[] {
|
||||
return text
|
||||
.split(/[\s,;,;、]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function PlateMultiSelect({ allPlates, selected, onChange, placeholder = '按车牌(可多选/粘贴)' }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [unmatched, setUnmatched] = useState<string[]>([]);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allSet = useMemo(() => new Set(allPlates), [allPlates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return allPlates.slice(0, 200);
|
||||
const q = search.toLowerCase();
|
||||
return allPlates.filter(p => p.toLowerCase().includes(q)).slice(0, 200);
|
||||
}, [allPlates, search]);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
const apply = (input: string) => {
|
||||
const tokens = parseInput(input);
|
||||
if (tokens.length === 0) return;
|
||||
const matched: string[] = [];
|
||||
const missed: string[] = [];
|
||||
const seen = new Set(selected);
|
||||
for (const t of tokens) {
|
||||
if (allSet.has(t)) {
|
||||
if (!seen.has(t)) {
|
||||
matched.push(t);
|
||||
seen.add(t);
|
||||
}
|
||||
} else {
|
||||
missed.push(t);
|
||||
}
|
||||
}
|
||||
if (matched.length > 0) onChange([...selected, ...matched]);
|
||||
setUnmatched(missed);
|
||||
setText('');
|
||||
};
|
||||
|
||||
const togglePlate = (plate: string) => {
|
||||
if (selectedSet.has(plate)) {
|
||||
onChange(selected.filter(p => p !== plate));
|
||||
} else {
|
||||
onChange([...selected, plate]);
|
||||
}
|
||||
};
|
||||
|
||||
const removePlate = (plate: string) => {
|
||||
onChange(selected.filter(p => p !== plate));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onChange([]);
|
||||
setUnmatched([]);
|
||||
setText('');
|
||||
};
|
||||
|
||||
const display = selected.length === 0
|
||||
? placeholder
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `${selected[0]} 等 ${selected.length} 个车牌`;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<div
|
||||
onClick={() => setIsOpen(o => !o)}
|
||||
className={`w-full bg-slate-50 rounded-lg py-1.5 px-2 text-[10px] font-bold cursor-pointer flex items-center justify-between gap-1 ${selected.length > 0 ? 'text-blue-600 ring-1 ring-blue-200' : 'text-slate-600'}`}
|
||||
>
|
||||
<span className="truncate">{display}</span>
|
||||
<ChevronDown size={10} className="text-slate-400 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute z-50 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl"
|
||||
style={{ width: 'min(280px, calc(100vw - 24px))', minWidth: '100%' }}
|
||||
>
|
||||
<div className="p-2 space-y-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="粘贴或输入车牌 支持换行/逗号/空格分隔,回车或点添加确认"
|
||||
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
apply(text);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-slate-400">已选 <span className="font-bold text-blue-600">{selected.length}</span> 个</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => apply(text)}
|
||||
disabled={!text.trim()}
|
||||
className="px-2 py-1 bg-blue-600 text-white rounded-md text-[10px] font-bold disabled:bg-slate-200 disabled:text-slate-400"
|
||||
>添加</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="px-2 py-1 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold"
|
||||
>清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unmatched.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 space-y-1">
|
||||
<div className="flex items-center gap-1 text-amber-700">
|
||||
<AlertTriangle size={10} />
|
||||
<span className="text-[10px] font-bold">{unmatched.length} 个车牌未匹配</span>
|
||||
<button
|
||||
onClick={() => setUnmatched([])}
|
||||
className="ml-auto text-amber-500 hover:text-amber-700"
|
||||
><X size={10} /></button>
|
||||
</div>
|
||||
<div className="text-[10px] text-amber-600 break-all max-h-16 overflow-y-auto leading-relaxed">
|
||||
{unmatched.join(',')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto p-1 bg-slate-50 rounded-lg">
|
||||
{selected.map(p => (
|
||||
<span key={p} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white border border-blue-100 text-blue-600 rounded text-[10px] font-bold">
|
||||
{p}
|
||||
<button onClick={() => removePlate(p)} className="text-blue-400 hover:text-blue-700"><X size={9} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-100 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索车牌"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
/>
|
||||
<div className="mt-1 max-h-40 overflow-y-auto">
|
||||
{filtered.map(p => (
|
||||
<div
|
||||
key={p}
|
||||
onClick={() => togglePlate(p)}
|
||||
className={`px-2 py-1 text-[10px] font-bold cursor-pointer flex items-center gap-1.5 rounded ${selectedSet.has(p) ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<span className={`w-3 h-3 rounded border ${selectedSet.has(p) ? 'bg-blue-600 border-blue-600' : 'border-slate-300'} flex items-center justify-center`}>
|
||||
{selectedSet.has(p) && <span className="text-white text-[8px] leading-none">✓</span>}
|
||||
</span>
|
||||
{p}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-1 text-[10px] text-slate-300 italic">无匹配项</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
src/modules/mileage/VehicleDetailModal.tsx
Normal file
342
src/modules/mileage/VehicleDetailModal.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||
import { X, Truck } from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||
} from 'recharts';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
vehicle: MonitoringVehicle | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||
|
||||
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
||||
{ key: 'last15', label: '近 15 天' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'quarter', label: '本季度' },
|
||||
];
|
||||
|
||||
function fmtYmd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const end = fmtYmd(today);
|
||||
if (key === 'last15') {
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - 14);
|
||||
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
|
||||
}
|
||||
if (key === 'month') {
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { start: fmtYmd(start), end, rangeLabel: '本月' };
|
||||
}
|
||||
const q = Math.floor(today.getMonth() / 3);
|
||||
const start = new Date(today.getFullYear(), q * 3, 1);
|
||||
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
||||
}
|
||||
|
||||
function isToday(date: string): boolean {
|
||||
return date === fmtYmd(new Date());
|
||||
}
|
||||
|
||||
function formatLabel(date: string, key: RangeKey): string {
|
||||
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||
void key;
|
||||
return date.slice(5);
|
||||
}
|
||||
|
||||
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [range, setRange] = useState<RangeKey>('last15');
|
||||
const dragControls = useDragControls();
|
||||
|
||||
// 切换车辆时重置区间为默认
|
||||
useEffect(() => {
|
||||
if (vehicle) setRange('last15');
|
||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 拉取数据(车辆或区间变化)
|
||||
useEffect(() => {
|
||||
if (!vehicle) return;
|
||||
const { start, end } = rangeFor(range);
|
||||
setLoading(true);
|
||||
setDays([]);
|
||||
let cancelled = false;
|
||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
||||
.then(d => { if (!cancelled) setDays(d.days); })
|
||||
.catch(() => { if (!cancelled) setDays([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 锁滚动
|
||||
useEffect(() => {
|
||||
if (!vehicle) return;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [vehicle]);
|
||||
|
||||
// 排除"今日"列(数据未到位时易引起误读)
|
||||
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
||||
const stats = useMemo(() => {
|
||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||
const avg = synced > 0 ? totalKm / synced : 0;
|
||||
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
|
||||
return { totalKm, synced, avg, max, totalDays: historyDays.length };
|
||||
}, [historyDays]);
|
||||
|
||||
// 骨架天数:根据区间预估
|
||||
const skeletonCount = useMemo(() => {
|
||||
if (range === 'last15') return 15;
|
||||
const { start, end } = rangeFor(range);
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
||||
}, [range]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{vehicle && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{
|
||||
y: { type: 'spring', damping: 32, stiffness: 320 },
|
||||
opacity: { duration: 0.18 },
|
||||
}}
|
||||
drag="y"
|
||||
dragControls={dragControls}
|
||||
dragListener={false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 600) onClose();
|
||||
}}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
||||
<div
|
||||
className="flex justify-center pt-2.5 pb-1.5 cursor-grab active:cursor-grabbing select-none"
|
||||
onPointerDown={(e) => dragControls.start(e)}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pb-2 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
|
||||
<Truck size={16} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
{vehicle.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 truncate">
|
||||
{vehicle.rentStatus || ''}
|
||||
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
|
||||
{vehicle.customer ? ` · ` : ''}
|
||||
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 时间范围切换 */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
||||
{RANGE_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setRange(tab.key)}
|
||||
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
{range === tab.key && (
|
||||
<motion.div
|
||||
layoutId="rangeTabBg"
|
||||
className="absolute inset-0 bg-white shadow-sm rounded-md"
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar chart */}
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold text-slate-500">行驶里程</span>
|
||||
<span className="text-[9px] font-bold text-slate-300">单位 km</span>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-50">
|
||||
<div className="h-[140px]">
|
||||
{loading ? (
|
||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(d) => formatLabel(d, range)}
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={6}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
|
||||
{historyDays.map((d, i) => (
|
||||
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日明细 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||
{loading ? (
|
||||
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
||||
) : (
|
||||
<motion.div
|
||||
key={range}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-1"
|
||||
>
|
||||
{historyDays.slice().reverse().map((d, i) => (
|
||||
<motion.div
|
||||
key={d.date}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
|
||||
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
|
||||
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
||||
{d.isDataSynced
|
||||
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
||||
: <span className="text-[9px]">未对接</span>}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonBars({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-full px-2 pb-2 pt-2">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-slate-100 rounded-t animate-pulse"
|
||||
style={{
|
||||
height: `${30 + Math.sin(i * 0.7) * 25 + Math.cos(i * 0.4) * 15 + 30}%`,
|
||||
animationDelay: `${i * 40}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonList({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-1.5 px-2 animate-pulse" style={{ animationDelay: `${i * 30}ms` }}>
|
||||
<div className="h-3 w-20 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export async function fetchMonitoring(params?: {
|
||||
rentStatus?: string;
|
||||
platePrefix?: string;
|
||||
targetName?: string;
|
||||
region?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
@@ -34,6 +35,7 @@ export async function fetchMonitoring(params?: {
|
||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||
if (params?.targetName) query.set('targetName', params.targetName);
|
||||
if (params?.region) query.set('region', params.region);
|
||||
if (params?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
@@ -59,3 +61,29 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
|
||||
params.set('days', String(days));
|
||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
||||
}
|
||||
|
||||
export interface VehicleRecentDay {
|
||||
date: string;
|
||||
dailyKm: number;
|
||||
isDataSynced: boolean;
|
||||
}
|
||||
|
||||
export interface VehicleRecentResponse {
|
||||
plate: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
days: VehicleRecentDay[];
|
||||
}
|
||||
|
||||
export async function fetchVehicleRecent(
|
||||
plate: string,
|
||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
||||
): Promise<VehicleRecentResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (range.start) params.set('start', range.start);
|
||||
if (range.end) params.set('end', range.end);
|
||||
if (range.days != null) params.set('days', String(range.days));
|
||||
return fetchJson<VehicleRecentResponse>(
|
||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface MonitoringVehicle {
|
||||
rentStatus: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringStats {
|
||||
@@ -30,6 +31,7 @@ export interface MonitoringFilters {
|
||||
rentStatuses: string[];
|
||||
platePrefixes: { prefix: string; count: number }[];
|
||||
targetNames: string[];
|
||||
regions: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
|
||||
81
src/modules/mileage/xlsx-export.ts
Normal file
81
src/modules/mileage/xlsx-export.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
interface ExportContext {
|
||||
date: string;
|
||||
sortBy: 'today' | 'total';
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'运营区域', '今日里程(km)', '累计里程(km)',
|
||||
] as const;
|
||||
|
||||
function statusLabel(v: MonitoringVehicle): string {
|
||||
if (!v.isDataSynced) return '未对接';
|
||||
return v.isOnline ? '在线' : '离线';
|
||||
}
|
||||
|
||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||
if (!v.isDataSynced) return '未对接';
|
||||
if (kind === 'today') return Math.max(0, Math.round(v.dailyKm || 0));
|
||||
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
||||
}
|
||||
|
||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||
const data: (string | number)[][] = [
|
||||
[...HEADERS],
|
||||
...vehicles.map(v => [
|
||||
statusLabel(v),
|
||||
v.plate,
|
||||
v.customer || '',
|
||||
v.department || '',
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
mileageCell(v, 'today'),
|
||||
mileageCell(v, 'total'),
|
||||
]),
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||
|
||||
ws['!cols'] = [
|
||||
{ wch: 8 }, // 状态
|
||||
{ wch: 12 }, // 车牌号
|
||||
{ wch: 28 }, // 客户
|
||||
{ wch: 14 }, // 业务部门
|
||||
{ wch: 16 }, // 项目
|
||||
{ wch: 10 }, // 租赁状态
|
||||
{ wch: 12 }, // 运营区域
|
||||
{ wch: 14 }, // 今日里程
|
||||
{ wch: 14 }, // 累计里程
|
||||
];
|
||||
|
||||
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
||||
|
||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
||||
for (let c = 0; c < HEADERS.length; c++) {
|
||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||
if (ws[ref]) {
|
||||
(ws[ref] as { s?: unknown }).s = {
|
||||
font: { bold: true, color: { rgb: 'FFFFFF' } },
|
||||
fill: { fgColor: { rgb: '2563EB' } },
|
||||
alignment: { horizontal: 'center', vertical: 'center' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
|
||||
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
|
||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import SuggestionDetail from './SuggestionDetail';
|
||||
import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
@@ -632,6 +633,7 @@ export default function SchedulingModule() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
|
||||
if (process.env.DEV_BYPASS_AUTH === '1') {
|
||||
const devUser: AuthUser = {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
loginName: 'dev-local',
|
||||
depCode: '',
|
||||
depName: '',
|
||||
permissionLevel: 'full',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
};
|
||||
c.set('user', devUser);
|
||||
return next();
|
||||
}
|
||||
|
||||
// 跳过不需要认证的路径
|
||||
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
||||
return next();
|
||||
|
||||
@@ -28,5 +28,9 @@ export {
|
||||
FULL_ACCESS_ROLES,
|
||||
DEPT_ACCESS_ROLES,
|
||||
SCHEDULING_ACCESS_ROLES,
|
||||
FEEDBACK_ADMIN_ROLES,
|
||||
ENERGY_ACCESS_ROLES,
|
||||
canAccessScheduling,
|
||||
canManageFeedback,
|
||||
canAccessEnergy,
|
||||
} from '../../shared/auth/roles.js';
|
||||
|
||||
@@ -6,6 +6,9 @@ import dotenv from 'dotenv';
|
||||
import vehiclesRouter from './routes/vehicles.js';
|
||||
import mileageRouter from './routes/mileage/index.js';
|
||||
import schedulingRouter from './routes/scheduling/index.js';
|
||||
import energyRouter from './routes/energy/index.js';
|
||||
import eleRouter from './routes/ele/index.js';
|
||||
import feedbackRouter from './routes/feedback/index.js';
|
||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
@@ -25,6 +28,9 @@ app.use('/api/*', authMiddleware);
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
app.route('/api/scheduling', schedulingRouter);
|
||||
app.route('/api/energy', energyRouter);
|
||||
app.route('/api/ele', eleRouter);
|
||||
app.route('/api/feedback', feedbackRouter);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||
|
||||
|
||||
355
src/server/routes/ele/index.ts
Normal file
355
src/server/routes/ele/index.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
|
||||
import * as XLSX from 'xlsx';
|
||||
import pool from '../../db.js';
|
||||
import { ensureChargeRecordTable } from './migration.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 与 xlsx 列名对齐
|
||||
const COL = {
|
||||
orderNo: '订单编号',
|
||||
stationNo: '电站编号',
|
||||
stationName: '电站名称',
|
||||
terminalName: '终端名称',
|
||||
region: '所属大区',
|
||||
city: '所属城市',
|
||||
district: '市区名称',
|
||||
operatingCompany:'运营公司',
|
||||
stationType: '电站类型',
|
||||
orderStatus: '订单状态',
|
||||
chargeForm: '充电形式',
|
||||
startTime: '充电开始时间',
|
||||
endTime: '充电结束时间',
|
||||
duration: '充电时长(分钟)',
|
||||
kwh: '充电电量(度)',
|
||||
eFee: '充电电费(元)',
|
||||
serviceFee: '充电服务费(元)',
|
||||
fee: '充电费用(元)',
|
||||
plate: '车牌号',
|
||||
judgedPlate: '判定车牌号',
|
||||
vin: '车架号',
|
||||
customerName: '真实姓名',
|
||||
customerPhone: '手机号',
|
||||
enterpriseName: '企业名称',
|
||||
} as const;
|
||||
|
||||
function safeStr(v: unknown, max = 250): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
if (!s) return null;
|
||||
return s.slice(0, max);
|
||||
}
|
||||
|
||||
function safeNum(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeDt(v: unknown): string | null {
|
||||
const s = safeStr(v);
|
||||
if (!s) return null;
|
||||
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
|
||||
// 简单校验
|
||||
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
|
||||
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
|
||||
}
|
||||
|
||||
function normalizePlate(p: unknown): string | null {
|
||||
const s = safeStr(p, 32);
|
||||
if (!s) return null;
|
||||
// 去掉所有空白字符
|
||||
const trimmed = s.replace(/\s+/g, '').toUpperCase();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
|
||||
// 寻找含"订单编号"和"车牌号"的那一行
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!Array.isArray(row)) continue;
|
||||
const cells = row.map(c => (c == null ? '' : String(c)));
|
||||
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
|
||||
return { headerIdx: i, header: cells };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
orderNo: string;
|
||||
raw: Record<string, unknown>;
|
||||
values: {
|
||||
stationNo: string | null; stationName: string | null; terminalName: string | null;
|
||||
region: string | null; city: string | null; district: string | null;
|
||||
operatingCompany: string | null; stationType: string | null;
|
||||
orderStatus: string | null; chargeForm: string | null;
|
||||
startTime: string | null; endTime: string | null;
|
||||
duration: number | null; kwh: number | null;
|
||||
eFee: number | null; serviceFee: number | null; fee: number | null;
|
||||
plate: string | null; judgedPlate: string | null; vin: string | null;
|
||||
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
|
||||
const wb = XLSX.read(buf, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
if (!ws) return [];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
|
||||
const found = findHeaderRow(rows as unknown[][]);
|
||||
if (!found) return [];
|
||||
const { headerIdx, header } = found;
|
||||
const idx = (label: string) => header.indexOf(label);
|
||||
const result: ParsedRow[] = [];
|
||||
for (let r = headerIdx + 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!Array.isArray(row)) continue;
|
||||
const orderNo = safeStr(row[idx(COL.orderNo)]);
|
||||
if (!orderNo) continue;
|
||||
const raw: Record<string, unknown> = {};
|
||||
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
|
||||
result.push({
|
||||
orderNo,
|
||||
raw,
|
||||
values: {
|
||||
stationNo: safeStr(row[idx(COL.stationNo)]),
|
||||
stationName: safeStr(row[idx(COL.stationName)]),
|
||||
terminalName: safeStr(row[idx(COL.terminalName)]),
|
||||
region: safeStr(row[idx(COL.region)]),
|
||||
city: safeStr(row[idx(COL.city)]),
|
||||
district: safeStr(row[idx(COL.district)]),
|
||||
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
|
||||
stationType: safeStr(row[idx(COL.stationType)]),
|
||||
orderStatus: safeStr(row[idx(COL.orderStatus)]),
|
||||
chargeForm: safeStr(row[idx(COL.chargeForm)]),
|
||||
startTime: safeDt(row[idx(COL.startTime)]),
|
||||
endTime: safeDt(row[idx(COL.endTime)]),
|
||||
duration: safeNum(row[idx(COL.duration)]),
|
||||
kwh: safeNum(row[idx(COL.kwh)]),
|
||||
eFee: safeNum(row[idx(COL.eFee)]),
|
||||
serviceFee: safeNum(row[idx(COL.serviceFee)]),
|
||||
fee: safeNum(row[idx(COL.fee)]),
|
||||
plate: normalizePlate(row[idx(COL.plate)]),
|
||||
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
|
||||
vin: safeStr(row[idx(COL.vin)]),
|
||||
customerName: safeStr(row[idx(COL.customerName)]),
|
||||
customerPhone: safeStr(row[idx(COL.customerPhone)]),
|
||||
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
|
||||
if (plates.size === 0) return new Map();
|
||||
const arr = Array.from(plates);
|
||||
const placeholders = arr.map(() => '?').join(',');
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
||||
FROM tab_truck
|
||||
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
|
||||
arr,
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const r of rows) {
|
||||
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// POST /api/ele/import — 上传 xlsx 文件
|
||||
// =========================================================
|
||||
app.post('/import', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
const form = await c.req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||
}
|
||||
const filename = file.name || 'unnamed.xlsx';
|
||||
const buf = await file.arrayBuffer();
|
||||
let parsed: ParsedRow[];
|
||||
try {
|
||||
parsed = parseSheet(buf);
|
||||
} catch (e) {
|
||||
console.error('parseSheet error:', e);
|
||||
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
|
||||
}
|
||||
|
||||
// 文件内去重
|
||||
const dedupMap = new Map<string, ParsedRow>();
|
||||
for (const p of parsed) dedupMap.set(p.orderNo, p);
|
||||
const records = Array.from(dedupMap.values());
|
||||
const fileDuplicates = parsed.length - records.length;
|
||||
|
||||
// 系统车辆匹配
|
||||
const allPlates = new Set<string>();
|
||||
for (const r of records) {
|
||||
if (r.values.plate) allPlates.add(r.values.plate);
|
||||
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
|
||||
}
|
||||
const plateMap = await buildPlateLookup(allPlates);
|
||||
|
||||
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const importedAt = new Date();
|
||||
|
||||
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
|
||||
const sql = `INSERT IGNORE INTO bi_ele_charge_record
|
||||
(order_no, station_no, station_name, terminal_name, region, city, district,
|
||||
operating_company, station_type, order_status, charge_form,
|
||||
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
|
||||
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
|
||||
matched_truck_id, matched_plate, vehicle_kind, raw_json,
|
||||
batch_id, imported_at)
|
||||
VALUES ?`;
|
||||
|
||||
const values = records.map(r => {
|
||||
const plate = r.values.plate || r.values.judgedPlate;
|
||||
const matchedId = plate ? plateMap.get(plate) || null : null;
|
||||
// 命中系统车辆=internal;其余(含车牌为空)一律 external
|
||||
const kind = matchedId ? 'internal' : 'external';
|
||||
return [
|
||||
r.orderNo,
|
||||
r.values.stationNo, r.values.stationName, r.values.terminalName,
|
||||
r.values.region, r.values.city, r.values.district,
|
||||
r.values.operatingCompany, r.values.stationType,
|
||||
r.values.orderStatus, r.values.chargeForm,
|
||||
r.values.startTime, r.values.endTime, r.values.duration,
|
||||
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
|
||||
r.values.plate, r.values.judgedPlate, r.values.vin,
|
||||
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
|
||||
matchedId, matchedId ? plate : null, kind,
|
||||
JSON.stringify(r.raw),
|
||||
batchId, importedAt,
|
||||
];
|
||||
});
|
||||
|
||||
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
|
||||
const inserted = result.affectedRows;
|
||||
const dbDuplicates = records.length - inserted;
|
||||
|
||||
// 统计内/外(无车牌也算外部)
|
||||
let internal = 0, external = 0;
|
||||
for (const r of records) {
|
||||
const plate = r.values.plate || r.values.judgedPlate;
|
||||
if (plate && plateMap.has(plate)) internal++;
|
||||
else external++;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
filename,
|
||||
batchId,
|
||||
parsed: parsed.length,
|
||||
fileDuplicates,
|
||||
inserted,
|
||||
dbDuplicates,
|
||||
breakdown: { internal, external },
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// GET /api/ele/list — 分页列表(最新优先)
|
||||
// =========================================================
|
||||
app.get('/list', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
const page = Math.max(1, Number(c.req.query('page')) || 1);
|
||||
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||
const kind = c.req.query('kind') || '';
|
||||
const batchId = c.req.query('batchId') || '';
|
||||
const search = c.req.query('search') || '';
|
||||
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (kind === 'internal' || kind === 'external') {
|
||||
where.push('vehicle_kind = ?');
|
||||
params.push(kind);
|
||||
}
|
||||
if (batchId) {
|
||||
where.push('batch_id = ?');
|
||||
params.push(batchId);
|
||||
}
|
||||
if (search) {
|
||||
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
|
||||
const q = `%${search}%`;
|
||||
params.push(q, q, q);
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, order_no, station_name, terminal_name, region, city,
|
||||
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
|
||||
plate, judged_plate, customer_name, vehicle_kind,
|
||||
batch_id, imported_at
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY start_time DESC, id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
);
|
||||
const [countRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
const total = Number(countRows[0]?.total || 0);
|
||||
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// GET /api/ele/batches — 批次列表
|
||||
// =========================================================
|
||||
app.get('/batches', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT batch_id,
|
||||
MIN(imported_at) AS imported_at,
|
||||
COUNT(*) AS records,
|
||||
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
|
||||
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
|
||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||
ROUND(SUM(fee), 2) AS total_fee
|
||||
FROM bi_ele_charge_record
|
||||
GROUP BY batch_id
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 50`,
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// GET /api/ele/aggregate — 聚合统计
|
||||
// =========================================================
|
||||
app.get('/aggregate', async (c) => {
|
||||
await ensureChargeRecordTable();
|
||||
// 全量分类汇总
|
||||
const [overallRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT vehicle_kind,
|
||||
COUNT(*) AS records,
|
||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||
ROUND(SUM(fee), 2) AS total_fee
|
||||
FROM bi_ele_charge_record
|
||||
GROUP BY vehicle_kind`,
|
||||
);
|
||||
// 近 30 日按日
|
||||
const [dailyRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
vehicle_kind,
|
||||
COUNT(*) AS records,
|
||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||
ROUND(SUM(fee), 2) AS total_fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
|
||||
ORDER BY date DESC`,
|
||||
);
|
||||
return c.json({ overall: overallRows, daily: dailyRows });
|
||||
});
|
||||
|
||||
export default app;
|
||||
49
src/server/routes/ele/migration.ts
Normal file
49
src/server/routes/ele/migration.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import pool from '../../db.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
station_no VARCHAR(64) NULL,
|
||||
station_name VARCHAR(128) NULL,
|
||||
terminal_name VARCHAR(64) NULL,
|
||||
region VARCHAR(64) NULL,
|
||||
city VARCHAR(64) NULL,
|
||||
district VARCHAR(64) NULL,
|
||||
operating_company VARCHAR(128) NULL,
|
||||
station_type VARCHAR(32) NULL,
|
||||
order_status VARCHAR(32) NULL,
|
||||
charge_form VARCHAR(32) NULL,
|
||||
start_time DATETIME NULL,
|
||||
end_time DATETIME NULL,
|
||||
duration_min INT NULL,
|
||||
kwh DECIMAL(10,3) NULL,
|
||||
e_fee DECIMAL(10,2) NULL,
|
||||
service_fee DECIMAL(10,2) NULL,
|
||||
fee DECIMAL(10,2) NULL,
|
||||
plate VARCHAR(32) NULL,
|
||||
judged_plate VARCHAR(32) NULL,
|
||||
vin VARCHAR(64) NULL,
|
||||
customer_name VARCHAR(128) NULL,
|
||||
customer_phone VARCHAR(32) NULL,
|
||||
enterprise_name VARCHAR(128) NULL,
|
||||
matched_truck_id VARCHAR(32) NULL,
|
||||
matched_plate VARCHAR(32) NULL,
|
||||
vehicle_kind ENUM('internal','external','unknown') NOT NULL DEFAULT 'unknown',
|
||||
raw_json JSON NULL,
|
||||
batch_id VARCHAR(64) NOT NULL,
|
||||
imported_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uk_order_no (order_no),
|
||||
KEY idx_start_time (start_time),
|
||||
KEY idx_batch (batch_id),
|
||||
KEY idx_kind (vehicle_kind),
|
||||
KEY idx_plate (plate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
export async function ensureChargeRecordTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
ensured = true;
|
||||
}
|
||||
135
src/server/routes/energy/cache.ts
Normal file
135
src/server/routes/energy/cache.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||
*
|
||||
* 工作机制:
|
||||
* - 首次请求:阻塞等待 loader(cold start,3-4s 不可避免)
|
||||
* - 之后:每个 key 自调度刷新(TTL 到期前 5s),用户永远命中热缓存
|
||||
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
|
||||
* - 同一 key 并发请求只触发一次 loader
|
||||
* - force=true:手动强制刷新,绕过缓存(但仍参与 inflight 复用)
|
||||
*/
|
||||
|
||||
interface Entry<T> {
|
||||
value: T;
|
||||
freshAt: number;
|
||||
expiresAt: number;
|
||||
loader: () => Promise<T>;
|
||||
lastAccess: number;
|
||||
timer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const TTL_MS = 60 * 1000;
|
||||
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
|
||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
|
||||
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
|
||||
|
||||
const cache = new Map<string, Entry<unknown>>();
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
function scheduleRefresh<T>(key: string, entry: Entry<T>) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
|
||||
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
|
||||
entry.timer.unref?.();
|
||||
}
|
||||
|
||||
async function runRefresh(key: string) {
|
||||
const entry = cache.get(key) as Entry<unknown> | undefined;
|
||||
if (!entry) return;
|
||||
// 闲置超时:停止调度
|
||||
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
return;
|
||||
}
|
||||
if (inflight.has(key)) return;
|
||||
const p = entry.loader()
|
||||
.then(value => {
|
||||
const now = Date.now();
|
||||
const next: Entry<unknown> = {
|
||||
value,
|
||||
freshAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
loader: entry.loader,
|
||||
lastAccess: entry.lastAccess,
|
||||
};
|
||||
cache.set(key, next);
|
||||
scheduleRefresh(key, next);
|
||||
return value;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
|
||||
// 保留旧值,10s 后重试
|
||||
const retry: Entry<unknown> = { ...entry };
|
||||
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
|
||||
retry.timer.unref?.();
|
||||
cache.set(key, retry);
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
}
|
||||
|
||||
export interface CachedOpts {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key) as Entry<T> | undefined;
|
||||
if (hit) {
|
||||
hit.lastAccess = now;
|
||||
hit.loader = loader;
|
||||
}
|
||||
|
||||
// 强制刷新:等待 loader 完成
|
||||
if (opts.force) {
|
||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (ongoing) return ongoing;
|
||||
const p = loader()
|
||||
.then(value => {
|
||||
const t = Date.now();
|
||||
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||||
cache.set(key, next);
|
||||
scheduleRefresh(key, next);
|
||||
return value;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p as Promise<unknown>);
|
||||
return p;
|
||||
}
|
||||
|
||||
// 命中且未过期 → 立即返回
|
||||
if (hit && hit.expiresAt > now) {
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// 命中但过期 → 返回 stale,后台刷新
|
||||
if (hit) {
|
||||
if (!inflight.has(key)) void runRefresh(key);
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// 完全未命中 → 阻塞等待
|
||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (ongoing) return ongoing;
|
||||
|
||||
const p = loader()
|
||||
.then(value => {
|
||||
const t = Date.now();
|
||||
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||||
cache.set(key, entry);
|
||||
scheduleRefresh(key, entry);
|
||||
return value;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p as Promise<unknown>);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 仅用于测试或调试:清空所有缓存与定时器 */
|
||||
export function _clearEnergyCache() {
|
||||
for (const e of cache.values()) {
|
||||
if (e.timer) clearTimeout(e.timer);
|
||||
}
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
613
src/server/routes/energy/index.ts
Normal file
613
src/server/routes/energy/index.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import { cached } from './cache.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行;
|
||||
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
|
||||
app.use('*', async (c, next) => {
|
||||
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
|
||||
if (user && !canAccessEnergy(user.roles)) {
|
||||
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||
|
||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||
|
||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||
|
||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||||
function customerClause(field: string, customer: CustomerKind): string {
|
||||
if (customer === 'external') return `${field} IS NULL`;
|
||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||||
return '1=1';
|
||||
}
|
||||
|
||||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
|
||||
function rangeClause(localExpr: string, range: Range): string {
|
||||
switch (range) {
|
||||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||||
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||
function enumerateDates(range: Range): string[] {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
let start: Date;
|
||||
if (range === 'thisWeek') {
|
||||
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
||||
const day = today.getDay() || 7; // 周日 7
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - (day - 1));
|
||||
} else if (range === 'thisMonth') {
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
} else {
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 14);
|
||||
}
|
||||
const result: string[] = [];
|
||||
const cur = new Date(start);
|
||||
while (cur <= today) {
|
||||
result.push(fmt(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 氢能 总览:KPI + Top5 + 区域占比
|
||||
// =========================================================
|
||||
app.get('/hydrogen/overview', async (c) => {
|
||||
const yearParam = c.req.query('year');
|
||||
const force = c.req.query('force') === '1';
|
||||
const today = new Date();
|
||||
const todayYear = today.getFullYear();
|
||||
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
||||
|
||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
||||
ORDER BY y DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
);
|
||||
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
|
||||
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
|
||||
const isCurrentYear = year === todayYear;
|
||||
|
||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
[year, year, year, year, year, year, year,
|
||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||
HYDROGEN_MIN_DATE],
|
||||
);
|
||||
const k = kpiRows[0] ?? {};
|
||||
const yearFee = Number(k.yearFee) || 0;
|
||||
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
|
||||
const yearRevenue = Number(k.yearRevenue) || 0;
|
||||
const monthFee = Number(k.monthFee) || 0;
|
||||
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
|
||||
const monthRevenue = Number(k.monthRevenue) || 0;
|
||||
const todayFee = Number(k.todayFee) || 0;
|
||||
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
|
||||
const todayRevenue = Number(k.todayRevenue) || 0;
|
||||
const kpi = {
|
||||
yearKg: Number(k.yearKg) || 0,
|
||||
yearFee,
|
||||
yearRevenue,
|
||||
yearProfit: yearRevenue - yearCustomerCost,
|
||||
ourYearKg: Number(k.ourYearKg) || 0,
|
||||
ourYearFee: Number(k.ourYearFee) || 0,
|
||||
customerYearKg: Number(k.customerYearKg) || 0,
|
||||
monthKg: Number(k.monthKg) || 0,
|
||||
monthFee,
|
||||
monthRevenue,
|
||||
monthProfit: monthRevenue - monthCustomerCost,
|
||||
todayKg: Number(k.todayKg) || 0,
|
||||
todayFee,
|
||||
todayRevenue,
|
||||
todayProfit: todayRevenue - todayCustomerCost,
|
||||
lingniuBornKg: Number(k.lingniuBornKg) || 0,
|
||||
lingniuBornFee: Number(k.lingniuBornFee) || 0,
|
||||
};
|
||||
|
||||
// Top5 加氢站(指定年份)
|
||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.cost_expense) AS fee
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.hydrogen_station_id
|
||||
ORDER BY kg DESC
|
||||
LIMIT 5`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const top5KgSum = kpi.yearKg || 1;
|
||||
const top5 = top5Rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
name: r.name as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
share: (Number(r.kg) || 0) / top5KgSum,
|
||||
}));
|
||||
|
||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.hydrogen_station_id
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
|
||||
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
|
||||
const stations = stationFullRows.map(r => ({
|
||||
name: r.name as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
share: (Number(r.kg) || 0) / stationKgSum,
|
||||
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
|
||||
}));
|
||||
|
||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT region, SUM(kg) AS kg FROM (
|
||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
||||
b.hydrogen_quantity AS kg
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
) r
|
||||
GROUP BY region
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
|
||||
const TOP_REGIONS = 8;
|
||||
const top = regionRows.slice(0, TOP_REGIONS);
|
||||
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
|
||||
const regions = [
|
||||
...top.map(r => ({
|
||||
region: r.region as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
share: (Number(r.kg) || 0) / totalKg,
|
||||
})),
|
||||
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
|
||||
];
|
||||
|
||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
||||
ROUND(SUM(cost_expense), 2) AS fee,
|
||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(customer_expense), 2) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY m
|
||||
ORDER BY m`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
|
||||
for (const r of monthRows) {
|
||||
monthMap.set(r.m as string, {
|
||||
kg: Number(r.kg) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
customerCost: Number(r.customerCost) || 0,
|
||||
});
|
||||
}
|
||||
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
|
||||
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
|
||||
for (let mi = 1; mi <= lastMonth; mi++) {
|
||||
const key = `${year}-${String(mi).padStart(2, '0')}`;
|
||||
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
|
||||
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
|
||||
}
|
||||
|
||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
||||
ELSE 'customer' END AS payer,
|
||||
SUM(hydrogen_quantity) AS kg,
|
||||
SUM(cost_expense) AS cost,
|
||||
SUM(customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY name
|
||||
ORDER BY kg DESC
|
||||
LIMIT 30`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
const customers = customerRows.map(r => ({
|
||||
name: r.name as string,
|
||||
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
|
||||
kg: Number(r.kg) || 0,
|
||||
cost: Number(r.cost) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
}));
|
||||
|
||||
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
||||
// =========================================================
|
||||
app.get('/hydrogen/daily', async (c) => {
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||
|
||||
const where = [
|
||||
'b.is_deleted = 0',
|
||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||
rangeClause(`b.hydrogen_time`, range),
|
||||
customerClause('b.truck_id', customer),
|
||||
].join(' AND ');
|
||||
|
||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||||
b.hydrogen_station_id AS stationId,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||
MAX(b.cost_price) AS pricePerKg
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE ${where}
|
||||
GROUP BY d, b.hydrogen_station_id
|
||||
ORDER BY d DESC, kg DESC`,
|
||||
);
|
||||
|
||||
// 站点环比:同站点上一条记录的 kg
|
||||
// 按 stationId 分组、按日期升序计算
|
||||
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
|
||||
const flat: StationRow[] = stationRows.map(r => ({
|
||||
date: r.d as string,
|
||||
stationId: Number(r.stationId),
|
||||
name: r.stationName as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
pricePerKg: Number(r.pricePerKg) || 0,
|
||||
}));
|
||||
|
||||
// 计算日级总量 + 日级环比
|
||||
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
|
||||
for (const s of flat) {
|
||||
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
|
||||
const e = dayMap.get(s.date)!;
|
||||
e.totalKg += s.kg;
|
||||
e.stations.push(s);
|
||||
}
|
||||
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
|
||||
const dayChainPct = new Map<string, number>();
|
||||
let prev = 0;
|
||||
for (const d of dates) {
|
||||
const cur = dayMap.get(d)!.totalKg;
|
||||
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
|
||||
prev = cur;
|
||||
}
|
||||
|
||||
// 站点级环比:按 stationId 分组按日期升序
|
||||
const stationPrev = new Map<number, number>();
|
||||
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
|
||||
// 需要按 stationId 分组排序
|
||||
const byStation = new Map<number, StationRow[]>();
|
||||
for (const s of flat) {
|
||||
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
|
||||
byStation.get(s.stationId)!.push(s);
|
||||
}
|
||||
for (const [, list] of byStation) {
|
||||
list.sort((a, b) => a.date.localeCompare(b.date));
|
||||
let p = 0;
|
||||
for (const r of list) {
|
||||
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
|
||||
p = r.kg;
|
||||
}
|
||||
}
|
||||
|
||||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||
const allDates = enumerateDates(range);
|
||||
const fullDays = allDates.map(date => {
|
||||
const info = dayMap.get(date);
|
||||
return {
|
||||
date,
|
||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||
chainPct: dayChainPct.get(date) ?? 0,
|
||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||
stations: info
|
||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||
name: s.name,
|
||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||||
kg: Math.round(s.kg * 100) / 100,
|
||||
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
});
|
||||
|
||||
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
||||
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
||||
let prevKg = 0;
|
||||
for (const d of ascDays) {
|
||||
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
||||
prevKg = d.totalKg;
|
||||
}
|
||||
|
||||
// 按日期降序返回
|
||||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||
return result;
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||||
// =========================================================
|
||||
app.get('/electric/overview', async (c) => {
|
||||
const force = c.req.query('force') === '1';
|
||||
const data = await cached('electric/overview', async () => {
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
SUM(kwh) AS totalKwh,
|
||||
SUM(fee) AS totalFee,
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN kwh ELSE 0 END) AS monthKwh,
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN fee ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
|
||||
FROM bi_ele_charge_record`,
|
||||
);
|
||||
const k = kpiRows[0] ?? {};
|
||||
const totalKwh = Number(k.totalKwh) || 0;
|
||||
const totalFee = Number(k.totalFee) || 0;
|
||||
const monthKwh = Number(k.monthKwh) || 0;
|
||||
const monthFee = Number(k.monthFee) || 0;
|
||||
const todayKwh = Number(k.todayKwh) || 0;
|
||||
const todayFee = Number(k.todayFee) || 0;
|
||||
|
||||
// 本月每日(用于柱图)
|
||||
const [trendRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`,
|
||||
);
|
||||
// 若本月无数据,降级展示最近一个有数据的自然月
|
||||
let trend = trendRows;
|
||||
if (trend.length === 0) {
|
||||
const [fallback] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
|
||||
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
|
||||
)
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`,
|
||||
);
|
||||
trend = fallback;
|
||||
}
|
||||
const trendArr = trend.map(r => ({
|
||||
date: r.date as string,
|
||||
kwh: Math.round((Number(r.kwh) || 0) * 100) / 100,
|
||||
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
|
||||
chainPct: 0,
|
||||
}));
|
||||
for (let i = 1; i < trendArr.length; i++) {
|
||||
const prev = trendArr[i - 1].kwh;
|
||||
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
|
||||
}
|
||||
|
||||
let todayChainPct = 0;
|
||||
if (todayKwh > 0) {
|
||||
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT SUM(kwh) AS kwh
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||||
);
|
||||
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||
trend: trendArr,
|
||||
};
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||||
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||||
// 缺失日期补零
|
||||
// =========================================================
|
||||
app.get('/electric/monthly', async (c) => {
|
||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||
|
||||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||
let kindClause = '1=1';
|
||||
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||||
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||||
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${kindClause}
|
||||
AND ${rangeClause('start_time', range)}
|
||||
GROUP BY date`,
|
||||
);
|
||||
|
||||
// 实际数据 map
|
||||
const dataMap = new Map<string, { kwh: number; fee: number }>();
|
||||
for (const r of rows) {
|
||||
dataMap.set(r.date as string, {
|
||||
kwh: Number(r.kwh) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 补零:枚举 range 全部日期
|
||||
const allDates = enumerateDates(range);
|
||||
const fullDays = allDates.map(date => {
|
||||
const d = dataMap.get(date);
|
||||
return {
|
||||
date,
|
||||
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
|
||||
fee: d ? Math.round(d.fee * 100) / 100 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
|
||||
const monthMap = new Map<string, typeof fullDays>();
|
||||
for (const d of fullDays) {
|
||||
const m = d.date.slice(0, 7);
|
||||
if (!monthMap.has(m)) monthMap.set(m, []);
|
||||
monthMap.get(m)!.push(d);
|
||||
}
|
||||
|
||||
const months = Array.from(monthMap.entries())
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([month, days]) => {
|
||||
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const chain = new Map<string, number>();
|
||||
let prev = 0;
|
||||
for (const d of asc) {
|
||||
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
|
||||
prev = d.kwh;
|
||||
}
|
||||
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
|
||||
const rowsWithChain = desc.map(d => ({
|
||||
date: d.date,
|
||||
kwh: d.kwh,
|
||||
fee: d.fee,
|
||||
chainPct: chain.get(d.date) ?? 0,
|
||||
}));
|
||||
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
|
||||
const feeSum = days.reduce((s, d) => s + d.fee, 0);
|
||||
return {
|
||||
month,
|
||||
kwh: Math.round(kwhSum * 100) / 100,
|
||||
fee: Math.round(feeSum * 100) / 100,
|
||||
rows: rowsWithChain,
|
||||
};
|
||||
});
|
||||
|
||||
return months;
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
export default app;
|
||||
190
src/server/routes/feedback/index.ts
Normal file
190
src/server/routes/feedback/index.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canManageFeedback } from '../../auth/types.js';
|
||||
import { uploadFeedbackImage } from './oss.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
||||
module VARCHAR(64) NULL,
|
||||
content TEXT NOT NULL,
|
||||
contact VARCHAR(200) NULL,
|
||||
screenshots JSON NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
user_name VARCHAR(128) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||
reply_content TEXT NULL,
|
||||
reply_user VARCHAR(128) NULL,
|
||||
reply_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
KEY idx_created_at (created_at),
|
||||
KEY idx_type (type),
|
||||
KEY idx_status (status),
|
||||
KEY idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
async function ensureTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
// 兼容旧表:补齐缺失列
|
||||
for (const alter of [
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||||
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||||
]) {
|
||||
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||||
}
|
||||
ensured = true;
|
||||
}
|
||||
|
||||
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||||
|
||||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||
|
||||
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
|
||||
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||||
|
||||
app.post('/submit', async (c) => {
|
||||
await ensureTable();
|
||||
const body = await c.req.json().catch(() => ({})) as {
|
||||
type?: string; module?: string | null; content?: string;
|
||||
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||||
};
|
||||
const type = (body.type || '').trim();
|
||||
const content = (body.content || '').trim();
|
||||
if (!VALID_TYPES.has(type)) {
|
||||
return c.json({ ok: false, message: '类型不合法' }, 400);
|
||||
}
|
||||
if (!content || content.length > 2000) {
|
||||
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
||||
}
|
||||
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||||
const contact = (body.contact || '').slice(0, 200) || null;
|
||||
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||||
const screenshots = Array.isArray(body.screenshots)
|
||||
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||||
: [];
|
||||
|
||||
const [r] = await pool.query<ResultSetHeader>(
|
||||
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
||||
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
||||
);
|
||||
return c.json({ ok: true, id: r.insertId });
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||||
// =========================================================
|
||||
app.post('/upload', async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||
}
|
||||
const mime = file.type || 'image/png';
|
||||
if (!ALLOWED_MIME.has(mime)) {
|
||||
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
|
||||
}
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
try {
|
||||
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
|
||||
return c.json({ ok: true, url });
|
||||
} catch (e) {
|
||||
console.error('feedback upload error:', e);
|
||||
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||
app.get('/mine', async (c) => {
|
||||
await ensureTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!user?.userId) return c.json({ items: [] });
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
[user.userId],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.get('/list', async (c) => {
|
||||
await ensureTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!canManageFeedback(user?.roles)) {
|
||||
return c.json({ ok: false, message: '无权限' }, 403);
|
||||
}
|
||||
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||||
const status = c.req.query('status') || '';
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (VALID_STATUS.has(status)) {
|
||||
where.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.patch('/:id', async (c) => {
|
||||
await ensureTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!canManageFeedback(user?.roles)) {
|
||||
return c.json({ ok: false, message: '无权限' }, 403);
|
||||
}
|
||||
const id = Number(c.req.param('id'));
|
||||
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||||
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||||
const fields: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (body.status) {
|
||||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||||
fields.push('status = ?');
|
||||
params.push(body.status);
|
||||
}
|
||||
if (typeof body.reply === 'string') {
|
||||
const reply = body.reply.trim().slice(0, 2000);
|
||||
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
params.push(reply || null, user?.userName || user?.userId || null);
|
||||
}
|
||||
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||||
params.push(id);
|
||||
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
38
src/server/routes/feedback/oss.ts
Normal file
38
src/server/routes/feedback/oss.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import OSS from 'ali-oss';
|
||||
|
||||
let client: OSS | null = null;
|
||||
|
||||
function getClient(): OSS {
|
||||
if (client) return client;
|
||||
const region = process.env.OSS_REGION || 'oss-cn-shanghai';
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID || '';
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || '';
|
||||
const bucket = process.env.OSS_BUCKET || '';
|
||||
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||
throw new Error('OSS 未配置:OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
|
||||
}
|
||||
client = new OSS({ region, accessKeyId, accessKeySecret, bucket, secure: true });
|
||||
return client;
|
||||
}
|
||||
|
||||
function safeExt(filename: string, fallback = 'png'): string {
|
||||
const m = /\.([a-zA-Z0-9]{1,8})$/.exec(filename);
|
||||
return m ? m[1].toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function randId(len = 8): string {
|
||||
return Math.random().toString(36).slice(2, 2 + len);
|
||||
}
|
||||
|
||||
/** 上传 buffer 到 OSS,返回公开访问的 URL */
|
||||
export async function uploadFeedbackImage(filename: string, buf: Buffer, mimetype: string): Promise<string> {
|
||||
const c = getClient();
|
||||
const baseDir = (process.env.OSS_BASE_DIR || '/dos').replace(/^\/+|\/+$/g, '');
|
||||
const ymd = new Date().toISOString().slice(0, 10);
|
||||
const key = `${baseDir}/feedback/${ymd}/${Date.now().toString(36)}-${randId()}.${safeExt(filename, mimetype.split('/')[1] || 'png')}`;
|
||||
await c.put(key, buf, {
|
||||
headers: { 'Content-Type': mimetype, 'x-oss-object-acl': 'public-read' },
|
||||
});
|
||||
const host = (process.env.OSS_HOST || `https://${process.env.OSS_BUCKET}.${process.env.OSS_ENDPOINT}/`).replace(/\/+$/, '/');
|
||||
return host + key;
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const regionMap: Record<string, string> = JSON.parse(
|
||||
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
|
||||
);
|
||||
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
|
||||
|
||||
let monitoringCache: MonitoringCache | null = null;
|
||||
|
||||
export function getCache(): MonitoringCache | null {
|
||||
@@ -38,7 +47,14 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
|
||||
.map(([prefix, count]) => ({ prefix, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
|
||||
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
|
||||
const regions = Array.from(regionSet).sort((a, b) => {
|
||||
const ai = REGION_ORDER.indexOf(a);
|
||||
const bi = REGION_ORDER.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
|
||||
}
|
||||
|
||||
interface MileageRow {
|
||||
@@ -99,6 +115,7 @@ function mergeVehicles(
|
||||
rentStatus: info?.rent_status || null,
|
||||
entity: info?.entity || null,
|
||||
project: info?.project || null,
|
||||
region: regionMap[m.plate] || null,
|
||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { refreshMonitoringCache } from './cache.js';
|
||||
import monitoringRouter from './monitoring.js';
|
||||
import targetsRouter from './targets.js';
|
||||
import trendRouter from './trend.js';
|
||||
import vehicleRecentRouter from './vehicle-recent.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -10,6 +11,7 @@ app.route('/monitoring', monitoringRouter);
|
||||
app.route('/targets', targetsRouter);
|
||||
app.route('/target', targetsRouter);
|
||||
app.route('/trend', trendRouter);
|
||||
app.route('/vehicle', vehicleRecentRouter);
|
||||
|
||||
// 启动时立即刷新缓存,之后每分钟刷新
|
||||
refreshMonitoringCache();
|
||||
|
||||
@@ -9,7 +9,7 @@ const app = new Hono();
|
||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
vehicles: [],
|
||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
search: string; dept: string; customer: string; project: string;
|
||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||
targetName: string; mileageMin: string; mileageMax: string;
|
||||
targetName: string; region: string; mileageMin: string; mileageMax: string;
|
||||
}): CachedVehicle[] {
|
||||
let result = vehicles;
|
||||
|
||||
@@ -36,8 +36,12 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
if (params.project) result = result.filter(v => v.project === params.project);
|
||||
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||
if (params.plate) result = result.filter(v => v.plate === params.plate);
|
||||
if (params.plate) {
|
||||
const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean));
|
||||
if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate));
|
||||
}
|
||||
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
||||
if (params.region) result = result.filter(v => v.region === params.region);
|
||||
if (params.targetName) {
|
||||
const cache = getCache();
|
||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||
@@ -66,6 +70,7 @@ app.get('/', async (c) => {
|
||||
plate: c.req.query('plate') || '',
|
||||
platePrefix: c.req.query('platePrefix') || '',
|
||||
targetName: c.req.query('targetName') || '',
|
||||
region: c.req.query('region') || '',
|
||||
mileageMin: c.req.query('mileageMin') || '',
|
||||
mileageMax: c.req.query('mileageMax') || '',
|
||||
};
|
||||
@@ -95,6 +100,12 @@ app.get('/', async (c) => {
|
||||
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
||||
}
|
||||
|
||||
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
|
||||
if (filterParams.region) {
|
||||
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
|
||||
filters = buildDateFilters(regionScope);
|
||||
}
|
||||
|
||||
const filtered = applyFilters(allVehicles, filterParams);
|
||||
|
||||
const stats = {
|
||||
|
||||
138
src/server/routes/mileage/region-map.json
Normal file
138
src/server/routes/mileage/region-map.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"粤AGP2009": "华南区域",
|
||||
"粤AGP2011": "华南区域",
|
||||
"粤AGP2017": "华南区域",
|
||||
"粤AGP2032": "华南区域",
|
||||
"粤AGP2035": "华东区域",
|
||||
"粤AGP3027": "华东区域",
|
||||
"粤AGP3029": "华南区域",
|
||||
"粤AGP3071": "华南区域",
|
||||
"粤AGP3078": "华东区域",
|
||||
"粤AGP3079": "华东区域",
|
||||
"粤AGP3082": "西南区域",
|
||||
"粤AGP3087": "西南区域",
|
||||
"粤AGP3097": "华南区域",
|
||||
"粤AGP3486": "西北区域",
|
||||
"粤AGP3502": "华南区域",
|
||||
"粤AGP3503": "华东区域",
|
||||
"粤AGP3505": "西南区域",
|
||||
"粤AGP3506": "华东区域",
|
||||
"粤AGP3509": "西南区域",
|
||||
"粤AGP3513": "华东区域",
|
||||
"粤AGP3515": "华东区域",
|
||||
"粤AGP3605": "华东区域",
|
||||
"粤AGP3607": "华东区域",
|
||||
"粤AGP3609": "华东区域",
|
||||
"粤AGP3612": "华东区域",
|
||||
"粤AGP3615": "华南区域",
|
||||
"粤AGP3617": "西北区域",
|
||||
"粤AGP3625": "华东区域",
|
||||
"粤AGP3627": "华东区域",
|
||||
"粤AGP3631": "西南区域",
|
||||
"粤AGP3642": "华南区域",
|
||||
"粤AGP3645": "华南区域",
|
||||
"粤AGP3649": "华东区域",
|
||||
"粤AGP3651": "华东区域",
|
||||
"粤AGP3659": "华东区域",
|
||||
"粤AGP3660": "华南区域",
|
||||
"粤AGP3667": "华南区域",
|
||||
"粤AGP3672": "华南区域",
|
||||
"粤AGP3673": "西南区域",
|
||||
"粤AGP3690": "华东区域",
|
||||
"粤AGP3692": "华东区域",
|
||||
"粤AGP3695": "华东区域",
|
||||
"粤AGP4223": "华南区域",
|
||||
"粤AGP4318": "华东区域",
|
||||
"粤AGP4321": "华东区域",
|
||||
"粤AGP4325": "华南区域",
|
||||
"粤AGP4335": "华东区域",
|
||||
"粤AGP4355": "华东区域",
|
||||
"粤AGP4377": "华东区域",
|
||||
"粤AGP4386": "华东区域",
|
||||
"粤AGP4396": "西南区域",
|
||||
"粤AGP4422": "华南区域",
|
||||
"粤AGP4435": "华南区域",
|
||||
"粤AGP4451": "华南区域",
|
||||
"粤AGP4482": "华南区域",
|
||||
"粤AGP4486": "华南区域",
|
||||
"粤AGP4489": "华东区域",
|
||||
"粤AGP4502": "华南区域",
|
||||
"粤AGP4522": "华东区域",
|
||||
"粤AGP4538": "华南区域",
|
||||
"粤AGP4548": "华东区域",
|
||||
"粤AGP4566": "华南区域",
|
||||
"粤AGP4569": "华南区域",
|
||||
"粤AGP4583": "华东区域",
|
||||
"粤AGP4586": "华东区域",
|
||||
"粤AGP4587": "西南区域",
|
||||
"粤AGP4596": "华南区域",
|
||||
"粤AGP4597": "华东区域",
|
||||
"粤AGP4599": "华东区域",
|
||||
"粤AGP4623": "华东区域",
|
||||
"粤AGP4629": "华南区域",
|
||||
"粤AGP5165": "华东区域",
|
||||
"粤AGP5167": "华东区域",
|
||||
"粤AGP5169": "华南区域",
|
||||
"粤AGP5301": "华东区域",
|
||||
"粤AGP5350": "华南区域",
|
||||
"粤AGP5351": "华东区域",
|
||||
"粤AGP5357": "华东区域",
|
||||
"粤AGP5363": "华南区域",
|
||||
"粤AGP5379": "华东区域",
|
||||
"粤AGP5613": "华南区域",
|
||||
"粤AGP5615": "华东区域",
|
||||
"粤AGP5617": "华东区域",
|
||||
"粤AGP5621": "西南区域",
|
||||
"粤AGP5622": "华东区域",
|
||||
"粤AGP5623": "华南区域",
|
||||
"粤AGP5642": "华东区域",
|
||||
"粤AGP5643": "西北区域",
|
||||
"粤AGP5646": "华东区域",
|
||||
"粤AGP5651": "华东区域",
|
||||
"粤AGP5661": "华东区域",
|
||||
"粤AGP5681": "华南区域",
|
||||
"粤AGP5691": "华东区域",
|
||||
"粤AGP5710": "华东区域",
|
||||
"粤AGP5711": "西北区域",
|
||||
"粤AGP5712": "华东区域",
|
||||
"粤AGP5719": "华南区域",
|
||||
"粤AGP5749": "华东区域",
|
||||
"粤AGP5760": "华东区域",
|
||||
"粤AGP5763": "华东区域",
|
||||
"粤AGP5769": "华东区域",
|
||||
"粤AGP5770": "华东区域",
|
||||
"粤AGP5791": "西北区域",
|
||||
"粤AGP5792": "华南区域",
|
||||
"粤AGP5797": "华东区域",
|
||||
"粤AGP7016": "华南区域",
|
||||
"粤AGP7019": "西南区域",
|
||||
"粤AGP7022": "华南区域",
|
||||
"粤AGP7026": "华东区域",
|
||||
"粤AGP7047": "华东区域",
|
||||
"粤AGP9330": "华南区域",
|
||||
"粤AGP9346": "华东区域",
|
||||
"粤AGP9347": "华南区域",
|
||||
"粤AGP9350": "华东区域",
|
||||
"粤AGP9351": "华南区域",
|
||||
"粤AGP9702": "华东区域",
|
||||
"粤AGP9703": "西北区域",
|
||||
"粤AGP9706": "华东区域",
|
||||
"粤AGP9707": "华东区域",
|
||||
"粤AGP9713": "华东区域",
|
||||
"粤AGP9717": "华南区域",
|
||||
"粤AGP9721": "华东区域",
|
||||
"粤AGP9726": "华南区域",
|
||||
"粤AGP9731": "华南区域",
|
||||
"粤AGP9735": "华东区域",
|
||||
"粤AGP9739": "华南区域",
|
||||
"粤AGP9751": "华南区域",
|
||||
"粤AGP9753": "华东区域",
|
||||
"粤AGP9755": "华东区域",
|
||||
"粤AGP9759": "华东区域",
|
||||
"粤AGP9782": "华东区域",
|
||||
"粤AGP9790": "华南区域",
|
||||
"粤AGP9791": "华东区域",
|
||||
"粤AGP9817": "华南区域",
|
||||
"粤AGP9827": "华南区域",
|
||||
"粤AGP9836": "华南区域"
|
||||
}
|
||||
@@ -19,8 +19,10 @@ app.get('/', async (c) => {
|
||||
if (plates.length === 0) return c.json([]);
|
||||
}
|
||||
|
||||
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
|
||||
let sql = `
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
|
||||
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
|
||||
`;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CachedVehicle {
|
||||
rentStatus: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
yesterdayKm: number;
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export interface MonitoringFilters {
|
||||
rentStatuses: string[];
|
||||
platePrefixes: PlatePrefix[];
|
||||
targetNames: string[];
|
||||
regions: string[];
|
||||
}
|
||||
|
||||
/** 监控缓存 */
|
||||
|
||||
101
src/server/routes/mileage/vehicle-recent.ts
Normal file
101
src/server/routes/mileage/vehicle-recent.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Hono } from 'hono';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
interface DayRow {
|
||||
date: string;
|
||||
daily_km: string | number | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function parseYmd(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
const MAX_DAYS = 366;
|
||||
|
||||
app.get('/:plate/recent', async (c) => {
|
||||
const plate = c.req.param('plate');
|
||||
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 区间参数:优先 start/end;否则回退 days(兼容旧调用)
|
||||
const startQ = c.req.query('start');
|
||||
const endQ = c.req.query('end');
|
||||
let start: Date;
|
||||
let end: Date;
|
||||
if (startQ) {
|
||||
const ps = parseYmd(startQ);
|
||||
if (!ps) return c.json({ plate, days: [] }, 400);
|
||||
start = ps;
|
||||
end = endQ ? (parseYmd(endQ) ?? today) : today;
|
||||
} else {
|
||||
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), MAX_DAYS);
|
||||
end = today;
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - (days - 1));
|
||||
}
|
||||
if (start > end) [start, end] = [end, start];
|
||||
// 限制区间长度
|
||||
const span = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
if (span > MAX_DAYS) {
|
||||
start = new Date(end);
|
||||
start.setDate(end.getDate() - (MAX_DAYS - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
||||
ORDER BY stat_date`,
|
||||
[plate, fmt(start), fmt(end)]
|
||||
) as [DayRow[], unknown];
|
||||
|
||||
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
||||
const map = new Map<string, { dailyKm: number; source: string }>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const src = r.source || 'NONE';
|
||||
const existing = map.get(r.date);
|
||||
if (!existing || km > existing.dailyKm) {
|
||||
map.set(r.date, { dailyKm: km, source: src });
|
||||
}
|
||||
}
|
||||
|
||||
// 补全:从 start 到 end 每天一条
|
||||
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const key = fmt(cursor);
|
||||
const hit = map.get(key);
|
||||
result.push({
|
||||
date: key,
|
||||
dailyKm: hit?.dailyKm ?? 0,
|
||||
isDataSynced: !!hit && hit.source !== 'NONE',
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
|
||||
} catch (e: unknown) {
|
||||
console.error('vehicle recent error:', e);
|
||||
return c.json({ plate, days: [] }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -420,7 +420,7 @@ interface WeeklyStats {
|
||||
// 交车单 SQL
|
||||
const DELIVERED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
@@ -439,7 +439,7 @@ WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||||
// 还车单 SQL
|
||||
const RETURNED_SQL = `SELECT
|
||||
r.id, DATE(r.return_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_return r
|
||||
@@ -457,7 +457,7 @@ WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
||||
// 替换车单 SQL
|
||||
const REPLACED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
@@ -793,11 +793,21 @@ app.get('/region-stats', async (c) => {
|
||||
cityMap.get(city)!.push(v);
|
||||
}
|
||||
|
||||
const getTypeBreakdown = (vList: Vehicle[]) =>
|
||||
['4.5T', '18T', '49T'].map((type) => {
|
||||
const tv = vList.filter((v) => v.type === type);
|
||||
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
|
||||
}).filter((t) => t.total > 0);
|
||||
const getTypeBreakdown = (vList: Vehicle[]) => {
|
||||
const KNOWN = ['4.5T', '18T', '49T'] as const;
|
||||
const make = (label: string, tv: Vehicle[]) => ({
|
||||
type: label,
|
||||
total: tv.length,
|
||||
operating: tv.filter((v) => v.status === 'Operating').length,
|
||||
inventory: tv.filter((v) => v.status === 'Inventory').length,
|
||||
pending: tv.filter((v) => v.status === 'Pending').length,
|
||||
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
});
|
||||
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
|
||||
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
|
||||
if (other.length > 0) known.push(make('其他', other));
|
||||
return known.filter((t) => t.total > 0);
|
||||
};
|
||||
|
||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||
const result = regionOrder
|
||||
@@ -880,6 +890,21 @@ app.get('/customer-stats', async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Location 过滤器:支持展示区域(嘉兴/广东/北京/新疆/其他)、库存区域(江浙沪/其它)、
|
||||
// 城市(嘉兴市)、宏观区域(华东/华南/...)。
|
||||
// '其他' 在两个体系里都存在(资产表的"库存-其他" vs 区域表的"其他"宏观区域),
|
||||
// 用 source 区分:source==='asset' 时按 v.location 匹配,其它情况按宏观区域匹配。
|
||||
function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
|
||||
if (isMacro) {
|
||||
return vehicles.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||
}
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
return vehicles.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||
}
|
||||
|
||||
// Vehicle type filter map (same logic as /by-type)
|
||||
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
||||
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
|
||||
@@ -925,15 +950,7 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.model === model);
|
||||
}
|
||||
if (location && location !== 'All') {
|
||||
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
if (macroRegions.includes(location) || location === '其他') {
|
||||
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||
} else {
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||
}
|
||||
filtered = filterByLocation(filtered, location, c.req.query('source'));
|
||||
}
|
||||
if (status && status !== 'All') {
|
||||
filtered = filtered.filter((v) => v.status === status);
|
||||
@@ -943,6 +960,8 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||
} else if (category === 'Operating') {
|
||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||
} else if (category === 'Pending') {
|
||||
filtered = filtered.filter((v) => v.status === 'Pending');
|
||||
}
|
||||
}
|
||||
if (manager) {
|
||||
@@ -1023,8 +1042,11 @@ app.get('/inventory-stats', async (c) => {
|
||||
});
|
||||
|
||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||
// Optional filters: model, batch, location, source — 按缓存车辆集合的 truck_id 交集过滤
|
||||
app.get('/weekly-detail', async (c) => {
|
||||
const type = c.req.query('type');
|
||||
const { model, batch, location } = c.req.query();
|
||||
const source = c.req.query('source');
|
||||
let sql: string;
|
||||
if (type === 'delivered') {
|
||||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||
@@ -1033,17 +1055,33 @@ app.get('/weekly-detail', async (c) => {
|
||||
} else if (type === 'replaced') {
|
||||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||
} else if (type === 'pending') {
|
||||
sql = `SELECT truck.id AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||
} else if (type === 'new') {
|
||||
sql = `SELECT truck.id AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
||||
} else {
|
||||
return c.json([]);
|
||||
}
|
||||
const [rows] = await pool.query<any[]>(sql);
|
||||
const masked = (rows as any[]).map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
|
||||
let result = rows as any[];
|
||||
|
||||
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
|
||||
const hasModelFilter = model && model !== 'All';
|
||||
const hasBatchFilter = batch && batch !== 'All';
|
||||
const hasLocationFilter = location && location !== 'All';
|
||||
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
let pool2 = vehicles;
|
||||
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
|
||||
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
|
||||
if (hasLocationFilter) pool2 = filterByLocation(pool2, location, source);
|
||||
const truckSet = new Set(pool2.map((v) => String(v.id)));
|
||||
result = result.filter((r: any) => truckSet.has(String(r.truck_id)));
|
||||
}
|
||||
|
||||
const masked = result.map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
|
||||
return c.json(masked);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,8 +10,27 @@ export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
||||
/** 智能调度模块访问角色 */
|
||||
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
||||
|
||||
/** 反馈管理(管理员)访问角色 */
|
||||
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
|
||||
|
||||
/** 能源管理模块访问角色 */
|
||||
export const ENERGY_ACCESS_ROLES = ['BI-LEADER-ENERGY'];
|
||||
|
||||
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
|
||||
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
|
||||
}
|
||||
|
||||
/** 用户是否可管理反馈。仅 BI-ADMIN-FEEDBACK 或全量权限角色可访问。 */
|
||||
export function canManageFeedback(roles: readonly string[] | null | undefined): boolean {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r));
|
||||
}
|
||||
|
||||
/** 用户是否可访问能源管理模块。仅 BI-LEADER-ENERGY 或「所有权限」可访问。 */
|
||||
const ENERGY_FULL_ACCESS = '所有权限';
|
||||
export function canAccessEnergy(roles: readonly string[] | null | undefined): boolean {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS);
|
||||
}
|
||||
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user