Compare commits
22 Commits
demo
...
3809e785c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 已有)
|
||||||
110
package-lock.json
generated
110
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.0",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.0",
|
"version": "1.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
@@ -1657,6 +1658,15 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -1766,6 +1776,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -1820,6 +1843,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1872,6 +1904,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -2177,6 +2221,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.38.0",
|
"version": "12.38.0",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||||
@@ -3209,6 +3262,18 @@
|
|||||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -3973,6 +4038,24 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
@@ -3991,6 +4074,27 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Truck, Route, Activity } from 'lucide-react';
|
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from './components/Shell';
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from './modules/assets/AssetsModule';
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from './modules/mileage/MileageModule';
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||||
|
import EnergyModule from './modules/energy/EnergyModule';
|
||||||
import AuthProvider from './auth/AuthProvider';
|
import AuthProvider from './auth/AuthProvider';
|
||||||
import { useAuth } from './auth/useAuth';
|
import { useAuth } from './auth/useAuth';
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||||
@@ -12,6 +13,7 @@ import { canAccessScheduling } from './shared/auth/roles';
|
|||||||
const BASE_MODULES: ModuleConfig[] = [
|
const BASE_MODULES: ModuleConfig[] = [
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
|
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
|
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: {
|
||||||
|
userId: 'dev-local',
|
||||||
|
userName: '本地开发',
|
||||||
|
permissionLevel: 'full',
|
||||||
|
depName: '',
|
||||||
|
roles: ['所有权限', 'BI-SCHEDULE-OPT'],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 检查 sessionStorage 中是否有 JWT
|
// 1. 检查 sessionStorage 中是否有 JWT
|
||||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
@@ -65,8 +82,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const jumpToken = params.get('jumpToken');
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
if (!jumpToken) {
|
if (!jumpToken) {
|
||||||
// 演示模式:无 token 时直接放行
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
|
||||||
setState({ isLoading: false, isAuthenticated: true, user: null, error: null });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const PATH_MAP: Record<string, string> = {
|
|||||||
'/assets': 'assets',
|
'/assets': 'assets',
|
||||||
'/mileage': 'mileage',
|
'/mileage': 'mileage',
|
||||||
'/scheduling': 'scheduling',
|
'/scheduling': 'scheduling',
|
||||||
|
'/energy': 'energy',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
@@ -71,8 +72,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={true}>
|
<DemoModeProvider enabled={false}>
|
||||||
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* 全局水印 */}
|
{/* 全局水印 */}
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
|
|||||||
@@ -223,11 +223,18 @@ export default function AssetsModule() {
|
|||||||
setModalLoading(true);
|
setModalLoading(true);
|
||||||
const cat = showPlateNumbers.category;
|
const cat = showPlateNumbers.category;
|
||||||
|
|
||||||
// Weekly categories use the dedicated weekly-detail endpoint
|
// Weekly categories use the dedicated weekly-detail endpoint.
|
||||||
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced', Pending: 'pending' };
|
// Pending 不属于 weekly:weekly-detail 不支持 model/batch/location 过滤,
|
||||||
|
// 走下面的 /list 路径才能按型号/区域等维度过滤。
|
||||||
|
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced' };
|
||||||
if (cat && weeklyTypes[cat]) {
|
if (cat && weeklyTypes[cat]) {
|
||||||
setModalVehicles([]);
|
setModalVehicles([]);
|
||||||
fetchWeeklyDetail(weeklyTypes[cat])
|
fetchWeeklyDetail(weeklyTypes[cat], {
|
||||||
|
model: showPlateNumbers.model,
|
||||||
|
batch: showPlateNumbers.batch,
|
||||||
|
location: showPlateNumbers.location,
|
||||||
|
source: showPlateNumbers.source,
|
||||||
|
})
|
||||||
.then(setModalWeeklyDetail)
|
.then(setModalWeeklyDetail)
|
||||||
.catch(() => setModalWeeklyDetail([]))
|
.catch(() => setModalWeeklyDetail([]))
|
||||||
.finally(() => setModalLoading(false));
|
.finally(() => setModalLoading(false));
|
||||||
@@ -241,8 +248,10 @@ export default function AssetsModule() {
|
|||||||
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
|
||||||
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
|
||||||
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
|
||||||
|
if (showPlateNumbers.source) params.source = showPlateNumbers.source;
|
||||||
if (cat === 'Inventory') params.category = 'Inventory';
|
if (cat === 'Inventory') params.category = 'Inventory';
|
||||||
if (cat === 'Operating') params.category = 'Operating';
|
if (cat === 'Operating') params.category = 'Operating';
|
||||||
|
if (cat === 'Pending') params.category = 'Pending';
|
||||||
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
|
||||||
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
|
||||||
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
|
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export async function fetchVehicleList(params: {
|
|||||||
department?: string;
|
department?: string;
|
||||||
attendance?: string;
|
attendance?: string;
|
||||||
subject?: string | null;
|
subject?: string | null;
|
||||||
|
source?: string;
|
||||||
}): Promise<VehicleListItem[]> {
|
}): Promise<VehicleListItem[]> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params.batch) query.set('batch', params.batch);
|
if (params.batch) query.set('batch', params.batch);
|
||||||
@@ -65,6 +66,7 @@ export async function fetchVehicleList(params: {
|
|||||||
if (params.department) query.set('department', params.department);
|
if (params.department) query.set('department', params.department);
|
||||||
if (params.attendance) query.set('attendance', params.attendance);
|
if (params.attendance) query.set('attendance', params.attendance);
|
||||||
if (params.subject) query.set('subject', params.subject);
|
if (params.subject) query.set('subject', params.subject);
|
||||||
|
if (params.source) query.set('source', params.source);
|
||||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +114,14 @@ export async function fetchRegionChart(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
export async function fetchWeeklyDetail(
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
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()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/modules/energy/ElectricDaily.tsx
Normal file
127
src/modules/energy/ElectricDaily.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import TrendBadge from './TrendBadge';
|
||||||
|
import { fetchElectricMonthly } from './api';
|
||||||
|
import type { CustomerType, ElectricMonthGroup } from './types';
|
||||||
|
|
||||||
|
export default function ElectricDaily() {
|
||||||
|
const [customer, setCustomer] = useState<CustomerType>('external');
|
||||||
|
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)
|
||||||
|
.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]);
|
||||||
|
|
||||||
|
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(m) ? next.delete(m) : next.add(m);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* 客户类型 */}
|
||||||
|
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||||
|
{(['external', 'lingniu'] 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>
|
||||||
|
|
||||||
|
{/* 月份分组表 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||||
|
<span>月份 / 日期</span>
|
||||||
|
<span className="hidden md:block text-right">充电电量</span>
|
||||||
|
<span className="text-right md:hidden">度</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-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 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 className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
|
{m.fee.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-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 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 text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
|
{d.fee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/modules/energy/ElectricOverview.tsx
Normal file
109
src/modules/energy/ElectricOverview.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Wallet, BatteryCharging, CalendarClock } from 'lucide-react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||||
|
import TrendBadge from './TrendBadge';
|
||||||
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 横向 mini KPI 头 */}
|
||||||
|
<div className="grid grid-cols-3 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 className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 relative">
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||||
|
<BatteryCharging size={11} className="text-blue-600" />今日
|
||||||
|
</div>
|
||||||
|
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.todayFee)}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 font-bold mt-0.5 whitespace-nowrap">{fmtKwh(k.todayKwh)}</div>
|
||||||
|
<TrendBadge value={k.todayChainPct} className="absolute top-2 right-2 md:top-3 md:right-3" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/modules/energy/ElectricView.tsx
Normal file
14
src/modules/energy/ElectricView.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import ElectricOverview from './ElectricOverview';
|
||||||
|
import ElectricDaily from './ElectricDaily';
|
||||||
|
|
||||||
|
export default function ElectricView() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||||
|
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||||
|
</div>
|
||||||
|
<ElectricOverview />
|
||||||
|
<ElectricDaily />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/modules/energy/EnergyModule.tsx
Normal file
40
src/modules/energy/EnergyModule.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Fuel, BatteryCharging } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import HydrogenView from './HydrogenView';
|
||||||
|
import ElectricView from './ElectricView';
|
||||||
|
|
||||||
|
type TopTab = 'hydrogen' | 'electric';
|
||||||
|
|
||||||
|
export default function EnergyModule() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
||||||
|
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('hydrogen')}
|
||||||
|
className={`flex items-center gap-2 py-1 transition-all relative ${activeTab === 'hydrogen' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||||
|
>
|
||||||
|
<Fuel size={14} />
|
||||||
|
<span className="text-[11px] font-bold">氢能</span>
|
||||||
|
{activeTab === 'hydrogen' && (
|
||||||
|
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('electric')}
|
||||||
|
className={`flex items-center gap-2 py-1 transition-all relative ${activeTab === 'electric' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||||
|
>
|
||||||
|
<BatteryCharging size={14} />
|
||||||
|
<span className="text-[11px] font-bold">电能</span>
|
||||||
|
{activeTab === 'electric' && (
|
||||||
|
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'hydrogen' ? <HydrogenView /> : <ElectricView />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/modules/energy/HydrogenDaily.tsx
Normal file
198
src/modules/energy/HydrogenDaily.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronRight } 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';
|
||||||
|
|
||||||
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
|
{ id: 'today', label: '当天' },
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'thisQuarter', label: '本季度' },
|
||||||
|
{ id: 'last7', label: '最近7天' },
|
||||||
|
{ id: 'last30', label: '最近30天' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HydrogenDaily() {
|
||||||
|
const [pick, setPick] = useState<DateQuickPick>('last30');
|
||||||
|
const [customer, setCustomer] = useState<CustomerType>('external');
|
||||||
|
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">
|
||||||
|
{(['external', 'lingniu'] 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>
|
||||||
|
|
||||||
|
{/* 时段加氢量柱图 */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
{/* 表头 */}
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||||
|
<span>日期 / 加氢站带价格</span>
|
||||||
|
<span className="hidden md:block text-right">单价</span>
|
||||||
|
<span className="text-right">加氢量(Kg)</span>
|
||||||
|
<span className="text-right">环比</span>
|
||||||
|
</div>
|
||||||
|
{/* 合计行 */}
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 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-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 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-400">—</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-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 pl-9 md:pl-9 border-t border-slate-100 first:border-t-0"
|
||||||
|
>
|
||||||
|
<span className="text-[12px] text-slate-600 truncate">
|
||||||
|
{s.name}
|
||||||
|
<span className="md:hidden text-slate-400"> · {s.pricePerKg} 元/Kg</span>
|
||||||
|
</span>
|
||||||
|
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold">{s.pricePerKg} 元/Kg</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/modules/energy/HydrogenOverview.tsx
Normal file
206
src/modules/energy/HydrogenOverview.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Fuel, Wallet, Coins, CalendarClock } from 'lucide-react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
|
||||||
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
|
|
||||||
|
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={-158} cy={0} r={9} fill="#3b82f6" />
|
||||||
|
<text x={-158} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||||
|
{index + 1}
|
||||||
|
</text>
|
||||||
|
<text x={-144} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||||
|
{payload?.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGION_COLORS = [
|
||||||
|
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||||
|
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||||
|
'#94a3b8',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtKg(kg: number) {
|
||||||
|
if (kg >= 1000) return `${(kg / 1000).toFixed(2)}T`;
|
||||||
|
return `${kg.toFixed(2)}Kg`;
|
||||||
|
}
|
||||||
|
function fmtYuanWan(yuan: number) {
|
||||||
|
return `¥${(yuan / 10_000).toFixed(2)}万`;
|
||||||
|
}
|
||||||
|
function fmtYuan(yuan: number) {
|
||||||
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HydrogenOverview() {
|
||||||
|
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchHydrogenOverview()
|
||||||
|
.then(d => { if (!cancelled) setData(d); })
|
||||||
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return <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 top5 = data.top5;
|
||||||
|
const regions = data.regions;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||||
|
数据自 2025-01-01 起,每 1 分钟更新
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{/* 卡 1:年加氢量 */}
|
||||||
|
<div className="bg-gradient-to-br from-cyan-50 to-blue-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span className="flex items-center gap-1 font-bold"><Fuel size={12} className="text-cyan-600" />年加氢量</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl lg:text-3xl font-bold text-slate-800 leading-tight">{fmtKg(k.yearKg)}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 font-bold space-y-0.5">
|
||||||
|
<div>我方 <span className="text-slate-700">{fmtKg(k.ourYearKg)}</span></div>
|
||||||
|
<div>客户产生 <span className="text-slate-700">{fmtKg(k.customerYearKg)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 卡 2:年加氢费 */}
|
||||||
|
<div className="bg-gradient-to-br from-blue-50 to-violet-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span className="flex items-center gap-1 font-bold"><Wallet size={12} className="text-blue-600" />年加氢费</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl lg:text-3xl font-bold text-slate-800 leading-tight">{fmtYuanWan(k.yearFee)}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 font-bold">
|
||||||
|
<div>我方 <span className="text-slate-700">{fmtYuanWan(k.ourYearFee)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 卡 3:累计羚牛承担 */}
|
||||||
|
<div className="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span className="flex items-center gap-1 font-bold"><Coins size={12} className="text-amber-600" />累计羚牛承担</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl lg:text-3xl font-bold text-slate-800 leading-tight">{fmtYuanWan(k.lingniuBornFee)}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 font-bold space-y-0.5">
|
||||||
|
<div>量 <span className="text-slate-700">{fmtKg(k.lingniuBornKg)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 卡 4:本月 / 今日 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span className="flex items-center gap-1 font-bold"><CalendarClock size={12} className="text-slate-500" />本月 / 今日</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">本月</div>
|
||||||
|
<div className="text-base md:text-lg font-bold text-slate-800">{fmtKg(k.monthKg)}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 font-bold">{fmtYuanWan(k.monthFee)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">今日</div>
|
||||||
|
<div className="text-base md:text-lg font-bold text-slate-800">{fmtKg(k.todayKg)}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 font-bold">{fmtYuan(k.todayFee)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{/* Top5 加氢站 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 0 }}>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={170}
|
||||||
|
tick={<RankYAxisTick />}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||||
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||||
|
{top5.map((_, i) => (
|
||||||
|
<Cell key={i} fill={`url(#topBarGrad)`} />
|
||||||
|
))}
|
||||||
|
<LabelList
|
||||||
|
dataKey="kg"
|
||||||
|
position="right"
|
||||||
|
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||||
|
fill="#475569"
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={700}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" />
|
||||||
|
<stop offset="100%" stopColor="#22d3ee" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{/* 区域占比环 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative w-1/2 h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={regions}
|
||||||
|
dataKey="kg"
|
||||||
|
nameKey="region"
|
||||||
|
innerRadius={48}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
>
|
||||||
|
{regions.map((_, i) => (
|
||||||
|
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||||
|
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||||
|
{regions.map((r, i) => (
|
||||||
|
<div key={r.region} className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||||
|
<span className="text-slate-600">{r.region}</span>
|
||||||
|
<span className="text-slate-400 ml-auto font-bold">{(r.share * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/modules/energy/HydrogenView.tsx
Normal file
37
src/modules/energy/HydrogenView.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import HydrogenOverview from './HydrogenOverview';
|
||||||
|
import HydrogenDaily from './HydrogenDaily';
|
||||||
|
|
||||||
|
type SubTab = 'daily' | 'overview';
|
||||||
|
|
||||||
|
const SUB_TABS: Array<{ id: SubTab; label: string; icon: typeof LayoutDashboard }> = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HydrogenView() {
|
||||||
|
const [sub, setSub] = useState<SubTab>('daily');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-1 sticky top-[58px] z-20 flex gap-1">
|
||||||
|
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
||||||
|
const active = sub === 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>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/modules/energy/api.ts
Normal file
37
src/modules/energy/api.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
import type {
|
||||||
|
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenDailyRow,
|
||||||
|
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||||
|
CustomerType, DateQuickPick,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const BASE = '/api/energy';
|
||||||
|
|
||||||
|
export interface HydrogenOverviewResponse {
|
||||||
|
kpi: HydrogenKpi;
|
||||||
|
top5: HydrogenStationTop[];
|
||||||
|
regions: HydrogenRegionShare[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenOverview(): Promise<HydrogenOverviewResponse> {
|
||||||
|
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||||
|
const q = new URLSearchParams({ range, customer });
|
||||||
|
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricOverviewResponse {
|
||||||
|
kpi: ElectricKpi;
|
||||||
|
trend: ElectricDailyRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||||
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchElectricMonthly(customer: CustomerType): Promise<ElectricMonthGroup[]> {
|
||||||
|
const q = new URLSearchParams({ customer });
|
||||||
|
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||||
|
}
|
||||||
69
src/modules/energy/types.ts
Normal file
69
src/modules/energy/types.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export type CustomerType = 'external' | 'lingniu';
|
||||||
|
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
||||||
|
|
||||||
|
export interface HydrogenKpi {
|
||||||
|
yearKg: number;
|
||||||
|
yearFee: number;
|
||||||
|
ourYearKg: number;
|
||||||
|
ourYearFee: number;
|
||||||
|
customerYearKg: number;
|
||||||
|
monthKg: number;
|
||||||
|
monthFee: number;
|
||||||
|
todayKg: number;
|
||||||
|
todayFee: number;
|
||||||
|
lingniuBornKg: number;
|
||||||
|
lingniuBornFee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenStationTop {
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
kg: number;
|
||||||
|
fee: number;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenRegionShare {
|
||||||
|
region: string;
|
||||||
|
kg: number;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenStationRow {
|
||||||
|
name: string;
|
||||||
|
pricePerKg: number;
|
||||||
|
kg: number;
|
||||||
|
chainPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenDailyRow {
|
||||||
|
date: string;
|
||||||
|
totalKg: number;
|
||||||
|
chainPct: number;
|
||||||
|
customerType: CustomerType;
|
||||||
|
stations: HydrogenStationRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricKpi {
|
||||||
|
totalKwh: number;
|
||||||
|
totalFee: number;
|
||||||
|
monthKwh: number;
|
||||||
|
monthFee: number;
|
||||||
|
todayKwh: number;
|
||||||
|
todayFee: number;
|
||||||
|
todayChainPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricDailyRow {
|
||||||
|
date: string;
|
||||||
|
kwh: number;
|
||||||
|
fee: number;
|
||||||
|
chainPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricMonthGroup {
|
||||||
|
month: string;
|
||||||
|
kwh: number;
|
||||||
|
fee: number;
|
||||||
|
rows: ElectricDailyRow[];
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
Truck, Search, Filter, ChevronDown,
|
Truck, Filter, ChevronDown,
|
||||||
Maximize2, Minimize2, RotateCcw,
|
Maximize2, Minimize2, RotateCcw,
|
||||||
ArrowUp, ArrowDown, ChevronsUp,
|
ArrowUp, ArrowDown, ChevronsUp, Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||||
import { fetchMonitoring } from './api';
|
import { fetchMonitoring } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import PlateMultiSelect from './PlateMultiSelect';
|
||||||
|
import { exportMileageXlsx } from './xlsx-export';
|
||||||
|
|
||||||
const SearchableSelect = ({
|
const SearchableSelect = ({
|
||||||
options,
|
options,
|
||||||
@@ -102,15 +104,17 @@ export default function MonitoringView() {
|
|||||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||||
|
|
||||||
// New filters from image
|
// New filters from image
|
||||||
const [filterPlate, setFilterPlate] = useState('All');
|
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
||||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||||
const [filterProject, setFilterProject] = useState('All');
|
const [filterProject, setFilterProject] = useState('All');
|
||||||
const [filterEntity, setFilterEntity] = useState('All');
|
const [filterEntity, setFilterEntity] = useState('All');
|
||||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||||
|
const [filterRegion, setFilterRegion] = useState('All');
|
||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [filterDate, setFilterDate] = useState(() => {
|
const [filterDate, setFilterDate] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||||
@@ -119,7 +123,7 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] });
|
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
@@ -147,7 +151,8 @@ export default function MonitoringView() {
|
|||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||||
|
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
date: filterDate || undefined,
|
date: filterDate || undefined,
|
||||||
@@ -159,7 +164,7 @@ export default function MonitoringView() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(d.page < d.totalPages);
|
setHasMore(d.page < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
|
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -179,7 +184,8 @@ export default function MonitoringView() {
|
|||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||||
|
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
date: filterDate || undefined,
|
date: filterDate || undefined,
|
||||||
@@ -188,13 +194,45 @@ export default function MonitoringView() {
|
|||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setHasMore(nextPage < d.totalPages);
|
setHasMore(nextPage < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||||
|
|
||||||
// 筛选/排序变化时重新加载
|
// 筛选/排序变化时重新加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFirstPage();
|
loadFirstPage();
|
||||||
}, [loadFirstPage]);
|
}, [loadFirstPage]);
|
||||||
|
|
||||||
|
// 下载当前筛选结果为 xlsx
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (exporting) return;
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const d = await fetchMonitoring({
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
limit: 9999,
|
||||||
|
page: 1,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||||
|
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||||
|
project: filterProject !== 'All' ? filterProject : undefined,
|
||||||
|
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||||
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
|
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||||
|
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||||
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
|
date: filterDate || undefined,
|
||||||
|
});
|
||||||
|
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('export failed', err);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||||
|
|
||||||
// 每分钟自动刷新
|
// 每分钟自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||||
@@ -260,14 +298,15 @@ export default function MonitoringView() {
|
|||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||||
|
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||||
date: filterDate || undefined,
|
date: filterDate || undefined,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]);
|
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
||||||
|
|
||||||
// 全屏时禁止背景滚动
|
// 全屏时禁止背景滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -391,14 +430,9 @@ export default function MonitoringView() {
|
|||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span>车牌号</span>
|
<span>车牌号</span>
|
||||||
<select
|
<span className="text-[9px] text-slate-500 font-normal">
|
||||||
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"
|
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
|
||||||
value={filterPlate}
|
</span>
|
||||||
onChange={(e) => setFilterPlate(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="All">全部车牌</option>
|
|
||||||
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
@@ -526,6 +560,14 @@ export default function MonitoringView() {
|
|||||||
>
|
>
|
||||||
<Maximize2 size={14} />
|
<Maximize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={exporting}
|
||||||
|
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
|
||||||
|
title="下载当前筛选结果"
|
||||||
|
>
|
||||||
|
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||||
@@ -559,32 +601,32 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
|
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={['__EMPTY__', ...departments]}
|
options={filterOptions.targetNames}
|
||||||
value={filterDept}
|
value={filterTargetName}
|
||||||
onChange={setFilterDept}
|
onChange={setFilterTargetName}
|
||||||
placeholder="按部门"
|
placeholder="批次型号"
|
||||||
/>
|
/>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={['__EMPTY__', ...filterOptions.customers]}
|
options={filterOptions.regions}
|
||||||
value={filterCustomer}
|
value={filterRegion}
|
||||||
onChange={setFilterCustomer}
|
onChange={setFilterRegion}
|
||||||
placeholder="按客户"
|
placeholder="运营区域"
|
||||||
/>
|
/>
|
||||||
<SearchableSelect
|
<PlateMultiSelect
|
||||||
options={plateNumbers}
|
allPlates={plateNumbers}
|
||||||
value={filterPlate}
|
selected={filterPlates}
|
||||||
onChange={setFilterPlate}
|
onChange={setFilterPlates}
|
||||||
placeholder="按车牌"
|
placeholder="按车牌"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || 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} />
|
<Filter size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -612,6 +654,37 @@ export default function MonitoringView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Project */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">项目</label>
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">项目</label>
|
||||||
@@ -625,21 +698,6 @@ export default function MonitoringView() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Rent Status */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">租赁状态</label>
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">租赁状态</label>
|
||||||
@@ -669,19 +727,6 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Target Name */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">型号批次</label>
|
|
||||||
<select
|
|
||||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
|
||||||
value={filterTargetName}
|
|
||||||
onChange={(e) => setFilterTargetName(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="All">无限制</option>
|
|
||||||
{filterOptions.targetNames.map(n => <option key={n} value={n}>{n}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plate Prefix */}
|
{/* Plate Prefix */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">车牌区域</label>
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">车牌区域</label>
|
||||||
@@ -724,11 +769,13 @@ export default function MonitoringView() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
setFilterDept('All');
|
setFilterDept('All');
|
||||||
setFilterPlate('All');
|
setFilterPlates([]);
|
||||||
setFilterCustomer('All');
|
setFilterCustomer('All');
|
||||||
setFilterProject('All');
|
setFilterProject('All');
|
||||||
setFilterEntity('All');
|
setFilterEntity('All');
|
||||||
setFilterPlatePrefix('All');
|
setFilterPlatePrefix('All');
|
||||||
|
setFilterTargetName('All');
|
||||||
|
setFilterRegion('All');
|
||||||
setFilterMileageRange({ min: '', max: '' });
|
setFilterMileageRange({ min: '', max: '' });
|
||||||
setAppliedMileageRange({ min: '', max: '' });
|
setAppliedMileageRange({ min: '', max: '' });
|
||||||
}}
|
}}
|
||||||
@@ -754,22 +801,23 @@ export default function MonitoringView() {
|
|||||||
{/* Active Filter Tags */}
|
{/* Active Filter Tags */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const tags: { label: string; onClear: () => void }[] = [];
|
const tags: { label: string; onClear: () => void }[] = [];
|
||||||
|
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||||||
|
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
|
||||||
|
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
|
||||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
||||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
||||||
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
|
|
||||||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
|
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
|
||||||
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
|
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
|
||||||
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
|
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
|
||||||
if (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 (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||||||
if (tags.length === 0) return null;
|
if (tags.length === 0) return null;
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||||
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
|
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
|
||||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||||
setFilterDate('');
|
setFilterDate('');
|
||||||
};
|
};
|
||||||
|
|||||||
198
src/modules/mileage/PlateMultiSelect.tsx
Normal file
198
src/modules/mileage/PlateMultiSelect.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl w-[280px] max-w-[calc(100vw-32px)]"
|
||||||
|
style={{ minWidth: '100%' }}
|
||||||
|
>
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const pasted = e.clipboardData.getData('text');
|
||||||
|
if (pasted && /[\s,;,;、]/.test(pasted)) {
|
||||||
|
e.preventDefault();
|
||||||
|
apply(text + (text ? ' ' : '') + pasted);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export async function fetchMonitoring(params?: {
|
|||||||
rentStatus?: string;
|
rentStatus?: string;
|
||||||
platePrefix?: string;
|
platePrefix?: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
|
region?: string;
|
||||||
plate?: string;
|
plate?: string;
|
||||||
mileageMin?: string;
|
mileageMin?: string;
|
||||||
mileageMax?: string;
|
mileageMax?: string;
|
||||||
@@ -34,6 +35,7 @@ export async function fetchMonitoring(params?: {
|
|||||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||||
if (params?.targetName) query.set('targetName', params.targetName);
|
if (params?.targetName) query.set('targetName', params.targetName);
|
||||||
|
if (params?.region) query.set('region', params.region);
|
||||||
if (params?.plate) query.set('plate', params.plate);
|
if (params?.plate) query.set('plate', params.plate);
|
||||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface MonitoringVehicle {
|
|||||||
rentStatus: string | null;
|
rentStatus: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
|
region: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringStats {
|
export interface MonitoringStats {
|
||||||
@@ -30,6 +31,7 @@ export interface MonitoringFilters {
|
|||||||
rentStatuses: string[];
|
rentStatuses: string[];
|
||||||
platePrefixes: { prefix: string; count: number }[];
|
platePrefixes: { prefix: string; count: number }[];
|
||||||
targetNames: string[];
|
targetNames: string[];
|
||||||
|
regions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringData {
|
export interface MonitoringData {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import type { JwtPayload, AuthUser } from './types.js';
|
|||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
|
||||||
// 演示分支:跳过所有认证(保留完整逻辑便于快速恢复)
|
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||||
const BYPASS_AUTH = true;
|
const BYPASS_AUTH = false;
|
||||||
|
|
||||||
export async function authMiddleware(c: Context, next: Next) {
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
const path = c.req.path;
|
const path = c.req.path;
|
||||||
@@ -14,6 +14,21 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
|
||||||
|
if (process.env.DEV_BYPASS_AUTH === '1') {
|
||||||
|
const devUser: AuthUser = {
|
||||||
|
userId: 'dev-local',
|
||||||
|
userName: '本地开发',
|
||||||
|
loginName: 'dev-local',
|
||||||
|
depCode: '',
|
||||||
|
depName: '',
|
||||||
|
permissionLevel: 'full',
|
||||||
|
roles: ['所有权限', 'BI-SCHEDULE-OPT'],
|
||||||
|
};
|
||||||
|
c.set('user', devUser);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// 跳过不需要认证的路径
|
// 跳过不需要认证的路径
|
||||||
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dotenv from 'dotenv';
|
|||||||
import vehiclesRouter from './routes/vehicles.js';
|
import vehiclesRouter from './routes/vehicles.js';
|
||||||
import mileageRouter from './routes/mileage/index.js';
|
import mileageRouter from './routes/mileage/index.js';
|
||||||
import schedulingRouter from './routes/scheduling/index.js';
|
import schedulingRouter from './routes/scheduling/index.js';
|
||||||
|
import energyRouter from './routes/energy/index.js';
|
||||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -25,6 +26,7 @@ app.use('/api/*', authMiddleware);
|
|||||||
app.route('/api/vehicles', vehiclesRouter);
|
app.route('/api/vehicles', vehiclesRouter);
|
||||||
app.route('/api/mileage', mileageRouter);
|
app.route('/api/mileage', mileageRouter);
|
||||||
app.route('/api/scheduling', schedulingRouter);
|
app.route('/api/scheduling', schedulingRouter);
|
||||||
|
app.route('/api/energy', energyRouter);
|
||||||
|
|
||||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
44
src/server/routes/energy/cache.ts
Normal file
44
src/server/routes/energy/cache.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 简单 TTL 内存缓存。
|
||||||
|
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。
|
||||||
|
* 同一 key 并发请求只会触发一次 loader(共享 in-flight Promise)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Entry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const cache = new Map<string, Entry<unknown>>();
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && hit.expiresAt > now) {
|
||||||
|
return hit.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一 key 并发只跑一次 loader
|
||||||
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
|
if (ongoing) return ongoing;
|
||||||
|
|
||||||
|
const p = loader()
|
||||||
|
.then(value => {
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + TTL_MS });
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
});
|
||||||
|
inflight.set(key, p as Promise<unknown>);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅用于测试或调试:清空所有缓存 */
|
||||||
|
export function _clearEnergyCache() {
|
||||||
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
}
|
||||||
405
src/server/routes/energy/index.ts
Normal file
405
src/server/routes/energy/index.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../../db.js';
|
||||||
|
import { cached } from './cache.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
|
// 把 DATETIME (UTC 字面值) 转换为 CST 用户日期
|
||||||
|
const HYDROGEN_LOCAL = `DATE_ADD(hydrogen_time, INTERVAL 8 HOUR)`;
|
||||||
|
const ELECTRIC_LOCAL = `DATE_ADD(charging_start_time, INTERVAL 8 HOUR)`;
|
||||||
|
|
||||||
|
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||||
|
|
||||||
|
function customerClause(field: string, customer: CustomerKind): string {
|
||||||
|
if (customer === 'lingniu') return `${field} IS NULL`;
|
||||||
|
if (customer === 'external') return `${field} IS NOT NULL`;
|
||||||
|
return '1=1';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Range = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
||||||
|
|
||||||
|
function rangeClause(localExpr: string, range: Range): string {
|
||||||
|
switch (range) {
|
||||||
|
case 'today': return `DATE(${localExpr}) = CURDATE()`;
|
||||||
|
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||||||
|
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||||||
|
case 'thisQuarter': return `YEAR(${localExpr}) = YEAR(CURDATE()) AND QUARTER(${localExpr}) = QUARTER(CURDATE())`;
|
||||||
|
case 'last7': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND CURDATE()`;
|
||||||
|
case 'last30': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 29 DAY) AND CURDATE()`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 氢能 总览:KPI + Top5 + 区域占比
|
||||||
|
// =========================================================
|
||||||
|
app.get('/hydrogen/overview', async (c) => {
|
||||||
|
const data = await cached('hydrogen/overview', async () => {
|
||||||
|
// KPI(年/月/日 + 我方/客户分解 + 累计羚牛承担)
|
||||||
|
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
||||||
|
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NULL
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NULL
|
||||||
|
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NOT NULL
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||||
|
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||||||
|
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||||
|
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
||||||
|
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
|
THEN cost_expense ELSE 0 END) AS todayFee,
|
||||||
|
SUM(CASE WHEN customer_id IS NULL
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||||
|
SUM(CASE WHEN customer_id IS NULL
|
||||||
|
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||||
|
FROM tab_energy_hydrogen_bill
|
||||||
|
WHERE is_deleted = 0 AND hydrogen_time >= ?`,
|
||||||
|
[HYDROGEN_MIN_DATE],
|
||||||
|
);
|
||||||
|
const k = kpiRows[0] ?? {};
|
||||||
|
const kpi = {
|
||||||
|
yearKg: Number(k.yearKg) || 0,
|
||||||
|
yearFee: Number(k.yearFee) || 0,
|
||||||
|
ourYearKg: Number(k.ourYearKg) || 0,
|
||||||
|
ourYearFee: Number(k.ourYearFee) || 0,
|
||||||
|
customerYearKg: Number(k.customerYearKg) || 0,
|
||||||
|
monthKg: Number(k.monthKg) || 0,
|
||||||
|
monthFee: Number(k.monthFee) || 0,
|
||||||
|
todayKg: Number(k.todayKg) || 0,
|
||||||
|
todayFee: Number(k.todayFee) || 0,
|
||||||
|
lingniuBornKg: Number(k.lingniuBornKg) || 0,
|
||||||
|
lingniuBornFee: Number(k.lingniuBornFee) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top5 加氢站(本年)
|
||||||
|
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT b.hydrogen_station_id AS id,
|
||||||
|
COALESCE(s.short_name, s.name, os.fixed_station_name, os.station_name, '未知站点') 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
|
||||||
|
WHERE b.is_deleted = 0
|
||||||
|
AND b.hydrogen_time >= ?
|
||||||
|
AND YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
||||||
|
GROUP BY b.hydrogen_station_id
|
||||||
|
ORDER BY kg DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
[HYDROGEN_MIN_DATE],
|
||||||
|
);
|
||||||
|
const top5KgSum = kpi.yearKg || 1;
|
||||||
|
const top5 = top5Rows.map((r, i) => ({
|
||||||
|
rank: i + 1,
|
||||||
|
name: r.name as string,
|
||||||
|
kg: Number(r.kg) || 0,
|
||||||
|
fee: Number(r.fee) || 0,
|
||||||
|
share: (Number(r.kg) || 0) / top5KgSum,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 区域占比(按城市,本年)— 取前 8,其余合并为"其他"
|
||||||
|
const [regionRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT region, SUM(kg) AS kg FROM (
|
||||||
|
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
||||||
|
b.hydrogen_quantity AS kg
|
||||||
|
FROM tab_energy_hydrogen_bill b
|
||||||
|
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||||
|
WHERE b.is_deleted = 0
|
||||||
|
AND b.hydrogen_time >= ?
|
||||||
|
AND YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
||||||
|
) r
|
||||||
|
GROUP BY region
|
||||||
|
ORDER BY kg DESC`,
|
||||||
|
[HYDROGEN_MIN_DATE],
|
||||||
|
);
|
||||||
|
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
|
||||||
|
const TOP_REGIONS = 8;
|
||||||
|
const top = regionRows.slice(0, TOP_REGIONS);
|
||||||
|
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
|
||||||
|
const regions = [
|
||||||
|
...top.map(r => ({
|
||||||
|
region: r.region as string,
|
||||||
|
kg: Number(r.kg) || 0,
|
||||||
|
share: (Number(r.kg) || 0) / totalKg,
|
||||||
|
})),
|
||||||
|
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { kpi, top5, regions };
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
||||||
|
// =========================================================
|
||||||
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
|
const range = (c.req.query('range') || 'last30') as Range;
|
||||||
|
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||||
|
|
||||||
|
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||||
|
|
||||||
|
const where = [
|
||||||
|
'b.is_deleted = 0',
|
||||||
|
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
|
rangeClause(`b.hydrogen_time + INTERVAL 8 HOUR`, range),
|
||||||
|
customerClause('b.customer_id', customer),
|
||||||
|
].join(' AND ');
|
||||||
|
|
||||||
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
|
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||||
|
b.hydrogen_station_id AS stationId,
|
||||||
|
COALESCE(s.short_name, s.name, os.fixed_station_name, os.station_name, '未知站点') AS stationName,
|
||||||
|
SUM(b.hydrogen_quantity) AS kg,
|
||||||
|
AVG(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
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装为 HydrogenDailyRow[],按日期降序
|
||||||
|
const result = Array.from(dayMap.entries())
|
||||||
|
.map(([date, info]) => ({
|
||||||
|
date,
|
||||||
|
totalKg: Math.round(info.totalKg * 100) / 100,
|
||||||
|
chainPct: dayChainPct.get(date) ?? 0,
|
||||||
|
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||||
|
stations: 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,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 电能 总览:KPI + 本月每日柱图数据
|
||||||
|
// =========================================================
|
||||||
|
app.get('/electric/overview', async (c) => {
|
||||||
|
const data = await cached('electric/overview', async () => {
|
||||||
|
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT
|
||||||
|
SUM(charging_degree) AS totalKwh,
|
||||||
|
SUM(cost_expense) AS totalFee,
|
||||||
|
SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN charging_degree ELSE 0 END) AS monthKwh,
|
||||||
|
SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||||
|
SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE()
|
||||||
|
THEN charging_degree ELSE 0 END) AS todayKwh,
|
||||||
|
SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE()
|
||||||
|
THEN cost_expense ELSE 0 END) AS todayFee
|
||||||
|
FROM tab_energy_electricity_bill
|
||||||
|
WHERE is_deleted = 0`,
|
||||||
|
);
|
||||||
|
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(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
||||||
|
SUM(charging_degree) AS kwh,
|
||||||
|
SUM(cost_expense) AS fee
|
||||||
|
FROM tab_energy_electricity_bill
|
||||||
|
WHERE is_deleted = 0
|
||||||
|
AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%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(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
||||||
|
SUM(charging_degree) AS kwh,
|
||||||
|
SUM(cost_expense) AS fee
|
||||||
|
FROM tab_energy_electricity_bill
|
||||||
|
WHERE is_deleted = 0
|
||||||
|
AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = (
|
||||||
|
SELECT DATE_FORMAT(MAX(${ELECTRIC_LOCAL}), '%Y-%m')
|
||||||
|
FROM tab_energy_electricity_bill
|
||||||
|
WHERE is_deleted = 0
|
||||||
|
)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日环比 = 今日 kwh / 上一个有数据的自然日 kwh - 1
|
||||||
|
let todayChainPct = 0;
|
||||||
|
if (todayKwh > 0) {
|
||||||
|
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT SUM(charging_degree) AS kwh
|
||||||
|
FROM tab_energy_electricity_bill
|
||||||
|
WHERE is_deleted = 0
|
||||||
|
AND DATE(${ELECTRIC_LOCAL}) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||||||
|
);
|
||||||
|
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||||||
|
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||||
|
trend: trendArr,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 电能 每日:月份分组 + 日级行
|
||||||
|
// =========================================================
|
||||||
|
app.get('/electric/monthly', async (c) => {
|
||||||
|
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||||
|
|
||||||
|
const data = await cached(`electric/monthly?customer=${customer}`, async () => {
|
||||||
|
|
||||||
|
const where = [
|
||||||
|
'is_deleted = 0',
|
||||||
|
customerClause('customer_id', customer),
|
||||||
|
].join(' AND ');
|
||||||
|
|
||||||
|
// 取最近 6 个月
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') AS month,
|
||||||
|
DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
||||||
|
SUM(charging_degree) AS kwh,
|
||||||
|
SUM(cost_expense) AS fee
|
||||||
|
FROM tab_energy_electricity_bill
|
||||||
|
WHERE ${where}
|
||||||
|
AND ${ELECTRIC_LOCAL} >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||||
|
GROUP BY month, date
|
||||||
|
ORDER BY date DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组装 month group with daily rows + chainPct
|
||||||
|
const monthMap = new Map<string, Array<{ date: string; kwh: number; fee: number }>>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const m = r.month as string;
|
||||||
|
if (!monthMap.has(m)) monthMap.set(m, []);
|
||||||
|
monthMap.get(m)!.push({
|
||||||
|
date: r.date as string,
|
||||||
|
kwh: Number(r.kwh) || 0,
|
||||||
|
fee: Number(r.fee) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = Array.from(monthMap.entries())
|
||||||
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
|
.map(([month, daysDesc]) => {
|
||||||
|
// 计算环比:daysDesc 是 DESC,需要按 ASC 算
|
||||||
|
const asc = [...daysDesc].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
const chain = new Map<string, number>();
|
||||||
|
for (let i = 1; i < asc.length; i++) {
|
||||||
|
const prev = asc[i - 1].kwh;
|
||||||
|
chain.set(asc[i].date, prev > 0 ? (asc[i].kwh - prev) / prev : 0);
|
||||||
|
}
|
||||||
|
const rowsWithChain = daysDesc.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
kwh: Math.round(d.kwh * 100) / 100,
|
||||||
|
fee: Math.round(d.fee * 100) / 100,
|
||||||
|
chainPct: chain.get(d.date) ?? 0,
|
||||||
|
}));
|
||||||
|
const kwhSum = daysDesc.reduce((s, d) => s + d.kwh, 0);
|
||||||
|
const feeSum = daysDesc.reduce((s, d) => s + d.fee, 0);
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
kwh: Math.round(kwhSum * 100) / 100,
|
||||||
|
fee: Math.round(feeSum * 100) / 100,
|
||||||
|
rows: rowsWithChain,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return months;
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -1,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 pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
import mileagePool from '../../mileage-db.js';
|
||||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const regionMap: Record<string, string> = JSON.parse(
|
||||||
|
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
|
||||||
|
);
|
||||||
|
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
|
||||||
|
|
||||||
let monitoringCache: MonitoringCache | null = null;
|
let monitoringCache: MonitoringCache | null = null;
|
||||||
|
|
||||||
export function getCache(): MonitoringCache | null {
|
export function getCache(): MonitoringCache | null {
|
||||||
@@ -38,7 +47,14 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
|
|||||||
.map(([prefix, count]) => ({ prefix, count }))
|
.map(([prefix, count]) => ({ prefix, count }))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
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 {
|
interface MileageRow {
|
||||||
@@ -99,6 +115,7 @@ function mergeVehicles(
|
|||||||
rentStatus: info?.rent_status || null,
|
rentStatus: info?.rent_status || null,
|
||||||
entity: info?.entity || null,
|
entity: info?.entity || null,
|
||||||
project: info?.project || null,
|
project: info?.project || null,
|
||||||
|
region: regionMap[m.plate] || null,
|
||||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const app = new Hono();
|
|||||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||||
vehicles: [],
|
vehicles: [],
|
||||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
||||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
|
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
|||||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||||
search: string; dept: string; customer: string; project: string;
|
search: string; dept: string; customer: string; project: string;
|
||||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||||
targetName: string; mileageMin: string; mileageMax: string;
|
targetName: string; region: string; mileageMin: string; mileageMax: string;
|
||||||
}): CachedVehicle[] {
|
}): CachedVehicle[] {
|
||||||
let result = vehicles;
|
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.project) result = result.filter(v => v.project === params.project);
|
||||||
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
||||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||||
if (params.plate) 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.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
||||||
|
if (params.region) result = result.filter(v => v.region === params.region);
|
||||||
if (params.targetName) {
|
if (params.targetName) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||||
@@ -66,6 +70,7 @@ app.get('/', async (c) => {
|
|||||||
plate: c.req.query('plate') || '',
|
plate: c.req.query('plate') || '',
|
||||||
platePrefix: c.req.query('platePrefix') || '',
|
platePrefix: c.req.query('platePrefix') || '',
|
||||||
targetName: c.req.query('targetName') || '',
|
targetName: c.req.query('targetName') || '',
|
||||||
|
region: c.req.query('region') || '',
|
||||||
mileageMin: c.req.query('mileageMin') || '',
|
mileageMin: c.req.query('mileageMin') || '',
|
||||||
mileageMax: c.req.query('mileageMax') || '',
|
mileageMax: c.req.query('mileageMax') || '',
|
||||||
};
|
};
|
||||||
|
|||||||
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": "华南区域"
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export interface CachedVehicle {
|
|||||||
rentStatus: string | null;
|
rentStatus: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
|
region: string | null;
|
||||||
yesterdayKm: number;
|
yesterdayKm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export interface MonitoringFilters {
|
|||||||
rentStatuses: string[];
|
rentStatuses: string[];
|
||||||
platePrefixes: PlatePrefix[];
|
platePrefixes: PlatePrefix[];
|
||||||
targetNames: string[];
|
targetNames: string[];
|
||||||
|
regions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 监控缓存 */
|
/** 监控缓存 */
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ interface WeeklyStats {
|
|||||||
// 交车单 SQL
|
// 交车单 SQL
|
||||||
const DELIVERED_SQL = `SELECT
|
const DELIVERED_SQL = `SELECT
|
||||||
take.id, DATE(take.handover_date) AS handover_date,
|
take.id, DATE(take.handover_date) AS handover_date,
|
||||||
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,
|
dic_contract_type.dic_name AS contract_type,
|
||||||
customer.customer_name
|
customer.customer_name
|
||||||
FROM tab_truck_rent_take take
|
FROM tab_truck_rent_take take
|
||||||
@@ -439,7 +439,7 @@ WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
|||||||
// 还车单 SQL
|
// 还车单 SQL
|
||||||
const RETURNED_SQL = `SELECT
|
const RETURNED_SQL = `SELECT
|
||||||
r.id, DATE(r.return_date) AS handover_date,
|
r.id, DATE(r.return_date) AS handover_date,
|
||||||
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,
|
dic_contract_type.dic_name AS contract_type,
|
||||||
customer.customer_name
|
customer.customer_name
|
||||||
FROM tab_truck_rent_return r
|
FROM tab_truck_rent_return r
|
||||||
@@ -457,7 +457,7 @@ WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
|||||||
// 替换车单 SQL
|
// 替换车单 SQL
|
||||||
const REPLACED_SQL = `SELECT
|
const REPLACED_SQL = `SELECT
|
||||||
take.id, DATE(take.handover_date) AS handover_date,
|
take.id, DATE(take.handover_date) AS handover_date,
|
||||||
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,
|
dic_contract_type.dic_name AS contract_type,
|
||||||
customer.customer_name
|
customer.customer_name
|
||||||
FROM tab_truck_rent_take take
|
FROM tab_truck_rent_take take
|
||||||
@@ -880,6 +880,21 @@ app.get('/customer-stats', async (c) => {
|
|||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Location 过滤器:支持展示区域(嘉兴/广东/北京/新疆/其他)、库存区域(江浙沪/其它)、
|
||||||
|
// 城市(嘉兴市)、宏观区域(华东/华南/...)。
|
||||||
|
// '其他' 在两个体系里都存在(资产表的"库存-其他" vs 区域表的"其他"宏观区域),
|
||||||
|
// 用 source 区分:source==='asset' 时按 v.location 匹配,其它情况按宏观区域匹配。
|
||||||
|
function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
|
||||||
|
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||||
|
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
|
||||||
|
if (isMacro) {
|
||||||
|
return vehicles.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||||
|
}
|
||||||
|
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||||
|
const mappedLocation = inventoryRegionMap[location] || location;
|
||||||
|
return vehicles.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||||
|
}
|
||||||
|
|
||||||
// Vehicle type filter map (same logic as /by-type)
|
// Vehicle type filter map (same logic as /by-type)
|
||||||
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
||||||
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
|
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
|
||||||
@@ -925,15 +940,7 @@ app.get('/list', async (c) => {
|
|||||||
filtered = filtered.filter((v) => v.model === model);
|
filtered = filtered.filter((v) => v.model === model);
|
||||||
}
|
}
|
||||||
if (location && location !== 'All') {
|
if (location && location !== 'All') {
|
||||||
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
|
filtered = filterByLocation(filtered, location, c.req.query('source'));
|
||||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
|
||||||
if (macroRegions.includes(location) || location === '其他') {
|
|
||||||
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
|
||||||
} else {
|
|
||||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
|
||||||
const mappedLocation = inventoryRegionMap[location] || location;
|
|
||||||
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (status && status !== 'All') {
|
if (status && status !== 'All') {
|
||||||
filtered = filtered.filter((v) => v.status === status);
|
filtered = filtered.filter((v) => v.status === status);
|
||||||
@@ -943,6 +950,8 @@ app.get('/list', async (c) => {
|
|||||||
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||||
} else if (category === 'Operating') {
|
} else if (category === 'Operating') {
|
||||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||||
|
} else if (category === 'Pending') {
|
||||||
|
filtered = filtered.filter((v) => v.status === 'Pending');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (manager) {
|
if (manager) {
|
||||||
@@ -1023,8 +1032,11 @@ app.get('/inventory-stats', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||||
|
// Optional filters: model, batch, location, source — 按缓存车辆集合的 truck_id 交集过滤
|
||||||
app.get('/weekly-detail', async (c) => {
|
app.get('/weekly-detail', async (c) => {
|
||||||
const type = c.req.query('type');
|
const type = c.req.query('type');
|
||||||
|
const { model, batch, location } = c.req.query();
|
||||||
|
const source = c.req.query('source');
|
||||||
let sql: string;
|
let sql: string;
|
||||||
if (type === 'delivered') {
|
if (type === 'delivered') {
|
||||||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||||
@@ -1033,17 +1045,33 @@ app.get('/weekly-detail', async (c) => {
|
|||||||
} else if (type === 'replaced') {
|
} else if (type === 'replaced') {
|
||||||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||||
} else if (type === 'pending') {
|
} else if (type === 'pending') {
|
||||||
sql = `SELECT 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`;
|
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||||
} else if (type === 'new') {
|
} else if (type === 'new') {
|
||||||
sql = `SELECT 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
|
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||||
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
||||||
} else {
|
} else {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
const [rows] = await pool.query<any[]>(sql);
|
const [rows] = await pool.query<any[]>(sql);
|
||||||
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);
|
return c.json(masked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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