Compare commits

...

119 Commits

Author SHA1 Message Date
kkfluous
0193e78f18 fix(auth): 能源管理仅 BI-LEADER-ENERGY 与「所有权限」可访问
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
收紧准入:之前 FULL_ACCESS_ROLES(含 数智中心 / BI-Leader)会自动通过。
现在只接受 BI-LEADER-ENERGY 或「所有权限」两类角色。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:16:42 +08:00
kkfluous
2a851fc243 feat(auth): 能源管理放开全量权限角色访问
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
BI-LEADER-ENERGY 之外,FULL_ACCESS_ROLES(所有权限/数智中心/BI-Leader)
也可访问能源管理模块。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:03:05 +08:00
kkfluous
6142af7617 fix(auth): 能源管理仅 BI-LEADER-ENERGY 可访问,移除全量权限旁路
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
与智能调度的口径一致:模块访问需要专属角色,全量权限角色不再自动通过。
本地开发 dev mock 用户已含 BI-LEADER-ENERGY,调试不受影响。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:02:21 +08:00
kkfluous
26f7d7ab3f feat(auth): 能源管理模块需要 BI-LEADER-ENERGY 角色
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 ENERGY_ACCESS_ROLES 与 canAccessEnergy(roles) 守卫(全量权限角色亦可访问)
- 后端 /api/energy/* 加模块级守卫:无角色返回 403
- 前端 App.tsx 按角色动态注入 EnergyModule,无权限时主导航不显示
- dev mock 用户(前端 + 后端)追加 BI-LEADER-ENERGY 便于本地调试

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:55:29 +08:00
kkfluous
f06b0d21eb perf(energy): SWR 缓存 + 自调度刷新,氢能总览 6s → 13ms
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
接口侧:
- cache.ts 改为 stale-while-revalidate:每个 key 自调度,TTL 到期前 5s 后台刷新,用户永远命中热缓存
- 闲置 10 分钟后停止调度,避免空跑
- loader 失败保留旧值 + 10s 后退避重试
- 所有 4 个端点支持 ?force=1 强制绕过缓存

前端 HydrogenOverview:
- 顶部加 RefreshCw 按钮(强刷绕过缓存),带旋转动画
- 显示"更新于 X 秒前"相对时间
- 刷新中:顶部 0.5px 流光进度条,不替换内容、不闪烁
- 60s 静默自动刷新(命中后端热缓存)

实测:cold 6.1s → 命中 13ms(470× 提速)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:43:24 +08:00
kkfluous
6ad4b5e2a4 feat(energy): 氢能总览补全维度(5KPI+收支+客户/加氢站全量+年份切换)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
按 BI 页面 (https://bi.lnh2e.com/lingniu/decision/link/0iqP) 完整还原:
- 5 张 KPI:累计加氢量 / 累计加氢费 / 时享加氢获利 / 本月加氢 / 本日加氢
- 月度收支对比柱图:成本支出 vs 客户收入双柱
- 加氢站加氢汇总(全量 55 站):加氢量+占比+氢费收入+收入占比,进度条
- 客户账单 Top 30:承担方 / 加氢量 / 成本支出 / 应收
- 年份切换(2025/2026),全量数据按选定年份重算
- 关键修正:用 cost_type 区分客户单/我司单(cost_type=2 客户单,cost_type=3 我司单),获利口径与 BI 对齐

后端 /hydrogen/overview 重写:
- 增加 customers/stations/availableYears/year 字段
- KPI 含 yearProfit/monthProfit/todayProfit
- monthly 含 fee/revenue/profit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:43:05 +08:00
kkfluous
ad8ec50038 refactor(energy): 氢能总览参照 BI 重构 + 月度趋势 + 高密度 KPI
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
参考 https://bi.lnh2e.com/lingniu/decision/link/0iqP 重新设计:
- 4 张高密度 KPI 卡:累计加氢量 / 累计加氢费 / 本月加氢 / 本日加氢
  每张含主指标 + 2 行明细(我司/客户、加氢费/占比)
- 新增年内月度加氢量柱图(缺失月份补 0)
- 数字格式化:万元/亿元/T 单位自动切换,tabular-nums 对齐
- 后端 /hydrogen/overview 增加 monthly 字段
- 骨架屏同步更新

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:54:35 +08:00
kkfluous
dc6f541c8b fix(energy): 桌面 sticky 失效 —— overflow:hidden 限定到移动端横屏
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
原因:父容器一直挂着 landscape:overflow-hidden,意图是手机横屏全屏
体验。但 Tailwind 的 landscape: 是纯方向匹配(含桌面横屏显示器),
所以桌面也命中 overflow:hidden,sticky 完全失效,滚动时头部 tab
全部消失,看起来像「半截被遮挡」。

修复:把 landscape: 修饰符改为 max-md:landscape: ,仅在移动端
(< 768px)+ 横屏时生效。桌面恢复正常 overflow:visible,sticky
头部能稳稳停在顶部。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:42:32 +08:00
kkfluous
034654265c style(energy): sticky 头部底部缓冲 pb-2 → pb-4
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
不再尝试把日期速选并入 sticky 头部(之前的方案撤回)。
仅增加一点底部 padding 当作缓冲,保持简洁。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:37:49 +08:00
kkfluous
5958bb581e Revert "fix(energy): 日期速选并入 sticky 头部,避免滚动时被遮挡"
This reverts commit 4153f329b8.
2026-04-30 15:37:17 +08:00
kkfluous
4153f329b8 fix(energy): 日期速选并入 sticky 头部,避免滚动时被遮挡
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前「本周/本月/近 15 天」放在 HydrogenDaily/ElectricDaily 的
内容区第一行,sticky 头部独占顶部。滚动后这一行会从 sticky
头部下方钻过去,露出半截,看起来像被切。

修复:把日期速选行也放进 sticky 头部白卡里:
  - EnergyModule 持有 hydroPick / electricPick state
  - 头部第三行(border-t 分割)渲染速选按钮,仅 daily 模式显示
  - HydrogenView/ElectricView/ElectricDaily/HydrogenDaily 改为
    通过 pick prop 接收,组件内不再 useState

现在头部「Top Tab + Sub Tab + 日期速选」是同一张白卡,
滚动时整体一起 sticky,不再有半截遮挡。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:34:41 +08:00
kkfluous
ee981639eb fix(energy): sticky 头部不再半透明,避免快捷选按钮"半截露脸"+ 文案改「每日加氢量」
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
问题 1:sticky 头部 bg-[#F8F9FB]/85 backdrop-blur-md 半透明,
下方「本周/本月/近15天」快捷选按钮在滚动时透过 sticky 条隐约可见,
看起来像被遮罩切掉一半,效果差。

修复:把头部背景改为不透明页面色 bg-[#F8F9FB],去掉 backdrop-blur,
加上一条很淡的下投影 shadow(仅在头部下边缘)作为分割线,
既不再透出后面的内容,也保留了一点层次感。

问题 2:氢能 daily 图表标题「时段每日加氢量」→「每日加氢量」更简洁。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:28:23 +08:00
kkfluous
fe70ec389b refactor(energy): 电能整体页面对齐氢能:每日 / 总览 子 tab 切换
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- ElectricView 改为受控组件接收 sub prop(与 HydrogenView 对齐)
- EnergyModule sticky 头部统一显示 sub-tabs:氢能、电能都给 每日 / 总览
  ETC 仍不显示子 tab(建设中页)
- 共享 sub state 抽 helper:activeTab 切换时自动用对应的 sub
- 龙王路停车场充电站信息条移入 ElectricOverview 顶部(同氢能"数据自...")

进入电能默认显示「每日」(与氢能一致),切换「总览」看 KPI + 柱图

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:22:02 +08:00
kkfluous
c3b463d9ca feat(energy): 外部车辆 tab 暂时显示「数据未就绪」占位
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
新系统的外部车辆数据尚未接通,按零填充会显示一长串 0,体验差。
当 customer=external 且全期总量为 0 时:
  - 隐藏柱图与明细表
  - 显示「外部车辆 · 数据未就绪」友好占位
    (Plug 图标 + 蓝色脉冲点 + 文案「新系统的外部车辆 X 数据还在准备中」)

氢能/电能 daily 都加。一旦后端接通真实外部数据,totalKg/totalKwh > 0,
占位自动消失,恢复正常表格视图。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:18:14 +08:00
kkfluous
f2acb73033 style(energy): 加氢站单价固定显示在站名下一行
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前 mobile 用 inline-flex flex-wrap 让单价跟在站名后,空间不够才换行。
统一改为站名独占第一行(whitespace-nowrap),单价 amber 徽章固定在
第二行(mt-1),结构更稳定一眼能看到。

行整体改 items-start 顶部对齐。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:15:07 +08:00
kkfluous
015ff9bc7e fix(energy): 站名长名称不再换行 + 「羚牛/外部」改为「羚牛车辆/外部车辆」
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
问题 1:广州新锋交通联新加氢站 等长名称在右侧空间足够时仍折行
- mobile 列宽过宽:120px (kg) + 88px (chainPct) 占去 232px
- 子行 pl-9 缩进 (36px) 也吃掉空间
修复:
  - mobile 列宽收紧 84/80,gap 缩到 2,子行缩进 pl-6
  - 名字 + 单价徽章改为 inline-flex(whitespace-nowrap)
    名字一行写完,单价徽章紧跟其后;空间不够时单价徽章自动换到下一行
  - desktop 列宽 140/120/104(之前 140/140/120)

问题 2:氢/电 daily 客户类型 segmented control
  「羚牛」→「羚牛车辆」,「外部」→「外部车辆」更明确

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:11:19 +08:00
kkfluous
d24ce55a59 fix(energy): 顶部双 sticky 间隙泄露 + 加氢站名单价完整显示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
问题 1:原本「氢能/电能/ETC」与「每日/总览」是两个独立的 sticky
元素,分别 top:0 / top:[58px],中间 gap-3 +位置不精确导致滚动时
图表内容从 14px 缝隙里穿过。

修复:
- 把 HydrogenView 内部的 sub-tab 状态提到 EnergyModule
- top tab + 子 tab 合并到「同一张白色 rounded-2xl 卡片」里,无内部间隙
- 外层 sticky 容器 frosted glass:bg-[#F8F9FB]/85 + backdrop-blur-md
  -mx -mt 扩到页面边,消除左右上的微缝
- HydrogenView 改为受控组件(接收 sub prop)

问题 2:站点行 mobile 上 name + ' · 价格' 共用一格还 truncate,
导致长名称(广州新锋交通联新…/上海浦江加氢站…)截断、单价不可见。

修复:
- 行改为「站名换行 + 下方单价 chip」纵向排列
- 单价用 amber 小徽章「单价 X 元/Kg」,不再 inline 跟着名字
- name 用 break-words 允许折行,items-start 顶部对齐
- 单价为 0(免费/赠送)时不显示徽章,desktop 列里显示「—」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:08:22 +08:00
kkfluous
e0183986ee feat(energy): 氢/电统一时间速选为「本周/本月/近15天」+ 缺失日补 0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端 Range 类型精简到 thisWeek / thisMonth / last15
  rangeClause 同步精简;删除 today / thisQuarter / last7 / last30 分支
- 新增 enumerateDates(range):列出 range 内全部日期,用于补零
- /hydrogen/daily:用 enumerateDates 补齐缺失日期 totalKg=0、stations=[]
  补零后基于完整日期序列重算环比(0→上一日有值时显示 -100%)
- /electric/monthly:增加 range 参数,扁平日聚合 + 月份分组
  缺失日期同样补零;环比基于补零序列重算
- 默认 range 改 last15

前端
- HydrogenDaily QUICK_PICK_OPTIONS 收紧到 3 项,默认 last15
- ElectricDaily 之前没有日期速选,现按氢能样式加上同样 3 项

类型 DateQuickPick 改 'thisWeek' | 'thisMonth' | 'last15'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:56:13 +08:00
kkfluous
234b44ea03 feat(energy): 能源管理新增 ETC tab,建设中占位页
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- EnergyModule TopTab 加 'etc',用 lucide Receipt 图标
- TABS 数据驱动渲染,加新 tab 不用复制粘贴
- 新增 ETCView:可爱的「建设中」占位
    - 大号 Construction 图标 + 顶上的 Hammer 摆动动画
    - 文案「ETC 模块建设中」+ 副标说明
    - 4 步进度(需求评审/数据对接/页面开发/正式上线)
      已完成项绿点、进行中项黄点 + 脉冲,未来项灰点
    - 底部专属 RotatingFooterHint,5 条 ETC 上下文文案

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:42:00 +08:00
kkfluous
8d861538af fix(feedback): 第一步选完类型后点「下一步」无反应
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
next() 漏写了 step===1 且 type 已选的分支,按钮只在「未选」时
拦截,「已选」时进入空函数体直接返回,没有 setStep(2)。

补上 step===1 已选时 setStep(2),行为:
- 直接点卡片:保持原有自动下一步(onClick 里 setStep)
- 选中后用底部「下一步」按钮:现在也能正常推进

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:37:46 +08:00
kkfluous
bdefb878a5 style(feedback): 选类型副标题换成轮转动态提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
step 1 标题下的「选一个最贴近的类型」改成 RotatingFooterHint,
6 条文案 4 秒一轮(含「数字背后还有故事,等下一次上线揭晓」等)。

RotatingFooterHint 兼容自定义对齐:传 className 就完全覆盖
默认 mt-1 + justify-center;不传则保持底部居中(其他模块的用法)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:34:21 +08:00
kkfluous
2aeff0c2f4 fix(feedback): 隐藏页加返回按钮 + 入库时间用东八区
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- FeedbackAdminPage / EleImportPage 头部加 ← 返回按钮:
  优先 history.back(来自 SPA 内跳转),否则 hash=#mileage 兜底回主页
- 反馈入库(created_at / reply_at)改为 DATE_ADD(UTC_TIMESTAMP, INTERVAL 8 HOUR)
  不再依赖 MySQL/容器的本地时区设置,固定 CST

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:27:41 +08:00
kkfluous
9bbd11cc86 fix(feedback): 反馈管理跳转无效 + 本地调试角色补齐
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
问题 1:菜单点「反馈管理」跳到 #/admin/feedback,URL 变了但
AuthGate 只在初始 render 读 location,hashchange 不会重渲染。
修复:AuthGate 用 useState/useEffect 监听 hashchange/popstate,
URL 变化即时切换页面。

问题 2:本地 DEV_BYPASS_AUTH 模式下 roles 没有 BI-ADMIN-FEEDBACK,
菜单看不到入口。前后端 dev bypass 的 roles 都补上:
  ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK']

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:25:30 +08:00
kkfluous
1a3d48b2d1 feat(feedback): 反馈 FAB 菜单加「反馈管理」入口,BI-ADMIN-FEEDBACK 角色可见
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- shared/auth/roles 新增 FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK']
  + canManageFeedback() helper(含 FULL_ACCESS_ROLES 兜底)
- FeedbackFab 菜单:在「我的反馈」下方加分割线 + 紫色 ⚙ 图标的「反馈管理」
  仅 canManageFeedback 为 true 时渲染,跳到 #/admin/feedback
- 后端守卫:GET /api/feedback/list 与 PATCH /api/feedback/:id 加角色判断
  无权限返回 403。/mine /submit /upload 仍对全部登录用户开放。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:20:45 +08:00
kkfluous
c5541fbbf5 style(feedback): 选类型步骤改用 Lucide 图标 + 克制白卡
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前用 emoji + 整卡渐变背景,颜色饱和度高、和系统其他模块的视觉
语言不一致,看起来有点像玩具。

新视觉:
  - 替换 emoji 为 Lucide 图标:Lightbulb / Bug / Palette / NotebookPen
    与项目其他模块(Truck/Route/Zap)保持一致
  - 卡片白底 + 1px 浅边框,hover 阴影;选中态用 ring 替代填色
  - 图标放在彩色圆角小容器里(amber/rose/violet/blue),强度更克制
  - 标题升级到 13px,副标题统一 11px slate-400 medium
  - 入场级联动画 + 微交互(hover y=-1,→ 按钮位移)

文案微调:「想反馈点什么?」+ 副标题「选一个最贴近的类型」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:16:26 +08:00
kkfluous
90b34b681e feat(feedback): 移除联系方式步骤,登录态用户身份已知
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前第三步要求填写微信/钉钉等联系方式,但用户已登录,后端已经
记录 user_id / user_name(与水印取的同一份),可以直接通过内部
渠道触达,无需再问。

流程从 4 步收紧为 3 步:
  1) 选类型
  2) 写内容 + 截图 + 板块
  3) 成功页

提交概览页一并删除(信息不变就直接提交,少一次点击)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:14:54 +08:00
kkfluous
90b1266fe5 fix(feedback): 反馈弹窗禁止背景点击关闭,只能用 X 按钮
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前点击外部遮罩会关闭弹窗,用户填到一半误触会丢失全部已输入内容。
去掉 backdrop 的 onClick={close},只保留右上角 X 按钮关闭路径。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:08:22 +08:00
kkfluous
20ebb16e08 feat(feedback): 截图上传 + 我的反馈历史 + 后台管理页
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
后端
- 接入阿里云 OSS(ali-oss SDK),bucket=lnh2etest,目录 /dos/feedback/YYYY-MM-DD/
- POST /api/feedback/upload:单图 multipart 上传,限制 5MB,仅 png/jpeg/webp/gif
- bi_user_feedback 增加 screenshots(JSON)、reply_content/reply_user/reply_at、user_id 索引
  老表通过 try-catch 自动 ALTER 兼容
- POST /api/feedback/submit:增加 screenshots[] 字段
- GET /api/feedback/mine:当前用户自己的反馈历史
- PATCH /api/feedback/:id:更新状态 + 回复
- GET /api/feedback/list:增加 status 过滤

前端
- FeedbackFab 改为悬浮按钮 + 弹出菜单:「提个建议」/「我的反馈」
- 弹窗 Step 2 增加截图区:点击选择 / 多张 / 直接 Ctrl+V 粘贴
  缩略图预览 + 单张移除,最多 6 张,上传中转圈
- FeedbackHistoryDrawer 新组件:底部抽屉展示自己的反馈
  含状态徽章(待处理/处理中/已完成/已忽略)、截图缩略图、产品同学回复区
- 新增隐藏后台管理页 /admin/feedback(或 #/admin/feedback)
  状态分类计数 + 列表 + 详情弹窗(改状态 + 写回复,状态选项含徽章色)
  待处理项有快捷按钮(标记处理中 / 忽略)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:06:21 +08:00
kkfluous
e8f1604c11 feat: 全局反馈系统 + 各模块底部统一动态提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 components/RotatingFooterHint:统一文案+蓝色脉冲,4 秒轮换
- 新增 components/FeedbackFab:右下角悬浮按钮(渐变 + 心形信封 + 黄色脉冲点),
  点击打开 4 步引导式弹窗
    Step 1 选类型(💡新维度 / 🐛bug / 🎨界面 / 📝其他)
    Step 2 描述需求 + 选当前板块(chip)
    Step 3 留联系方式(可选)+ 提交概览
    Step 4 ❤️ 成功页(弹簧 √ 动画)
  顶部 spring 进度条,底部上一步/下一步,下拉手柄,背景点击或 X 关闭
- 后端 routes/feedback:bi_user_feedback 表(自动建表,含 status 字段)
  POST /api/feedback/submit + GET /api/feedback/list
- Shell 全局挂载 FeedbackFab,自动从 hash 检测当前模块
- 各模块底部追加 RotatingFooterHint:
  AssetsModule / MileageModule / SchedulingModule / EleImportPage
  HydrogenOverview / HydrogenDaily / ElectricOverview / ElectricDaily
  (HydrogenOverview 旧的内嵌实现已替换为共享组件)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:50:39 +08:00
kkfluous
08f21b7e24 chore(energy): 电能总览删除「今日」KPI 卡
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
KPI 头从 3 列改为 2 列,仅保留累计与本月。
今日卡常被遮罩(手工导入的数据滞后),且与本月信息重复,先去掉。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:57:24 +08:00
kkfluous
57207debfb fix(energy): hydrogen_time 已是 CST 字面值,去掉多余的 +8 HOUR 转换
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前认为 hydrogen_time / charging_start_time 字段存的是 UTC,
所有氢能查询都加了 DATE_ADD(..., INTERVAL 8 HOUR) 转 CST。
但实际上字段存的是 CST 字面值,转换反而把日期边界提前了 8 小时,
导致诸如「04-28 嘉兴嘉燃经开站」的统计少算了部分晚间订单。

实测:
  - 之前:04-28 嘉燃经开 = 144.36 Kg(CST 转换错位)
  - 现在:04-28 嘉燃经开 = 153.81 Kg(与业务方直接 DATE(hydrogen_time) 口径一致)

电能 charging_start_time 同样去掉转换。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:54:22 +08:00
kkfluous
d3fa2fd4d6 revert(energy): 取消 GF_HECRI_BILL 过滤,全部数据展示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
按用户要求恢复全量统计:移除 4 处 GF 过滤子句和相关常量。
现在 GF_HECRI_BILL 历史订单会与 JQ 新订单一同计入。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:28:52 +08:00
kkfluous
8b4fb6563f refactor(energy): 简化为始终过滤 GF_HECRI_BILL,移除条件判断
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
去掉 shouldFilterGfBills 探测、5 分钟缓存、JQ 存在性判定。
4 处氢能查询无条件追加 GF_HECRI_BILL 过滤,逻辑更直接。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:26:52 +08:00
kkfluous
e187c0d02e feat(energy): 嘉燃经开站存在 JQ 单时全局过滤 GF_HECRI_BILL 历史订单
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
业务背景:旧系统加氢账单使用 GF_HECRI_BILL 前缀,新系统统一改用 JQ 前缀。
切换期间两套数据共存,会重复计入加氢量。约定:当嘉兴嘉燃经开站出现 JQ
订单(视为切到新单号体系),全局过滤掉 GF_HECRI_BILL 前缀的历史订单。

实现:
- shouldFilterGfBills() 探测嘉兴嘉燃经开站是否有 JQ 单,结果缓存 5 分钟
- GF_EXCLUDE_CLAUSE = b.bill_code NOT LIKE 'GF\_HECRI\_BILL%' ESCAPE '\\'
- 应用到 4 处氢能查询:KPI、Top5、区域占比、daily 站点聚合

实测:当前嘉燃经开 4508 JQ + 665 GF,嘉锦 11783 JQ + 1 GF,全部 GF 已隐藏。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:23:03 +08:00
kkfluous
3d4d862d73 feat(energy): 氢能总览删除 4 张 KPI 卡,底部加动态幽默提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 删除:年加氢量 / 年加氢费 / 累计羚牛承担 / 本月-今日 四张顶部 KPI 卡
  (及对应的 Skeleton 占位、未用的 Fuel/Wallet/Coins/CalendarClock import 与 fmt 工具函数)
- 新增 RotatingFooterHint:底部居中蓝色脉冲点 + 6 条幽默文案 4s 轮换淡入
  例如「更多统计维度接入中,欢迎您的建议 ~」「数据科学家正在深夜挖掘新维度…」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:10:27 +08:00
kkfluous
23a7722583 chore(energy): 氢能总览删除「我方/客户产生」副统计
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
年加氢量、年加氢费 卡片移除以下副信息:
  - 我方 XXX
  - 客户产生 XXX

只保留主标题与大数字。其他卡片(累计羚牛承担、本月/今日)保持不变。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:56 +08:00
kkfluous
213037c2ac fix(energy): 氢能 Top5 排名圆点左侧被 SVG 视窗切掉
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前 cx=-178 + YAxis.width=190 + margin.left=0:
  Y 轴线 pivot 在 chart x=190;圆点 abs x=12,半径 9 → 左缘 abs x=3
  紧贴 SVG viewBox 左边界,渲染时被切掉一半。

新值:margin.left=12,YAxis.width=188,circle cx=-172,text x=-154
  pivot=200,圆点 abs x=28,左缘=19,离左边界 19px 缓冲,圆点完整可见。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:03:24 +08:00
kkfluous
3efa701395 feat(energy): 氢能总览加载骨架屏,缓解 1-2s 初始等待
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前数据回来前只显示一行「加载中…」纯文本,1-2 秒等待体感差。
新增 HydrogenOverviewSkeleton:
  - 4 张 KPI 卡占位(含标题/数值/副信息行)
  - Top5 横向条形图占位(5 行 圆点 + 站名 + 渐变条 + 数值)
  - 区域占比环 + 图例占位
  - 底部蓝色脉冲点 +「正在加载氢能总览…」
全部用 animate-pulse,结构与真实页面保持一致,避免回填时跳动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:48 +08:00
kkfluous
e775acb8fe fix(energy): 氢能 Top5 排名圆点与站名重叠修复
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
YAxis 宽度 170→190,圆点 cx 从 -158 → -178,文字 x 从 -144 → -160。
间距从 14px 拉到 18px+,避免在窄屏上圆点压住站名首字。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:59:37 +08:00
kkfluous
c788dd4577 fix(energy): 单价直接取 MAX(cost_price),不重算不返 null
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前用 MIN=MAX...ELSE NULL 判定,再 NULLIF 排零,遇到「1 笔 0 元免费单 + 多笔 35 元正价单」
仍可能误判混合,最终页面显示「—」(如佛山豪汇石油加氢站)。

按业务约定:单价就是订单上记录的成本价,不做"统一性"判定,也不返 null。
改用 MAX(b.cost_price):
  - 自然忽略 0 元免费/赠送单(被正价 max 掉)
  - 同价组等于原价
  - 极少数真正混合价组也展示该日付出过的最高单价(仍是订单上的真实数字)

回退类型:HydrogenStationRow.pricePerKg 重新固定为 number。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:56:26 +08:00
kkfluous
3851335843 fix(energy): 氢能单价不再加权,混合价组显示「—」并修复 hydrogen_time 歧义
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- pricePerKg 改为 CASE WHEN MIN=MAX THEN MIN ELSE NULL,
  同价组返回原价(无小数误读),混合价组返回 null
- 类型 HydrogenStationRow.pricePerKg: number | null
- 前端 mobile/desktop 两处展示在 null 时显示「—」
- 修复 ER_NON_UNIQ_ERROR:tab_import_hydrogen_order 也有 hydrogen_time 字段,
  把 SELECT/ORDER BY 中 ${HYDROGEN_LOCAL} 替换为显式 b.hydrogen_time 限定
- 实测:712 个站点-日组中 682 个同价直接显示原价,30 个混合显示「—」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:51:41 +08:00
kkfluous
d0a644cf18 fix(energy): 氢能站点名补全 tab_import_hydrogen_order,单价改为按量加权
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
站点名 fallback 链新增第三档:
  内部站表 → 外部站表 → tab_import_hydrogen_order(by bill_code) → 「未关联/未知 #ID」
经此关联,原来 140 条「未知站点」补全为 9 个真实站名(洛阳新红山、佛山新城等)

单价之前用 AVG(cost_price) 简单平均,混合价组会算出 34.0334... 这种
意外小数(看起来像被「重新计算」)。改为按量加权效率价:
  ROUND(SUM(cost_expense) / NULLIF(SUM(hydrogen_quantity), 0), 2)
单一价格组自动等于原价,混合价组得到真实付款单价。

Top5 与每日聚合两处 SQL 同步修改。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:46:56 +08:00
kkfluous
0d30ee2df5 fix(energy): 氢能站点 fallback 区分「未关联」与「未知 #ID」
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
之前两张 site 表都查不到的账单,UI 一律显示「未知站点」。
其中 137 条 station_id 为 NULL(账单未填站点),3 条 station_id
有值但站点表没收录(站点删除/字典漂移)。账单上的 cost_price 是真实的,
所以会出现「未知站点 25 元/Kg」这种可追溯但难定位的情况。

现在 SQL fallback 改为:
  - station_id IS NULL → 「未关联站点」
  - station_id 不为空但 join 不上 → 「未知站点 #ID」
方便定位具体是哪条字典记录缺失。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:41:57 +08:00
kkfluous
9a20a7cb79 refactor(energy): 电能仅展示充电量,电/氢 daily 表格统一列宽
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 电能去掉「充电费用(元)」列,只保留 月份/日期 | 充电量(度) | 环比
  原列宽 auto+auto+auto 在窄屏会挤压重叠
- 改用固定 minmax(0,1fr)/120/88(移动)和 1fr/160/120(桌面)
- 氢能 daily 同步统一列宽:1fr/120/88(移动)和 1fr/140/140/120(桌面)
- 单价表头改为「单价 (元/Kg)」全展示,移动端站点行价格作为副信息缩小展示

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:37:26 +08:00
kkfluous
d1d79f1c7c feat(energy): 电能统计切到 bi_ele_charge_record,外部数据接通
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- /api/energy/electric/overview & /electric/monthly 不再读 tab_energy_electricity_bill
- 改读 bi_ele_charge_record:kwh/fee/start_time
- 外部/我司用 vehicle_kind 区分(external/internal)
- 电能默认 customer 由 'external' 改 'lingniu',与导入页约定一致
- ElectricDaily 移除「数据对接中…」友好空状态(外部已有数据)

ele 导入页同步收紧:
- 命中系统车辆=internal,未命中(含车牌为空)一律 external
- 移除 unknown 分类、KPI 卡、批次列、过滤按钮、UploadResult 字段
- 历史 unknown 行已 UPDATE 为 external

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:11:52 +08:00
kkfluous
5217e19b25 fix(ele): /ele/import 同时支持 hash 路由
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
主应用模块切换走 hash(#mileage 等),用户也会用 #/ele/import 访问。
之前只判 pathname='/ele/import',hash 形式直接落到 Shell 默认模块。
现在 path / hash 两种形式(/ele/import、#/ele/import、#ele/import)都识别。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:04:48 +08:00
kkfluous
57fdd346cf feat(ele): 充电记录后台导入页面 /ele/import(隐藏入口)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
后端
- 新建 bi_ele_charge_record 表(首次访问自动 CREATE TABLE IF NOT EXISTS)
  字段含订单编号(UNIQUE)、电站、时段、电量/费用、车牌/判定车牌、内外部分类、原始 JSON、批次号
- POST /api/ele/import:multipart 上传 xlsx,识别表头自动定位,
  文件内 + 数据库双重去重(INSERT IGNORE on UNIQUE order_no)
  上传时按 plate/judged_plate 在 tab_truck 中匹配,命中=internal、未命中但有牌=external、无牌=unknown
- GET /api/ele/list 分页 + kind/batch/search 过滤
- GET /api/ele/batches 批次汇总(数量、内/外/未知拆分、电量/费用合计)
- GET /api/ele/aggregate 全量与近 30 日按日 × 分类聚合

前端
- /ele/import 路径直接渲染 EleImportPage,主导航不显示,需手动输入 URL
- 拖拽/点击上传,结果卡展示解析/新增/重复/分类
- KPI 8 卡:总数、内/外/未知记录、累计电量与费用、内/外电量
- 批次列表(点击筛选)+ 最新记录表(kind 切换 + 关键字搜索)
- 上传后自动 reload 全部数据

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:02:38 +08:00
kkfluous
d8189329ac chore(energy): 羚牛 tab 置前并默认选中(氢能/电能一致)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- HydrogenDaily/ElectricDaily 默认 customer 改为 'lingniu'
- segmented control 顺序改为 ['lingniu', 'external']
- 进入页面立刻看到我司数据,避免空状态首屏

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:53:22 +08:00
kkfluous
e2d04db06d chore(energy): 重新放开电能 tab
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 取消之前的临时屏蔽,恢复氢能/电能两个顶部 tab
- 电能内部已和氢能同时支持 truck_id 区分外部/我司,外部空数据会展示「数据对接中…」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:52:11 +08:00
kkfluous
5493e27e49 feat(energy): 用 truck_id 区分外部/我司,外部数据空时给友好提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端 customerClause 改为基于 truck_id:外部=IS NULL,我司=IS NOT NULL
- KPI 内联条件(ourYearKg/Fee、customerYearKg、lingniuBornKg/Fee)同步切换为 truck_id
- 调用方 /hydrogen/daily 与 /electric/monthly 改传 b.truck_id / truck_id
- 当前外部账单 truck_id 尚未对接,HydrogenDaily/ElectricDaily 在 customer=external 且无数据时
  改展示「数据对接中…」友好状态(插头图标 + 蓝色脉冲),替代「暂无数据」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:52:03 +08:00
kkfluous
355c45a2e4 fix(assets): 区域车型分解新增「待交车」字段,并合入「其他」车型
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- typeBreakdown 之前只产 4.5T/18T/49T 且仅含 inventory 字段,
  导致区域级 待交车 与车型级 待:N 不一致、操作中合计 != 区域合计
- 后端 getTypeBreakdown 计算 pending(status==='Pending'),
  并把不属 4.5T/18T/49T 的车辆聚合为「其他」类型
- 前端区域 mobile/desktop 视图把「待:」从 inventory 改读 pending
- 点击穿透的 category 也由 'Inventory' 改 'Pending'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:21:37 +08:00
kkfluous
66779a98e3 fix(mileage): 弹窗关闭改为向下滑出 + 淡出
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- exit 从 y:60 改为 y:'100%',整张表自顶向下滑出屏幕
- y/opacity 拆分 transition:y 走 spring,opacity 走 0.18s 淡出
- initial 也改为 y:'100%' 让出现/消失对称

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:16:57 +08:00
kkfluous
97ac92a0da feat(mileage): 车辆明细弹窗新增时间范围切换、骨架加载与下滑关闭
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端 vehicle/:plate/recent 支持 start/end 任意区间,最长 366 天
- 前端弹窗加 segmented control: 近 15 天 / 本月 / 本季度,切换重新加载
- 加载时柱状图与每日明细均显示骨架,区间合计/日均/有数据天 KPI 同步骨架
- 数据回来后柱条与每行进度条带渐入动画
- 顶部加 iOS 风格 drag handle(小白条),按住下滑超过 100px 或大速度触发关闭
- 保留点击背景与 X 按钮两种关闭方式

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:14:47 +08:00
kkfluous
7ca8ef24dc feat(mileage): 点击车辆卡片展示近 15 日行驶里程明细
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端新增 GET /api/mileage/vehicle/:plate/recent,返回近 N 天 + 今日的每日里程
- 缺失日补全为 dailyKm=0 + isDataSynced=false
- 前端新增 VehicleDetailModal:头部信息、合计/日均/有数据天 KPI、近 N 日柱状图、每日明细列表
- 移动端从底部弹起;缺失日柱条置灰,明细行标注「未对接」
- 卡片点击改为打开弹窗(不再复制车牌)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:16:54 +08:00
kkfluous
f9c6155ea7 fix(mileage): 修复车牌多选弹窗手机端右侧溢出与粘贴行为
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 弹窗改为 right-0 锚右,宽度 min(280px, vw-24px),避免在窄列触发器下右侧溢出
- 移除 onPaste 自动 apply(避免与已输入文本拼接出非预期 token),改为粘贴入框 + 点击「添加」或 Cmd/Ctrl+Enter 确认
- placeholder 文案补充提示

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:12:19 +08:00
kkfluous
cab86556f3 feat(mileage): 区域与车牌列表级联,并自动剔除越界车牌
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端: 选中 region 时基于该区域车辆重算 filters,车牌列表只展示该区域
- 前端: filterOptions.plates 收窄后自动从已选车牌中剔除不属于新区域的项

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:31:12 +08:00
kkfluous
e0c609168e chore(energy): 恢复能源管理入口,仅隐藏电能 tab
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 重新启用 EnergyModule 作为侧边栏入口
- EnergyModule 内部隐藏「电能」tab,只保留「氢能」(保留 Electric* 代码)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:25:37 +08:00
kkfluous
ebd82893bc chore(energy): 暂时隐藏能源管理入口(发版前临时屏蔽,保留模块代码)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:24:19 +08:00
kkfluous
3809e785c1 feat(mileage): 外部三选筛选 + 车牌多选粘贴 + 运营区域 + xlsx 下载
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 外部行改为 批次型号 / 运营区域 / 车牌多选;按部门、按客户移入详情面板
- 车牌多选支持从 Excel 粘贴(换行/逗号/空格分隔),未匹配项显示警告
- 新增运营区域筛选:基于 136 批次区域映射(华东/华南/西南/西北)
- 新增 xlsx 数据下载,导出当前筛选结果(带表头样式与列宽)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:19:00 +08:00
kkfluous
d1acdafa7e refactor(energy): merge electric overview into a single page
Drop the 每日/总览 sub-tabs on 电能 — only 龙王路充电站 in scope, so
the overview is light (3 KPI cards + 1 bar chart) and combining
saves a click for daily ops. ElectricView now renders ElectricOverview
+ ElectricDaily back-to-back below the hint card.

氢能 keeps its sub-tabs (richer overview with Top5 + region chart).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:04:26 +08:00
kkfluous
c3b43837fb fix(energy): update hint text to match real 1m cache TTL
The "每 5 分钟更新" copy was inherited from the BI dashboard mock and
no longer matches reality — server cache is 60s TTL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:02:41 +08:00
kkfluous
c02c1aa62c perf(energy): add 60s TTL cache for all 4 endpoints
Hydrogen overview was 1.8-2.0s (3 full-table aggregations on 66K rows).
With cache: cold 1 user/min eats the full query, all subsequent within
60s window return in ~10ms.

Implementation:
- New cache.ts with cached(key, loader) helper
- Per-key in-flight de-duplication: concurrent requests share one loader
- Each handler wrapped, cache key includes query params
  (e.g. "hydrogen/daily?range=last30&customer=external")
- TTL 60s as requested

Measured speedups:
- hydrogen/overview: 1.96s → 12ms (165x)
- hydrogen/daily:    270ms → 11ms (24x)
- electric/overview:  93ms →  9ms (10x)
- electric/monthly:   36ms →  9ms (4x)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:50:48 +08:00
kkfluous
9a4f1945d9 feat(energy): connect to real DB (lingniu_prod)
Replace front-end mock data with live API backed by:
- tab_energy_hydrogen_bill (66.5K rows) joined with
  tab_hydrogen_site (internal stations) and tab_outside_hydrogen_site
  (external stations, joined via inner_site_id)
- tab_energy_electricity_bill (4.4K rows, all 龙王路充电站)

New server routes (src/server/routes/energy/):
- GET /api/energy/hydrogen/overview  → KPI + Top5 站点 + 区域占比
- GET /api/energy/hydrogen/daily?range=&customer=  → 日级 + 站点级下钻
- GET /api/energy/electric/overview  → KPI + 本月柱图 (fallback to last
  available month if current month has no data)
- GET /api/energy/electric/monthly?customer=  → 6 个月分组日级表

Business rules encoded server-side:
- 客户类型: customer_id IS NULL = 羚牛承担, NOT NULL = 外部
- 时区: DATETIME 列字面值是 UTC,分组前 +8h 转成 CST
- 数据清理: hydrogen_time >= 2024-01-01 (排除 1900 年脏数据)
- 站点名 fallback: short_name → name → fixed_station_name → station_name → '未知站点'
- 区域归一化: SUBSTRING_INDEX(city, '-', -1) 取最后一段,去掉 '省'/'市'
  让 '四川省-成都市' 和 '成都市' 合并为 '成都'

Component changes:
- All 4 components (HydrogenOverview, HydrogenDaily, ElectricOverview,
  ElectricDaily) now use useEffect + fetch with loading/error states
- HydrogenDaily filtering moved to server (range + customer params)
  → drops client-side TODAY constant + isInPick switch
- ElectricOverview chart title is dynamic: shows 'YYYY-MM 每日充电'
  when fallback kicks in (current month has no data)
- mock.ts deleted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:42:37 +08:00
kkfluous
7de2d1ecd5 refactor(energy): split electric view into 总览/每日 sub-tabs
- Symmetry with hydrogen — both sides now have a 每日/总览 sub-tab pair
- New ElectricOverview (KPI + bar chart) and ElectricDaily (table)
- Sub-tab styling: pill fill (active = blue-50/blue-600) instead of the
  underline-style used by parent — clearer visual hierarchy
- Tab order swapped to 每日 → 总览 with 每日 as default (daily ops focus)
- Today KPI: pill moves to absolute top-right corner so today's kwh
  reading regains full row width (was getting truncated to "510...")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:39:05 +08:00
kkfluous
42ec6e1c01 refactor(energy): drop anomaly coloring on hydrogen daily bars
All bars now use the cyan→blue gradient consistently, matching the
electric daily chart. Anomaly information stays available via the
table row tinting and the trend pills below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:33:43 +08:00
kkfluous
313325553d feat(energy): hydrogen daily — period bar chart with anomaly coloring
Mirrors the electric-view treatment: a 时段每日加氢量 bar chart sits
between the customer toggle and the table. Bars use the cyan→blue
gradient by default; days where |chainPct| >= 30% render in solid
emerald (positive) or red (negative), giving an at-a-glance view of
anomalous days that's reinforced by the table's row tinting below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:32:40 +08:00
kkfluous
d9b9ff495e feat(energy): electric view — daily bar chart + anomaly tint + mobile 环比
- New 本月每日充电 bar chart (蓝青 gradient) sits between KPI row and
  table, fixing the previous "wall of numbers" feel
- Day rows now tint emerald/red when |chainPct| >= 30% (matches hydrogen)
- 环比 pill column now also shows on mobile (was desktop-only)
- Today KPI: pill moves to second line alongside kwh via justify-between
  so it no longer gets clipped on narrow viewports
- Day labels in table trimmed to MM-DD (parent month row already shows year)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:05:11 +08:00
kkfluous
bdd039a2c4 refactor(energy): visual polish + KPI/table self-consistency
- mock: derive ELECTRIC_KPI month/today from APR_DAYS so card and table
  totals always agree (previously ¥8,437 vs ¥9,151 mismatch)
- overview: Top5 bar chart now shows rank badges (1-5) and inline value
  labels at bar ends — readable without hover
- overview: donut "年合计 362.43T" moves into the chart center
  (previously below as a separate line, defeating the donut hole)
- daily: rows with |chainPct| ≥ 30% get a tinted background
  (green for spikes, red for drops) for at-a-glance abnormal-day spotting

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:01:13 +08:00
kkfluous
2a92d991b0 fix(energy): bump KPI text-3xl to lg breakpoint to avoid landscape wrap
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:46:15 +08:00
kkfluous
ccf76cba79 feat(energy): electric view with mini KPI + month grouping
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:39:55 +08:00
kkfluous
a40fd2be34 feat(energy): hydrogen daily table with station drilldown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:33:52 +08:00
kkfluous
c8a1e8506e fix(energy): widen Top5 YAxis to prevent station name truncation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:29:45 +08:00
kkfluous
dc1f0326fc feat(energy): hydrogen overview Top5 bar + region donut
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:27:38 +08:00
kkfluous
e6880cba17 feat(energy): hydrogen overview KPI cards (4-card grid)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:20:32 +08:00
kkfluous
09b9862f1f feat(energy): add module shell, register in nav
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:15:55 +08:00
kkfluous
deb2f2d5da feat(energy): add types and mock data for new module
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:07:04 +08:00
kkfluous
ccd97d3aae docs(energy): add 7-task implementation plan
Mobile + desktop responsive front-end module with mocked data.
Each task = lint pass + chrome-devtools visual verification (no
test framework in this project).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:04:08 +08:00
kkfluous
61db692980 docs(energy): add design spec for new 能源管理 module
Mobile-first responsive entry under bottom nav. Phase 1: front-end
prototype with mocked data — backend deferred. Mirrors three FineBI
dashboards (TPqB / GBSp / 0iqP) restructured into 氢能/电能 tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:57:12 +08:00
kkfluous
cfe79cace2 fix(assets): correct modal filtering for 待交车/库存-其他/本周X
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
三个弹窗筛选问题一起修:

1. 待交车 drill-in:Pending 原本错归入 weekly-detail(该接口不支持
   model/batch/location 过滤),改走 /list 并给 /list 的 category 分支
   补上 'Pending' 状态匹配。

2. 库存-其他:'其他' 同时存在于两个体系——资产表的"库存-其他"
   (mapRegion 结果) vs 区域统计的"其他"(mapMacroRegion 结果),
   过滤语义完全不同。引入 source 参数由前端传递,source==='asset'
   时按 v.location 匹配(库存语义),否则按 mapMacroRegion(宏观区域)。
   抽取 filterByLocation 辅助函数供 /list 与 /weekly-detail 共用。

3. 本周交车/还车/替换:/weekly-detail 接口新增 model/batch/location/source
   过滤;前端 fetchWeeklyDetail 签名扩容。实现方式:SQL 结果与缓存
   车辆集(按过滤条件筛)按 truck_id 取交集。

4. BIGINT 精度丢失:DELIVERED_SQL / RETURNED_SQL / REPLACED_SQL 及
   pending/new 子查询原本使用裸 truck.id,mysql2 驱动把 BIGINT 当
   JS Number 返回,大 id (>2^53) 尾部被截,导致 truck_id 交集永远
   为空。全部改为 CAST(truck.id AS CHAR),与 MAIN_SQL 保持一致。

5. fetchVehicleList 类型补上 source,避免前端传的 source 被 URLSearchParams
   构造时静默丢弃。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:00:45 +08:00
kkfluous
9ea2f306c4 feat(dev): env-gated local auth bypass for development
.env 里设 DEV_BYPASS_AUTH=1 + VITE_DEV_BYPASS_AUTH=1 即可本地免登录调试。
前端判定强制要求 import.meta.env.DEV,避免生产构建误启用。
后端塞入 dev 身份(含 所有权限 / BI-SCHEDULE-OPT 角色),保证 c.get('user')
下游依赖不会 crash。

新增 src/vite-env.d.ts 引入 vite/client 类型以访问 import.meta.env。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:00:30 +08:00
kkfluous
a472e543ce refactor(scheduling): gate access strictly on BI-SCHEDULE-OPT role
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Remove the implicit fallback that granted scheduling access to any
FULL_ACCESS role (所有权限 / 数智中心 / BI-Leader). Access now requires
an explicit BI-SCHEDULE-OPT assignment, so the module scope is managed
purely via role assignment rather than piggy-backing on admin roles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:50:48 +08:00
kkfluous
0c258dd1a2 fix(docker): copy src/shared into runtime image for server imports
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
src/server/auth/types.ts imports runtime values (role constants,
canAccessScheduling helper) from src/shared/auth/roles.ts — without
the shared folder in the final stage the server crashes with
ERR_MODULE_NOT_FOUND. Existing shared/scheduling imports survived
only because they were type-only and elided at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:44:49 +08:00
kkfluous
200172f0af feat(scheduling): role-based access + align list count with qualifiedCount
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Gate 智能调度 module on BI-SCHEDULE-OPT role (or full-access roles)
  via shared canAccessScheduling helper, replacing hardcoded userId allowlist
- Thread roles[] through JWT payload → middleware → frontend nav
- Add router guard that 403s non-authorized users on /api/scheduling/*
- Emit replace_qualified suggestion for every qualified vehicle so list
  count matches the 已完成考核目标 card; recalc qualifiedCount /
  hopelessCount post-permission-filter for card↔list consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:42:21 +08:00
kkfluous
a954fb90f6 refactor(scheduling): 考核剩余 → 年度考核剩余
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 11:04:08 +08:00
kkfluous
2ea00a5383 refactor(scheduling): 拆分 reason 区为 客户/车辆 两栏
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
详情页的指标区从单列两格网格改为 左:客户 / 右:车辆 两栏。客户日均归
左侧,考核剩余、日均需、年度完成率、可为新车贡献归右侧,便于一眼
识别数据归属。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:01:59 +08:00
kkfluous
cf138f67c0 refactor(scheduling): remove 7日 客户日均 趋势徽章
详情页和列表里的 ↗ 7日 +X% / ↘ 7日 -X% 徽章移除,客户日均只保留
30 日均这一项。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:00:10 +08:00
kkfluous
e32b0b58b3 fix(scheduling): 近7天 filter should be time-only, not exclude cancelled
Previously the toggle hid cancelled records, so users who clicked a
record timestamped within 7 days but later cancelled would see nothing.
Now 近7天 filters purely by createdAt; combine with status tabs to
narrow further.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:56:30 +08:00
kkfluous
9d1e8c4d30 feat(scheduling): enrich history records with customer/dept/manager + drill-in to swap plan
Each row in 调度记录 now shows 业务部门(简)/业务负责人/客户 beneath
the plate line, and is clickable to open the reusable SwapPreview
showing the full replacement plan (current mileage, 考核目标, 替换后预测).
Drill-in is only enabled when the suggestion is still in the active
scheduling view; the user can still 取消干预 from the preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:30:07 +08:00
kkfluous
ba1e0e9f16 feat(scheduling): add 近期已干预 summary card (last 7 days)
Restore 替换建议 card and add a new emerald 近期已干预 card. Clicking
opens the history modal pre-filtered to the last 7 days (excluding
cancelled) via a toggle chip users can switch off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:25:59 +08:00
kkfluous
1b2ad68743 fix(scheduling): enforce one active intervention per current vehicle
Business rule: a running vehicle can hold AT MOST ONE active (sent|executed)
intervention. Switching to a different candidate requires cancelling the
prior one first.

- Server: insertNotification dedup key changes from (suggestion_id,
  candidate_plate) to just suggestion_id; 409 response includes the blocking
  candidate plate
- Detail modal: shows a banner naming the locked candidate; non-active
  candidates render a disabled "该车已有其他干预,请先解除" hint instead
  of the action button
- Batch: pickBestCandidate returns null for any suggestion already holding
  an active intervention — the whole suggestion is excluded

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:19:43 +08:00
kkfluous
210db7f8ff feat(scheduling): rename 通知→干预, allow drill-in on intervened items
- Globally rename user-facing 通知 → 干预 (list badge, detail button, batch
  modal, CSV header, server response messages, db table comment)
- 已干预 row in detail is now clickable — opens SwapPreview which shows
  a read-only summary plus a 取消干预 action (PATCH notify /:id with
  status=cancelled). Sending is blocked while already intervened.
- Selected suggestion now follows the latest data snapshot so status
  changes from within the detail flow propagate immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:14:53 +08:00
kkfluous
1d9f4cb43d feat(scheduling): history view, execute/cancel lifecycle, CSV export, 7d trend
- Add 调度记录 modal: lists notifications by status, supports 标记已执行 (with
  after-mileage + notes) and 取消 for open records
- Add CSV export of filtered suggestions (UTF-8 BOM for Excel); top candidate
  per row picked by same-region > can-qualify preference
- Compute customer 7-day average alongside 30-day baseline in a single query;
  show trend indicator (up/down/flat) next to 客户日均 in list and detail card

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:47:31 +08:00
kkfluous
3ef0d4edfa feat(scheduling): persist notifications, batch notify flow, dedup protection
- Add tab_scheduling_notifications table with bootstrap via ensureSchedulingTables()
- Notify endpoint rewritten: dedup by (suggestion_id, candidate_plate), history list, PATCH /:id for execute/cancel lifecycle
- Batch notify endpoint returns success/skipped/failed counts
- Suggestions response now carries notificationId + notificationStatus per candidate (joined from active-notification map)
- UI: select mode with checkboxes, floating action bar, confirmation modal listing each swap; already-notified items are dimmed and skipped
- Detail view badges show sent/executed state, preventing duplicate notify

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:43:21 +08:00
kkfluous
31716c6547 refactor(scheduling): shared types, structured reason, cross-region candidates
- Extract shared types to src/shared/scheduling/types.ts (client/server both re-export)
- Convert SchedulingSuggestion.reason from string to structured { lines, conclusion }
- Remove hard region filter; algorithm keeps cross-region candidates with isSameRegion flag
- SuggestionDetail renders same-region vs cross-region sections with a divider
- Close detail modal when selected suggestion no longer exists in data
- Unify estimatedGain definition (strict canQualifyAfterSwap) between algorithm and API layers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:36:38 +08:00
kkfluous
335282a2c3 feat(scheduling): add km unit to 客户日均; move 年度考核 to top-right of list item
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:24:25 +08:00
kkfluous
dfc32c4485 fix(scheduling): rename 日均→客户日均 to avoid ambiguity
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:07:49 +08:00
kkfluous
ceed067807 refactor(scheduling): re-layout list items — left group (dept/manager/customer) + right group (daily/rate)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:06:49 +08:00
kkfluous
2f11afc25f fix(scheduling): shorten department name by removing 业务 prefix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:05:40 +08:00
kkfluous
9f781c766a feat(scheduling): rename 完成→年度达标, add sort by 客户日均/年度达标 to list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:03:59 +08:00
kkfluous
8664317852 feat(scheduling): show department and manager in list items and detail card
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:02:03 +08:00
kkfluous
c3de4ebaf5 refactor(scheduling): redesign current vehicle card to match candidate card style
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:00:22 +08:00
kkfluous
aa9a29fed8 fix(scheduling): use each candidate's own daysLeft for prediction
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Different assessment targets have different end dates. Previously all
candidates used the current vehicle's daysLeft, causing wrong predictions.

Now each inventory vehicle computes its own daysLeft from its assessment
target's current_year_assessment_end_date. predictedAfterSwap uses the
candidate's own daysLeft instead of the current vehicle's.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:58:47 +08:00
kkfluous
a52a77f3a2 feat(scheduling): batch filter as multi-select pills instead of dropdown
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:54:01 +08:00
kkfluous
9012a955b8 feat(scheduling): add 剩余xx天 to candidate header
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:52:50 +08:00
kkfluous
caff78c5f3 fix(scheduling): rename 预估年终 to 考核期结束预估
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:51:37 +08:00
kkfluous
f6f872d2ce fix(scheduling): remove duplicate mileage text, keep only metrics row
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:50:42 +08:00
kkfluous
dbefb90089 feat(scheduling): add metrics row (当前/预估年终/考核) for current vehicle
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:49:43 +08:00
kkfluous
db568c1ebb fix(scheduling): rename 全部批次→全部, reorder metrics to 当前/替换后预计/考核
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:48:26 +08:00
kkfluous
ba6a38973d feat(scheduling): add batch filter and sort controls for candidate list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Batch dropdown filter (全部批次 / per target name)
- Sort by 替换后预计 (asc/desc toggle)
- Sort by 当前里程 (asc/desc toggle)
- Active sort button highlighted in blue
- Display count shows filtered/total (e.g. "3/12 辆")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:46:42 +08:00
kkfluous
7aa0d961ce feat(scheduling): show all candidates instead of top 5, update section title
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:44:51 +08:00
kkfluous
4f02a54d38 fix(scheduling): move region to header as pin badge, remove from metrics grid
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:39:31 +08:00
kkfluous
073496cd44 fix(scheduling): reorder candidate metrics to 区域/考核/当前/替换后预计, remove 缺口
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:38:25 +08:00
kkfluous
bea67b6710 fix(scheduling): remove decimals from km display (1,990.2 → 1,990)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:37:14 +08:00
kkfluous
d0984a430b refactor(scheduling): improve reason text, fix classification, polish detail view
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Classification: qualified requires actual completionRate >= 100% (not just predicted)
- Reason text: structured two-column layout (客户日均 | 考核周期剩余)
- Conclusion line in red bold (预估无法达标,需替换 / 已达标,建议换上未达标车辆)
- Remove verbose subtitle from candidate section
- Remove redundant middle line (预估考核期里程 vs 考核里程)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:32:50 +08:00
kkfluous
b3a6beb26b fix(scheduling): update card titles per user request
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:12:19 +08:00
kkfluous
0a7a9a096d refactor(scheduling): condense reason text to data-only one-liner
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:11:37 +08:00
kkfluous
dd03157804 refactor(scheduling): simplify card labels for clarity
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:09:27 +08:00
kkfluous
694e9a207a feat(scheduling): enable department/personal permission filtering
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Disable BYPASS_AUTH (was true, now false) — backend enforces JWT auth
- Scheduling suggestions filtered by department/manager permissions:
  - full: see all suggestions
  - department: see only own department's vehicles
  - personal: see only own managed vehicles
- Candidate vehicles (inventory) remain fully visible to all
- Summary recalculated after permission filtering
- Consistent with mileage module permission model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:04:52 +08:00
kkfluous
fb89c9beed fix(scheduling): change 辆 to 干预 in suggestion list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 21:57:35 +08:00
69 changed files with 9979 additions and 506 deletions

View File

@@ -13,6 +13,7 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY src/server ./src/server COPY src/server ./src/server
COPY src/shared ./src/shared
COPY tsconfig.json ./ COPY tsconfig.json ./
EXPOSE 3001 EXPOSE 3001

File diff suppressed because it is too large Load Diff

View 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` 时同名 exportUI 切到 `useEffect + fetch` 即可。
5. **icon 选型**`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`
## 验收标准
- [ ] 底部 nav 多出「能源管理」Tabicon Zap登录后可见
- [ ] 进入后默认在「氢能 → 总览」
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
- [ ] 切到电能 Tab3 张 mini KPI + 月份分组表,月份能展开日级行
- [ ] 横屏不出现底部 nav 遮挡内容
- [ ] `npm run lint` 通过
- [ ] 不引入新依赖recharts / lucide-react / motion 已有)

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,36 +1,60 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useState } 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 EleImportPage from './modules/ele/EleImportPage';
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
import AuthProvider from './auth/AuthProvider'; import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth'; import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage'; import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
const SCHEDULING_ALLOWED_USERS = new Set([
'1105261382487539712',
'1116631120763437056',
]);
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 },
]; ];
const ENERGY_MODULE: ModuleConfig = {
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
};
const SCHEDULING_MODULE: ModuleConfig = { const SCHEDULING_MODULE: ModuleConfig = {
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule, id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
}; };
function getRouteKey(): string {
if (typeof window === 'undefined') return '';
const path = window.location.pathname;
const hash = window.location.hash;
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
return '';
}
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const [routeKey, setRouteKey] = useState(getRouteKey);
// 监听 hashchange / popstate让 a href="#/..." 跳转能即时生效
useEffect(() => {
const update = () => setRouteKey(getRouteKey());
window.addEventListener('hashchange', update);
window.addEventListener('popstate', update);
return () => {
window.removeEventListener('hashchange', update);
window.removeEventListener('popstate', update);
};
}, []);
const modules = useMemo(() => { const modules = useMemo(() => {
if (user?.userId && SCHEDULING_ALLOWED_USERS.has(user.userId)) { const result = [...BASE_MODULES];
return [...BASE_MODULES, SCHEDULING_MODULE]; if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
} if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return BASE_MODULES; return result;
}, [user?.userId]); }, [user?.roles]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -47,6 +71,10 @@ function AuthGate() {
return <UnauthorizedPage message={error || undefined} />; return <UnauthorizedPage message={error || undefined} />;
} }
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === 'ele/import') return <EleImportPage />;
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
return <Shell modules={modules} />; return <Shell modules={modules} />;
} }

View File

@@ -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', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
},
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) {

View File

@@ -3,7 +3,13 @@ import { createContext, useContext } from 'react';
export interface AuthState { export interface AuthState {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
user: { userId: string; userName: string; permissionLevel: string; depName: string } | null; user: {
userId: string;
userName: string;
permissionLevel: string;
depName: string;
roles?: string[];
} | null;
error: string | null; error: string | null;
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, type ComponentType } from 'react'; import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur'; import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
export interface ModuleConfig { export interface ModuleConfig {
id: string; id: string;
@@ -15,6 +16,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 {
@@ -105,6 +107,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{/* 内容区 */} {/* 内容区 */}
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}> <main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
{ActiveComponent && <ActiveComponent />} {ActiveComponent && <ActiveComponent />}
<FeedbackFab module={activeModule} />
</main> </main>
{/* 移动端底部导航 (md 以下) */} {/* 移动端底部导航 (md 以下) */}

View File

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

View File

@@ -35,6 +35,7 @@ import type { WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
// --- Constants --- // --- Constants ---
@@ -223,11 +224,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 不属于 weeklyweekly-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 +249,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;
@@ -2180,7 +2190,7 @@ export default function AssetsModule() {
</td> </td>
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td> <td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td> <td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 库存` }); }}>{tb.inventory}</td> <td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}</td>
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td> <td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
</tr> </tr>
))} ))}
@@ -2242,9 +2252,9 @@ export default function AssetsModule() {
</span> </span>
<span <span
className="font-bold text-orange-600 cursor-pointer" className="font-bold text-orange-600 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 库存` })} onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })}
> >
:{tb.inventory} :{tb.pending}
</span> </span>
</div> </div>
</div> </div>
@@ -2829,7 +2839,7 @@ export default function AssetsModule() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<RotatingFooterHint className="pb-4" />
</div> </div>
); );
} }

View File

@@ -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()}`);
} }

View File

@@ -152,6 +152,7 @@ export interface RegionTypeBreakdown {
total: number; total: number;
operating: number; operating: number;
inventory: number; inventory: number;
pending: number;
customers: string[]; customers: string[];
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import ETCView from './ETCView';
type TopTab = 'hydrogen' | 'electric' | 'etc';
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
{ key: 'hydrogen', label: '氢能', icon: Fuel },
{ key: 'electric', label: '电能', icon: BatteryCharging },
{ key: 'etc', label: 'ETC', icon: Receipt },
];
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
{/* 统一 sticky 头部top tab + (氢能时) 子 tab同一张卡片无间隙 */}
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 顶部 tab氢能 / 电能 / ETC */}
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
{TABS.map(tab => {
const Icon = tab.icon;
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
>
<Icon size={14} />
<span className="text-[11px] font-bold">{tab.label}</span>
{active && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
);
})}
</div>
{/* 子 tab氢能 / 电能 都显示 每日 / 总览 */}
{showSubTabs && (
<div className="p-1 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = currentSub === id;
return (
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
{activeTab === 'etc' && <ETCView />}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

47
src/modules/energy/api.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
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';
import VehicleDetailModal from './VehicleDetailModal';
const SearchableSelect = ({ const SearchableSelect = ({
options, options,
@@ -102,15 +105,18 @@ 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 [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [filterDate, setFilterDate] = useState(() => { const [filterDate, setFilterDate] = useState(() => {
const now = new Date(); const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1); if (now.getHours() < 5) now.setDate(now.getDate() - 1);
@@ -119,7 +125,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 +153,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 +166,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 +186,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 +196,53 @@ 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]);
// 区域级联plate 选项收窄后,剔除已选但已不属于该区域的车牌
useEffect(() => {
if (filterPlates.length === 0) return;
const valid = new Set(filterOptions.plates);
const next = filterPlates.filter(p => valid.has(p));
if (next.length !== filterPlates.length) setFilterPlates(next);
}, [filterOptions.plates, filterPlates]);
// 下载当前筛选结果为 xlsx
const handleDownload = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
const d = await fetchMonitoring({
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined,
});
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
} catch (err) {
console.error('export failed', err);
} finally {
setExporting(false);
}
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
// 每分钟自动刷新 // 每分钟自动刷新
useEffect(() => { useEffect(() => {
const timer = setInterval(loadFirstPage, 60 * 1000); const timer = setInterval(loadFirstPage, 60 * 1000);
@@ -260,14 +308,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 +440,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 +570,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 +611,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 +664,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 +708,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 +737,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 +779,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 +811,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('');
}; };
@@ -843,10 +901,8 @@ export default function MonitoringView() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
key={v.plate} key={v.plate}
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all" className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
onClick={() => { onClick={() => setDetailVehicle(v)}
navigator.clipboard.writeText(v.plate);
}}
> >
<div className="flex items-center gap-3 overflow-hidden flex-1"> <div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
@@ -910,6 +966,8 @@ export default function MonitoringView() {
<div ref={sentinelRef} className="h-1" /> <div ref={sentinelRef} className="h-1" />
</div> </div>
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
{/* 回到顶部按钮 */} {/* 回到顶部按钮 */}
<AnimatePresence> <AnimatePresence>
{showBackToTop && ( {showBackToTop && (

View File

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

View File

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

View File

@@ -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);
@@ -59,3 +61,29 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
params.set('days', String(days)); params.set('days', String(days));
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`); return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
} }
export interface VehicleRecentDay {
date: string;
dailyKm: number;
isDataSynced: boolean;
}
export interface VehicleRecentResponse {
plate: string;
start?: string;
end?: string;
days: VehicleRecentDay[];
}
export async function fetchVehicleRecent(
plate: string,
range: { days?: number; start?: string; end?: string } = { days: 15 },
): Promise<VehicleRecentResponse> {
const params = new URLSearchParams();
if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days));
return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
);
}

View File

@@ -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 {

View 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);
}

View File

@@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchNotifications, updateNotification } from './api';
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
interface Props {
onClose: () => void;
onChange?: () => void;
/** When true, pre-filter to the last 7 days (excluding cancelled). */
recentOnly?: boolean;
/** Current suggestions used to enrich records with customer/dept/manager and enable drill-down. */
suggestions?: SchedulingSuggestion[];
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function shortDept(dept: string | null | undefined): string {
return (dept || '').replace('业务', '');
}
type StatusTab = 'all' | NotificationStatus;
const STATUS_TABS: { key: StatusTab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'sent', label: '待执行' },
{ key: 'executed', label: '已执行' },
{ key: 'cancelled', label: '已取消' },
];
function statusBadge(status: NotificationStatus) {
if (status === 'sent') return { text: '待执行', icon: <Send size={9} />, cls: 'text-amber-700 bg-amber-50' };
if (status === 'executed') return { text: '已执行', icon: <CheckCircle2 size={9} />, cls: 'text-emerald-700 bg-emerald-50' };
return { text: '已取消', icon: <XCircle size={9} />, cls: 'text-slate-500 bg-slate-100' };
}
function fmtDateTime(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}`;
}
export default function NotificationHistory({ onClose, onChange, recentOnly = false, suggestions }: Props) {
const [records, setRecords] = useState<NotificationRecord[]>([]);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState<StatusTab>('all');
const [recent7d, setRecent7d] = useState(recentOnly);
const [mutatingId, setMutatingId] = useState<number | null>(null);
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
const [afterMileageInput, setAfterMileageInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const [drillTarget, setDrillTarget] = useState<{ suggestion: SchedulingSuggestion; candidate: CandidateVehicle } | null>(null);
const suggestionById = useMemo(() => {
const map = new Map<string, SchedulingSuggestion>();
for (const s of suggestions ?? []) map.set(s.id, s);
return map;
}, [suggestions]);
const visibleRecords = recent7d
? records.filter(r => {
const t = Date.parse(r.createdAt);
return Number.isFinite(t) && Date.now() - t <= SEVEN_DAYS_MS;
})
: records;
const load = useCallback(async () => {
setLoading(true);
try {
const resp = await fetchNotifications(tab === 'all' ? undefined : tab);
setRecords(resp.records);
} finally {
setLoading(false);
}
}, [tab]);
useEffect(() => { load(); }, [load]);
const handleExecuteClick = (rec: NotificationRecord) => {
setExecuteTarget(rec);
setAfterMileageInput('');
setNotesInput('');
};
const handleExecuteConfirm = async () => {
if (!executeTarget) return;
setMutatingId(executeTarget.id);
try {
const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' };
if (notesInput.trim()) body.notes = notesInput.trim();
const parsed = Number(afterMileageInput);
if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed;
await updateNotification(executeTarget.id, body);
setExecuteTarget(null);
await load();
onChange?.();
} finally {
setMutatingId(null);
}
};
const handleCancel = async (rec: NotificationRecord) => {
if (!confirm(`确定取消 ${rec.currentPlate}${rec.candidatePlate} 的干预?`)) return;
setMutatingId(rec.id);
try {
await updateNotification(rec.id, { status: 'cancelled' });
await load();
onChange?.();
} finally {
setMutatingId(null);
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4"
>
{/* Header */}
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<Clock size={16} className="text-white" />
<span className="text-white font-bold text-sm"></span>
</div>
<div className="flex items-center gap-1">
<button onClick={load} disabled={loading} className="text-slate-300 hover:text-white p-1 cursor-pointer">
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
</button>
<button onClick={onClose} className="text-slate-300 hover:text-white p-1 cursor-pointer">
<X size={18} />
</button>
</div>
</div>
{/* Status tabs */}
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0 flex-wrap items-center">
{STATUS_TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
tab === t.key ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{t.label}
</button>
))}
<div className="ml-auto">
<button
onClick={() => setRecent7d(v => !v)}
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
recent7d ? 'bg-emerald-600 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
title="仅看最近 7 天"
>
7
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{loading && records.length === 0 ? (
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
<Loader2 size={14} className="animate-spin" />
</div>
) : visibleRecords.length === 0 ? (
<div className="py-16 text-center text-slate-400">
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
<p className="text-sm">{recent7d ? '最近 7 天暂无干预记录' : '暂无记录'}</p>
</div>
) : (
<div className="divide-y divide-slate-50">
{visibleRecords.map(rec => {
const badge = statusBadge(rec.status);
const busy = mutatingId === rec.id;
const suggestion = suggestionById.get(rec.suggestionId);
const candidate = suggestion?.candidates.find(c => c.plateNumber === rec.candidatePlate) ?? null;
const canDrill = !!suggestion && !!candidate;
const v = suggestion?.currentVehicle;
const handleRowClick = () => {
if (canDrill && suggestion && candidate) setDrillTarget({ suggestion, candidate });
};
return (
<div
key={rec.id}
onClick={handleRowClick}
className={`px-4 py-3 transition-colors ${canDrill ? 'cursor-pointer hover:bg-slate-50/60 active:bg-slate-100' : ''}`}
>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 text-xs min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
{badge.icon} {badge.text}
</span>
{canDrill && <ChevronRight size={12} className="text-slate-300" />}
</div>
</div>
{v && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 mb-0.5 truncate">
{v.department && <span className="font-medium">{shortDept(v.department)}</span>}
{v.manager && <span>{v.manager}</span>}
<span className="text-slate-400 truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-400">
{rec.operatorName && <span> {rec.operatorName}</span>}
<span>{fmtDateTime(rec.createdAt)}</span>
{rec.status === 'executed' && rec.executedAt && (
<span className="text-emerald-500"> {fmtDateTime(rec.executedAt)}</span>
)}
</div>
{rec.notes && (
<div className="mt-1 text-[10px] text-slate-500 bg-slate-50 rounded px-2 py-1">{rec.notes}</div>
)}
{rec.status === 'sent' && (
<div className="mt-2 flex items-center gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => handleExecuteClick(rec)}
disabled={busy}
className="text-[10px] font-bold text-white bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-2.5 py-1 rounded cursor-pointer transition-colors flex items-center gap-1"
>
<CheckCircle2 size={10} />
</button>
<button
onClick={() => handleCancel(rec)}
disabled={busy}
className="text-[10px] font-medium text-slate-500 hover:text-rose-600 disabled:opacity-50 px-2 py-1 rounded cursor-pointer transition-colors"
>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</motion.div>
{/* Execute confirmation modal */}
<AnimatePresence>
{executeTarget && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[80] flex items-end sm:items-center justify-center"
onClick={() => mutatingId === null && setExecuteTarget(null)}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4"
>
<div className="bg-emerald-600 px-4 py-3 flex items-center justify-between">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => mutatingId === null && setExecuteTarget(null)}
disabled={mutatingId !== null}
className="text-emerald-100 hover:text-white p-1 cursor-pointer disabled:opacity-50"
>
<X size={16} />
</button>
</div>
<div className="px-4 py-4 space-y-3">
<div className="text-xs text-slate-500">
<span className="font-mono font-bold text-slate-900"><Blur>{executeTarget.currentPlate}</Blur></span>
<span className="mx-1.5"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{executeTarget.candidatePlate}</Blur></span>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> (km, )</label>
<input
type="number"
inputMode="numeric"
value={afterMileageInput}
onChange={e => setAfterMileageInput(e.target.value)}
placeholder="例如 45230"
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all"
/>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> ()</label>
<textarea
value={notesInput}
onChange={e => setNotesInput(e.target.value)}
rows={2}
placeholder="例如:司机已到位,交接完成"
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all resize-none"
/>
</div>
</div>
<div className="border-t border-slate-100 px-4 py-3 flex gap-2">
<button
onClick={() => setExecuteTarget(null)}
disabled={mutatingId !== null}
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={handleExecuteConfirm}
disabled={mutatingId !== null}
className="flex-1 py-2 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{mutatingId !== null ? '保存中...' : '确认'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Drill-down: replacement plan */}
{drillTarget && (
<SwapPreview
suggestion={drillTarget.suggestion}
candidate={drillTarget.candidate}
onClose={() => setDrillTarget(null)}
onSuccess={() => { load(); onChange?.(); }}
/>
)}
</div>
);
}

View File

@@ -1,10 +1,14 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Filter, RotateCcw, X, Search, ChevronDown } from 'lucide-react'; import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions } from './api'; import { fetchSuggestions, sendNotifyBatch } from './api';
import type { SchedulingResponse, SchedulingSuggestion } from './types'; import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
import SuggestionList from './SuggestionList'; import SuggestionList from './SuggestionList';
import SuggestionDetail from './SuggestionDetail'; import SuggestionDetail from './SuggestionDetail';
import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
type TypeFilter = 'all' | 'qualified' | 'hopeless'; type TypeFilter = 'all' | 'qualified' | 'hopeless';
@@ -143,6 +147,16 @@ function SkeletonPage() {
); );
} }
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
// Business rule: at most one active intervention per suggestion. If ANY
// candidate is already intervened, skip the whole suggestion in batch flow.
const hasActive = s.candidates.some(
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
);
if (hasActive) return null;
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
}
export default function SchedulingModule() { export default function SchedulingModule() {
const [data, setData] = useState<SchedulingResponse | null>(null); const [data, setData] = useState<SchedulingResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -152,6 +166,13 @@ export default function SchedulingModule() {
const [showFilter, setShowFilter] = useState(false); const [showFilter, setShowFilter] = useState(false);
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS); const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS); const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
const [batchInFlight, setBatchInFlight] = useState(false);
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -161,6 +182,64 @@ export default function SchedulingModule() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]); const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
// Keep selectedSuggestion synced with latest data so candidate notification
// status changes (登记 / 取消干预) propagate into the open detail modal.
useEffect(() => {
if (!selectedSuggestion || !data) return;
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
if (!fresh) setSelectedSuggestion(null);
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
}, [data, selectedSuggestion]);
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const exitSelectMode = useCallback(() => {
setSelectMode(false);
setSelectedIds(new Set());
setShowBatchConfirm(false);
}, []);
const batchItems = useMemo(() => {
if (!data) return [];
return [...selectedIds]
.map(id => data.suggestions.find(s => s.id === id))
.filter((s): s is SchedulingSuggestion => !!s)
.map(s => {
const candidate = pickBestCandidate(s);
if (!candidate) return null;
return { suggestion: s, candidate };
})
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
}, [data, selectedIds]);
const handleBatchSubmit = useCallback(async () => {
if (batchItems.length === 0) return;
setBatchInFlight(true);
try {
const resp = await sendNotifyBatch({
items: batchItems.map(i => ({
suggestionId: i.suggestion.id,
currentPlate: i.suggestion.currentVehicle.plateNumber,
candidatePlate: i.candidate.plateNumber,
})),
});
setBatchResultMsg(resp.message);
await loadData();
exitSelectMode();
} catch (e) {
console.error('batch notify failed:', e);
setBatchResultMsg('批量干预失败,请重试');
} finally {
setBatchInFlight(false);
}
}, [batchItems, loadData, exitSelectMode]);
const filterOptions = useMemo(() => { const filterOptions = useMemo(() => {
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] }; if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>(); const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
@@ -200,7 +279,7 @@ export default function SchedulingModule() {
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0"> <div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* ===== Summary Cards ===== */} {/* ===== Summary Cards ===== */}
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
{/* 里程高·换下 — warm orange */} {/* 里程高·换下 — warm orange */}
<button <button
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
@@ -211,14 +290,14 @@ export default function SchedulingModule() {
}`} }`}
> >
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}> <div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
·
</div> </div>
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}> <div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
{loading && !data ? '-' : summary?.qualifiedCount ?? 0} {loading && !data ? '-' : summary?.qualifiedCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span> <span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span>
</div> </div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}> <div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
</div> </div>
</button> </button>
@@ -232,14 +311,14 @@ export default function SchedulingModule() {
}`} }`}
> >
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}> <div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
·
</div> </div>
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}> <div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
{loading && !data ? '-' : summary?.hopelessCount ?? 0} {loading && !data ? '-' : summary?.hopelessCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span> <span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span>
</div> </div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}> <div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
</div> </div>
</button> </button>
@@ -260,7 +339,24 @@ export default function SchedulingModule() {
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span> <span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span>
</div> </div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}> <div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
+{summary?.estimatedGain ?? 0} +{summary?.estimatedGain ?? 0}
</div>
</button>
{/* 近期已干预 — emerald */}
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
>
<div className="text-[10px] font-bold mb-1 text-emerald-600">
</div>
<div className="text-2xl font-black text-emerald-700">
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
<span className="text-[10px] font-normal ml-1 text-emerald-400"></span>
</div>
<div className="text-[9px] mt-0.5 text-emerald-400">
7 ·
</div> </div>
</button> </button>
</div> </div>
@@ -277,6 +373,33 @@ export default function SchedulingModule() {
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"> className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} /> <RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
</button> </button>
<button
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
disabled={filteredSuggestions.length === 0}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="导出 CSV"
>
<Download size={15} />
</button>
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
title="调度记录"
>
<Clock size={15} />
</button>
<button
onClick={() => {
if (selectMode) exitSelectMode();
else { setSelectMode(true); setSelectedSuggestion(null); }
}}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
title={selectMode ? '退出多选' : '多选模式'}
>
<CheckSquare size={15} />
</button>
<button <button
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }} onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${ className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
@@ -391,14 +514,126 @@ export default function SchedulingModule() {
))} ))}
</div> </div>
) : ( ) : (
<SuggestionList suggestions={filteredSuggestions} onSelect={setSelectedSuggestion} /> <SuggestionList
suggestions={filteredSuggestions}
onSelect={setSelectedSuggestion}
selectMode={selectMode}
selectedIds={selectedIds}
onToggleSelect={toggleSelect}
/>
)} )}
</div> </div>
{selectedSuggestion && ( {selectedSuggestion && (
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} /> <SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
)} )}
{showHistory && (
<NotificationHistory
onClose={() => setShowHistory(false)}
onChange={loadData}
recentOnly={historyRecentOnly}
suggestions={data?.suggestions}
/>
)}
{/* Batch action bar */}
<AnimatePresence>
{selectMode && (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium"></span>
<span className="text-lg font-black">{selectedIds.size}</span>
<span className="text-xs text-slate-400"></span>
</div> </div>
<div className="flex items-center gap-2">
<button
onClick={exitSelectMode}
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
>
</button>
<button
onClick={() => setShowBatchConfirm(true)}
disabled={selectedIds.size === 0}
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
>
<Send size={12} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Batch confirmation modal */}
{showBatchConfirm && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
>
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
disabled={batchInFlight}
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
>
<X size={18} />
</button>
</div>
<div className="px-4 py-3 overflow-y-auto flex-1">
<p className="text-xs text-slate-500 mb-3">
<span className="font-bold text-slate-800">{batchItems.length}</span>
</p>
<div className="space-y-2">
{batchItems.map(({ suggestion, candidate }) => (
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
</div>
{candidate.canQualifyAfterSwap ? (
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0"></span>
) : (
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0"></span>
)}
</div>
))}
</div>
{batchResultMsg && (
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
)}
</div>
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
<button
onClick={() => setShowBatchConfirm(false)}
disabled={batchInFlight}
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={handleBatchSubmit}
disabled={batchInFlight || batchItems.length === 0}
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length}`}
</button>
</div>
</motion.div>
</div>
)}
</div>
<RotatingFooterHint className="pb-4" />
</div> </div>
); );
} }

View File

@@ -1,12 +1,15 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { import {
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, Lock,
} from 'lucide-react'; } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview'; import SwapPreview from './SwapPreview';
type SortKey = 'predicted' | 'current';
type SortDir = 'asc' | 'desc';
interface Props { interface Props {
suggestion: SchedulingSuggestion; suggestion: SchedulingSuggestion;
onClose: () => void; onClose: () => void;
@@ -15,20 +18,129 @@ interface Props {
function fmtKm(value: number): string { function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万'; if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return value.toLocaleString(); return Math.round(value).toLocaleString();
} }
function fmtRate(rate: number): string { function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%'; return (rate * 100).toFixed(1) + '%';
} }
const CUSTOMER_REASON_LABELS = new Set(['客户日均']);
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) { export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null); const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set()); const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
const [batchFilter, setBatchFilter] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('predicted');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const v = s.currentVehicle; const v = s.currentVehicle;
const isRescue = s.type === 'rescue_hopeless'; const isRescue = s.type === 'rescue_hopeless';
// Business rule: a current vehicle can have AT MOST ONE active intervention.
// Find the active candidate (if any) — other candidates are blocked until
// this one is cancelled.
const activeIntervention = s.candidates.find(
cc => cc.notificationStatus === 'sent' || cc.notificationStatus === 'executed',
);
// Batch options from candidates
const batchOptions = useMemo(() => {
const set = new Set<string>();
for (const c of s.candidates) if (c.targetName) set.add(c.targetName);
return [...set].sort();
}, [s.candidates]);
// Filtered + sorted candidates, grouped by region
const { sameRegion, crossRegion } = useMemo(() => {
let list = s.candidates;
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
const sorted = [...list].sort((a, b) => {
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
return sortDir === 'desc' ? vb - va : va - vb;
});
return {
sameRegion: sorted.filter(c => c.isSameRegion),
crossRegion: sorted.filter(c => !c.isSameRegion),
};
}, [s.candidates, batchFilter, sortKey, sortDir]);
const displayCount = sameRegion.length + crossRegion.length;
const toggleSort = (key: SortKey) => {
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
else { setSortKey(key); setSortDir('desc'); }
};
const renderCandidate = (c: CandidateVehicle) => {
const sent =
sentPlates.has(c.plateNumber) ||
c.notificationStatus === 'sent' ||
c.notificationStatus === 'executed';
const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber;
return (
<div key={c.plateNumber} className={`rounded-xl border overflow-hidden bg-white ${blockedByOther ? 'border-slate-200 opacity-60' : 'border-slate-200'}`}>
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
</span>
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
<span className="text-[9px] text-slate-400">{c.daysLeft}</span>
</div>
{c.canQualifyAfterSwap ? (
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded flex-shrink-0">
<CheckCircle size={10} />
</span>
) : (
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded flex-shrink-0">
<AlertTriangle size={10} />
</span>
)}
</div>
<div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
</div>
</div>
</div>
<div className="px-3 pb-2.5">
{blockedByOther ? (
<div className="w-full flex items-center justify-center gap-1.5 text-[11px] font-medium py-2 rounded-lg bg-slate-50 text-slate-400 cursor-not-allowed">
<Lock size={11} />
</div>
) : (
<button
onClick={() => setPreviewCandidate(c)}
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
sent
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
}`}
>
{sent ? <><CheckCircle size={12} /> · <ArrowRight size={12} /></> : <> <ArrowRight size={12} /></>}
</button>
)}
</div>
</div>
);
};
return ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}> <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
<motion.div <motion.div
@@ -56,121 +168,184 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
{/* Body */} {/* Body */}
<div className="overflow-y-auto flex-1 no-scrollbar"> <div className="overflow-y-auto flex-1 no-scrollbar">
{/* Current Vehicle */} {/* Current Vehicle — same format as candidate cards */}
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100"> <div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-1.5"> <div className="rounded-xl border border-slate-200 overflow-hidden bg-white">
<div className="flex items-center gap-2"> {/* Header — same style as candidate header */}
<span className="text-base font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span> <div className="flex items-center justify-between px-3 py-2">
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white text-slate-500 font-bold border border-slate-200">{v.vehicleType}</span> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span>
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{v.region}</span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">{v.targetName}</span>
<span className="text-[9px] text-slate-400">{v.daysLeft}</span>
</div> </div>
<span className={`text-lg font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-600'}`}> <span className={`text-sm font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>
{fmtRate(v.completionRate)} {fmtRate(v.completionRate)}
</span> </span>
</div> </div>
{/* Customer + dept/manager info */}
<div className="text-[10px] text-slate-500 space-y-0.5"> <div className="px-3 pb-1.5 flex items-center gap-2 text-[10px] text-slate-500 flex-wrap">
<div className="flex items-center gap-2 flex-wrap"> {v.department && <span><b className="text-slate-700">{v.department}</b></span>}
<span className="text-slate-400">{v.targetName}</span> {v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
<span className="text-slate-200">|</span> {(v.department || v.manager) && <span className="text-slate-200">|</span>}
<span> <b className="text-slate-700">{fmtKm(v.currentYearMileage)}</b></span>
<span> <b className="text-slate-700">{fmtKm(v.yearTarget)}</b> km</span>
<span className="flex items-center gap-0.5"><MapPin size={9} /> {v.region}</span>
</div>
<div className="flex items-center gap-2">
<span> <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span> <span> <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span> <span>
</div> 30 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
</div>
</div>
{/* Reason */}
<div className="px-4 py-2 text-[11px] text-slate-500 leading-relaxed border-b border-slate-100 bg-amber-50/50">
<span className="text-amber-700 font-bold"></span>
<span className="text-slate-600">{s.reason}</span>
</div>
{/* Candidates */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-slate-700">
{isRescue ? '从库存调入替换' : '换上以下里程少的车'}
</span> </span>
<span className="text-[10px] text-slate-400">{s.candidates.length} </span>
</div> </div>
<div className="text-[10px] text-slate-400 mb-2.5"> {/* Metrics */}
{isRescue
? '以下车辆快达标,换到当前客户处利用剩余天数即可冲线'
: '以下车辆里程缺口大,换到该高里程客户处可加速达标'
}
</div>
<div className="space-y-2">
{s.candidates.map(c => {
const sent = sentPlates.has(c.plateNumber);
return (
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
</div>
{c.canQualifyAfterSwap ? (
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded">
<CheckCircle size={10} />
</span>
) : (
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded">
<AlertTriangle size={10} />
</span>
)}
</div>
{/* Metrics — compact table style */}
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200"> <div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center"> <div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div> <div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div> <div className="font-bold text-slate-700">{fmtKm(v.currentYearMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>{fmtKm(v.currentYearMileage + v.customerAvgDaily * v.daysLeft)}</div>
</div> </div>
<div className="flex-1 py-1.5 px-2 text-center"> <div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div> <div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div> <div className="font-bold text-blue-700">{fmtKm(v.yearTarget)}</div>
</div> </div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-rose-400"></div>
<div className="font-bold text-rose-600">{fmtKm(c.mileageGap)}</div>
</div> </div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{c.region}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
</div> </div>
</div> </div>
</div> </div>
{/* Action */} {/* Reason — customer vs vehicle columns */}
<div className="px-3 pb-2.5"> <div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
<div className="grid grid-cols-2 gap-x-5">
{(() => {
const customerLines = s.reason.lines.filter(l => CUSTOMER_REASON_LABELS.has(l.label));
const vehicleLines = s.reason.lines.filter(l => !CUSTOMER_REASON_LABELS.has(l.label));
return (
<>
<div>
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1"></div>
<div className="space-y-1">
{customerLines.map((line, i) => (
<div key={i} className="flex items-center justify-between text-[11px]">
<span className="text-slate-500">{line.label}</span>
<span className="text-slate-700 font-medium">{line.value}</span>
</div>
))}
</div>
</div>
<div>
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1"></div>
<div className="space-y-1">
{vehicleLines.map((line, i) => (
<div key={i} className="flex items-center justify-between text-[11px]">
<span className="text-slate-500">{line.label}</span>
<span className="text-slate-700 font-medium">{line.value}</span>
</div>
))}
</div>
</div>
</>
);
})()}
</div>
<div className="mt-2 pt-2 border-t border-slate-200">
<span className="text-xs font-bold text-rose-600">{s.reason.conclusion}</span>
</div>
</div>
{/* Candidates */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-bold text-slate-700"></span>
<span className="text-[10px] text-slate-400">{displayCount}/{s.candidates.length} </span>
</div>
{activeIntervention && (
<div className="mb-2.5 flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-800">
<Lock size={12} className="mt-0.5 flex-shrink-0" />
<span>
<b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b>
</span>
</div>
)}
{/* Filter + Sort controls */}
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
{/* Batch multi-select pills */}
<div className="flex items-center gap-1.5 flex-wrap">
<button <button
onClick={() => setPreviewCandidate(c)} onClick={() => setBatchFilter(new Set())}
disabled={sent} className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${ batchFilter.size === 0 ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
sent
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
}`} }`}
> >
{sent ? <><CheckCircle size={12} /> </> : <> <ArrowRight size={12} /></>}
</button>
{batchOptions.map(b => {
const active = batchFilter.has(b);
return (
<button
key={b}
onClick={() => setBatchFilter(prev => {
const next = new Set(prev);
if (active) next.delete(b); else next.add(b);
return next;
})}
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
active ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
}`}
>
{b}
</button> </button>
</div>
</div>
); );
})} })}
</div> </div>
{/* Sort buttons */}
<button
onClick={() => toggleSort('predicted')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'predicted' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'predicted' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'predicted' && <ArrowUpDown size={10} />}
</button>
<button
onClick={() => toggleSort('current')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'current' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'current' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'current' && <ArrowUpDown size={10} />}
</button>
</div>
{sameRegion.length > 0 && (
<div className="space-y-2">
{sameRegion.map(c => renderCandidate(c))}
</div>
)}
{crossRegion.length > 0 && (
<>
<div className="flex items-center gap-2 my-3">
<div className="flex-1 h-px bg-slate-200" />
<span className="text-[10px] text-slate-400 font-medium"> · {crossRegion.length} </span>
<div className="flex-1 h-px bg-slate-200" />
</div>
<div className="space-y-2">
{crossRegion.map(c => renderCandidate(c))}
</div>
</>
)}
{displayCount === 0 && (
<div className="py-8 text-center text-xs text-slate-400"></div>
)}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { ArrowRightLeft, ChevronRight } from 'lucide-react'; import { useState, useMemo } from 'react';
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { SchedulingSuggestion } from './types'; import type { SchedulingSuggestion } from './types';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
@@ -6,13 +7,40 @@ import Blur from '../../components/Blur';
interface Props { interface Props {
suggestions: SchedulingSuggestion[]; suggestions: SchedulingSuggestion[];
onSelect: (s: SchedulingSuggestion) => void; onSelect: (s: SchedulingSuggestion) => void;
selectMode?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
}
function hasActiveNotification(s: SchedulingSuggestion): boolean {
return s.candidates.some(c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
} }
function fmtRate(rate: number): string { function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%'; return (rate * 100).toFixed(1) + '%';
} }
export default function SuggestionList({ suggestions, onSelect }: Props) { type SortKey = 'default' | 'avgDaily' | 'completion';
type SortDir = 'asc' | 'desc';
export default function SuggestionList({ suggestions, onSelect, selectMode = false, selectedIds, onToggleSelect }: Props) {
const [sortKey, setSortKey] = useState<SortKey>('default');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleSort = (key: SortKey) => {
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
else { setSortKey(key); setSortDir('desc'); }
};
const sorted = useMemo(() => {
if (sortKey === 'default') return suggestions;
return [...suggestions].sort((a, b) => {
const va = sortKey === 'avgDaily' ? a.currentVehicle.customerAvgDaily : a.currentVehicle.completionRate;
const vb = sortKey === 'avgDaily' ? b.currentVehicle.customerAvgDaily : b.currentVehicle.completionRate;
return sortDir === 'desc' ? vb - va : va - vb;
});
}, [suggestions, sortKey, sortDir]);
if (suggestions.length === 0) { if (suggestions.length === 0) {
return ( return (
<div className="py-16 text-center"> <div className="py-16 text-center">
@@ -23,10 +51,46 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
} }
return ( return (
<div>
{/* Sort controls */}
<div className="px-4 py-2 border-b border-slate-50 flex items-center gap-2">
<button
onClick={() => toggleSort('avgDaily')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'avgDaily' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'avgDaily' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'avgDaily' && <ArrowUpDown size={10} />}
</button>
<button
onClick={() => toggleSort('completion')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'completion' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'completion' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'completion' && <ArrowUpDown size={10} />}
</button>
</div>
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
{suggestions.map((s, idx) => { {sorted.map((s, idx) => {
const isRescue = s.type === 'rescue_hopeless'; const isRescue = s.type === 'rescue_hopeless';
const v = s.currentVehicle; const v = s.currentVehicle;
const notified = hasActiveNotification(s);
const isSelected = selectedIds?.has(s.id) ?? false;
const canSelect = selectMode && !notified;
const handleClick = () => {
if (selectMode) {
if (canSelect) onToggleSelect?.(s.id);
} else {
onSelect(s);
}
};
return ( return (
<motion.div <motion.div
@@ -34,43 +98,75 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: Math.min(idx * 0.02, 0.3) }} transition={{ delay: Math.min(idx * 0.02, 0.3) }}
className="px-4 py-3 hover:bg-slate-50/60 cursor-pointer transition-colors active:bg-slate-100 flex items-center gap-3" className={`px-4 py-3 hover:bg-slate-50/60 transition-colors flex items-center gap-3 ${
onClick={() => onSelect(s)} canSelect || !selectMode ? 'cursor-pointer active:bg-slate-100' : 'cursor-default opacity-60'
} ${isSelected ? 'bg-blue-50/60' : ''}`}
onClick={handleClick}
> >
{/* Checkbox (select mode) */}
{selectMode && (
<div
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-colors ${
isSelected
? 'bg-blue-600 border-blue-600 text-white'
: notified
? 'bg-slate-100 border-slate-200'
: 'bg-white border-slate-300'
}`}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</div>
)}
{/* Color bar */} {/* Color bar */}
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} /> <div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} />
{/* Info */} {/* Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-black text-slate-900 font-mono"> <span className="text-xs font-black text-slate-900 font-mono">
<Blur>{v.plateNumber}</Blur> <Blur>{v.plateNumber}</Blur>
</span> </span>
<span className={`text-[9px] px-1.5 py-px rounded font-bold ${
isRescue ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'
}`}>
{isRescue ? '里程低·换走' : '里程高·换下'}
</span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span> <span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">·</span> <span className="text-[9px] text-slate-300">·</span>
<span className="text-[9px] text-slate-400">{v.region}</span> <span className="text-[9px] text-slate-400">{v.region}</span>
{notified && (
<span className="text-[9px] font-bold text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded flex items-center gap-0.5">
<CheckCircle size={9} />
</span>
)}
</div>
<span className="text-[10px] flex-shrink-0">
<span className="text-slate-500"> </span>
<span className={`font-medium ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)}</span>
</span>
</div>
<div className="flex items-center justify-between mt-0.5 text-[10px] overflow-hidden">
<div className="flex items-center gap-1.5 text-slate-400 truncate">
{v.department && <span className="text-slate-500 font-medium">{v.department.replace('业务', '')}</span>}
{v.manager && <span className="text-slate-500">{v.manager}</span>}
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<span className="text-slate-500">
<span className="text-slate-700 font-medium">{Math.round(v.customerAvgDaily)}</span> km
</span>
</div> </div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-slate-400 overflow-hidden">
<span className="truncate max-w-[40%] flex-shrink"><Blur>{v.customer || '-'}</Blur></span>
<span className="flex-shrink-0"> <span className="text-slate-600 font-medium">{Math.round(v.customerAvgDaily)}</span> km</span>
<span className="flex-shrink-0"> <span className={`font-medium ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)}</span></span>
</div> </div>
</div> </div>
{/* Right */} {/* Right */}
<div className="flex items-center gap-1 flex-shrink-0"> {!selectMode && (
<span className="text-xs font-bold text-blue-600">{s.candidates.length}</span> <div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-[9px] text-slate-400"></span> <span className="text-[9px] text-slate-400"></span>
<ChevronRight size={14} className="text-slate-300" /> <ChevronRight size={14} className="text-slate-300" />
</div> </div>
)}
</motion.div> </motion.div>
); );
})} })}
</div> </div>
</div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ArrowDownUp, CheckCircle, Send, X } from 'lucide-react'; import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
import { sendNotify } from './api'; import { sendNotify, updateNotification } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
@@ -13,7 +13,7 @@ interface Props {
function fmtKm(value: number): string { function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万'; if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return value.toLocaleString(); return Math.round(value).toLocaleString();
} }
function fmtRate(rate: number): string { function fmtRate(rate: number): string {
@@ -23,10 +23,15 @@ function fmtRate(rate: number): string {
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) { export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [cancelling, setCancelling] = useState(false);
const v = s.currentVehicle; const v = s.currentVehicle;
const alreadyIntervened =
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
const isExecuted = c.notificationStatus === 'executed';
const handleSend = async () => { const handleSend = async () => {
if (sending || sent) return; if (sending || sent || alreadyIntervened) return;
setSending(true); setSending(true);
try { try {
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber }); const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
@@ -34,6 +39,21 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
} catch { alert('网络错误'); } finally { setSending(false); } } catch { alert('网络错误'); } finally { setSending(false); }
}; };
const handleCancel = async () => {
if (!c.notificationId || cancelling) return;
if (isExecuted) {
if (!confirm('此干预已标记为执行。确定要取消吗?')) return;
} else {
if (!confirm(`确定取消 ${v.plateNumber}${c.plateNumber} 的干预?`)) return;
}
setCancelling(true);
try {
const result = await updateNotification(c.notificationId, { status: 'cancelled' });
if (result.success) { onSuccess(); onClose(); }
else { alert(result.message || '取消失败'); }
} catch { alert('网络错误'); } finally { setCancelling(false); }
};
return ( return (
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col"> <div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
{/* Header */} {/* Header */}
@@ -58,6 +78,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
<div className="text-right"> <div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div> <div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {fmtKm(v.yearTarget)} km</div> <div className="text-[10px] text-slate-400"> {fmtKm(v.yearTarget)} km</div>
<div className="text-[10px] text-slate-400"> <b className="text-slate-700">{v.daysLeft}</b> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500"> <div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
@@ -115,7 +136,22 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
{/* Bottom */} {/* Bottom */}
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]"> <div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
<div className="max-w-sm mx-auto"> <div className="max-w-sm mx-auto space-y-2">
{alreadyIntervened && (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700 flex items-center gap-2">
<CheckCircle size={13} />
<span>{isExecuted ? '执行干预' : '登记干预'}</span>
</div>
)}
{alreadyIntervened ? (
<button
onClick={handleCancel}
disabled={cancelling}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold bg-white text-rose-600 border border-rose-200 hover:bg-rose-50 active:scale-[0.98] transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Ban size={16} /> {cancelling ? '取消中...' : '取消干预'}
</button>
) : (
<button <button
onClick={handleSend} onClick={handleSend}
disabled={sending || sent} disabled={sending || sent}
@@ -123,8 +159,9 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg' sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
}`} }`}
> >
{sent ? <><CheckCircle size={16} /> </> : <><Send size={16} /> </>} {sent ? <><CheckCircle size={16} /> </> : <><Send size={16} /> </>}
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,13 @@
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type { SchedulingResponse } from './types'; import type {
SchedulingResponse,
NotifyRequest,
NotifyBatchRequest,
NotifyBatchResult,
NotificationRecord,
NotificationStatus,
UpdateNotificationRequest,
} from './types';
const BASE = '/api/scheduling'; const BASE = '/api/scheduling';
@@ -10,14 +18,44 @@ export async function fetchSuggestions(targetId?: number): Promise<SchedulingRes
return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`); return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`);
} }
export async function sendNotify(body: { export async function sendNotify(
suggestionId: string; body: NotifyRequest,
currentPlate: string; ): Promise<{ success: boolean; message: string; record?: NotificationRecord }> {
candidatePlate: string; return fetchJson(`${BASE}/notify`, {
}): Promise<{ success: boolean; message: string }> {
return fetchJson<{ success: boolean; message: string }>(`${BASE}/notify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }
export async function sendNotifyBatch(
body: NotifyBatchRequest,
): Promise<{ success: boolean; message: string; result: NotifyBatchResult }> {
return fetchJson(`${BASE}/notify/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
export async function fetchNotifications(
status?: NotificationStatus,
limit?: number,
): Promise<{ records: NotificationRecord[] }> {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (limit) params.set('limit', String(limit));
const qs = params.toString();
return fetchJson(`${BASE}/notify${qs ? `?${qs}` : ''}`);
}
export async function updateNotification(
id: number,
body: UpdateNotificationRequest,
): Promise<{ success: boolean; record?: NotificationRecord; message?: string }> {
return fetchJson(`${BASE}/notify/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,103 @@
import type { SchedulingSuggestion, CandidateVehicle } from './types';
function csvCell(v: string | number | null | undefined): string {
if (v === null || v === undefined) return '';
const s = typeof v === 'number' ? String(v) : v;
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function pickTopCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
if (s.candidates.length === 0) return null;
const sameRegion = s.candidates.filter(c => c.isSameRegion);
const pool = sameRegion.length > 0 ? sameRegion : s.candidates;
return pool.find(c => c.canQualifyAfterSwap) ?? pool[0];
}
function pctString(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
function typeLabel(s: SchedulingSuggestion): string {
return s.type === 'replace_qualified' ? '里程高·换下' : '里程低·换走';
}
const HEADERS = [
'车牌号',
'业务部门',
'业务负责人',
'客户',
'车型',
'运营区域',
'调度类型',
'当前年里程(km)',
'年度考核(km)',
'年度完成率',
'客户30日均(km)',
'客户7日均(km)',
'剩余天数',
'最优候选车牌',
'候选当前里程(km)',
'候选替换后预估(km)',
'候选可达标',
'候选区域',
'干预状态',
] as const;
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {
const rows: string[] = [HEADERS.map(csvCell).join(',')];
for (const s of suggestions) {
const v = s.currentVehicle;
const top = pickTopCandidate(s);
const notifStatus =
s.candidates.find(c => c.notificationStatus === 'executed') ? '已执行'
: s.candidates.find(c => c.notificationStatus === 'sent') ? '待执行'
: '';
rows.push([
csvCell(v.plateNumber),
csvCell(v.department ?? ''),
csvCell(v.manager ?? ''),
csvCell(v.customer ?? ''),
csvCell(v.vehicleType),
csvCell(v.region),
csvCell(typeLabel(s)),
csvCell(Math.round(v.currentYearMileage)),
csvCell(Math.round(v.yearTarget)),
csvCell(pctString(v.completionRate)),
csvCell(Math.round(v.customerAvgDaily)),
csvCell(Math.round(v.customerAvgDaily7d)),
csvCell(v.daysLeft),
csvCell(top?.plateNumber ?? ''),
csvCell(top ? Math.round(top.totalMileage) : ''),
csvCell(top ? Math.round(top.predictedAfterSwap) : ''),
csvCell(top ? (top.canQualifyAfterSwap ? '是' : '否') : ''),
csvCell(top?.region ?? ''),
csvCell(notifStatus),
].join(','));
}
return rows.join('\r\n');
}
export function downloadCsv(filename: string, csv: string): void {
// UTF-8 BOM so Excel opens Chinese characters correctly
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function exportSuggestionsCsv(suggestions: SchedulingSuggestion[], prefix = '调度建议'): void {
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 csv = buildSuggestionsCsv(suggestions);
downloadCsv(`${prefix}_${y}${m}${d}_${hh}${mm}.csv`, csv);
}

View File

@@ -1,61 +1,16 @@
export interface SchedulingVehicleInfo { export type {
plateNumber: string; SchedulingVehicleInfo,
targetId: number; CandidateVehicle,
targetName: string; SchedulingSuggestion,
vehicleType: string; SchedulingSummary,
totalMileage: number; SchedulingTargetOption,
currentYearMileage: number; SchedulingResponse,
completionRate: number; NotifyRequest,
yearTarget: number; NotifyBatchRequest,
region: string; NotifyBatchResult,
province: string; NotificationStatus,
customer: string | null; NotificationRecord,
department: string | null; UpdateNotificationRequest,
manager: string | null; ReasonLine,
customerAvgDaily: number; ReasonBlock,
predictedYearEnd: number; } from '../../shared/scheduling/types';
daysLeft: number;
}
export interface CandidateVehicle {
plateNumber: string;
targetId: number | null;
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
region: string;
province: string;
mileageGap: number;
predictedAfterSwap: number;
canQualifyAfterSwap: boolean;
}
export interface SchedulingSuggestion {
id: string;
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: SchedulingVehicleInfo;
candidates: CandidateVehicle[];
reason: string;
}
export interface SchedulingSummary {
qualifiedCount: number;
hopelessCount: number;
suggestionCount: number;
estimatedGain: number;
}
export interface SchedulingTargetOption {
id: number;
name: string;
vehicleCount: number;
}
export interface SchedulingResponse {
summary: SchedulingSummary;
suggestions: SchedulingSuggestion[];
targets: SchedulingTargetOption[];
}

View File

@@ -66,6 +66,7 @@ app.get('/exchange', async (c) => {
depCode: userInfo.depCode, depCode: userInfo.depCode,
depName, depName,
permissionLevel, permissionLevel,
roles: roleNames,
}; };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' }); const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });

View File

@@ -5,7 +5,7 @@ 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', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
};
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();
@@ -35,6 +50,7 @@ export async function authMiddleware(c: Context, next: Next) {
depCode: payload.depCode, depCode: payload.depCode,
depName: payload.depName, depName: payload.depName,
permissionLevel: payload.permissionLevel, permissionLevel: payload.permissionLevel,
roles: payload.roles ?? [],
}; };
c.set('user', user); c.set('user', user);
return next(); return next();

View File

@@ -7,6 +7,7 @@ export interface AuthUser {
depCode: string; depCode: string;
depName: string; depName: string;
permissionLevel: PermissionLevel; permissionLevel: PermissionLevel;
roles: string[];
} }
export interface JwtPayload { export interface JwtPayload {
@@ -16,12 +17,20 @@ export interface JwtPayload {
depCode: string; depCode: string;
depName: string; depName: string;
permissionLevel: PermissionLevel; permissionLevel: PermissionLevel;
roles: string[];
iat?: number; iat?: number;
exp?: number; exp?: number;
} }
/** 全量权限角色名 */ // Re-export role constants and helpers from the shared module so existing
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader']; // server imports (`from './types.js'`) keep working.
export {
/** 部门级权限角色名 */ FULL_ACCESS_ROLES,
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep']; DEPT_ACCESS_ROLES,
SCHEDULING_ACCESS_ROLES,
FEEDBACK_ADMIN_ROLES,
ENERGY_ACCESS_ROLES,
canAccessScheduling,
canManageFeedback,
canAccessEnergy,
} from '../../shared/auth/roles.js';

View File

@@ -6,6 +6,10 @@ import dotenv from 'dotenv';
import vehiclesRouter from './routes/vehicles.js'; import vehiclesRouter from './routes/vehicles.js';
import mileageRouter from './routes/mileage/index.js'; import mileageRouter from './routes/mileage/index.js';
import schedulingRouter from './routes/scheduling/index.js'; import schedulingRouter from './routes/scheduling/index.js';
import energyRouter from './routes/energy/index.js';
import eleRouter from './routes/ele/index.js';
import feedbackRouter from './routes/feedback/index.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js'; import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js'; import { authMiddleware } from './auth/middleware.js';
@@ -24,6 +28,9 @@ app.use('/api/*', authMiddleware);
app.route('/api/vehicles', vehiclesRouter); app.route('/api/vehicles', vehiclesRouter);
app.route('/api/mileage', mileageRouter); app.route('/api/mileage', mileageRouter);
app.route('/api/scheduling', schedulingRouter); app.route('/api/scheduling', schedulingRouter);
app.route('/api/energy', energyRouter);
app.route('/api/ele', eleRouter);
app.route('/api/feedback', feedbackRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
@@ -34,6 +41,7 @@ app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
const port = Number(process.env.SERVER_PORT) || 3001; const port = Number(process.env.SERVER_PORT) || 3001;
console.log(`Server starting on port ${port}...`); console.log(`Server starting on port ${port}...`);
ensureSchedulingTables().catch(e => console.error('scheduling bootstrap error:', e));
serve({ fetch: app.fetch, port }, () => { serve({ fetch: app.fetch, port }, () => {
console.log(`Server running at http://localhost:${port}`); console.log(`Server running at http://localhost:${port}`);
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}; };
}); });

View File

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

View File

@@ -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') || '',
}; };
@@ -95,6 +100,12 @@ app.get('/', async (c) => {
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围 filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
} }
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
if (filterParams.region) {
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
filters = buildDateFilters(regionScope);
}
const filtered = applyFilters(allVehicles, filterParams); const filtered = applyFilters(allVehicles, filterParams);
const stats = { const stats = {

View 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": "华南区域"
}

View File

@@ -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[];
} }
/** 监控缓存 */ /** 监控缓存 */

View File

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

View File

@@ -1,6 +1,7 @@
import type { import type {
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion, EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
CandidateVehicle, VehicleClassification, SchedulingSummary, CandidateVehicle, VehicleClassification, SchedulingSummary,
ReasonBlock,
} from './types.js'; } from './types.js';
function fmtKmSimple(v: number): string { function fmtKmSimple(v: number): string {
@@ -25,11 +26,15 @@ export function isTypeCompatible(sourceType: string, candidateType: string): boo
export function classifyVehicle( export function classifyVehicle(
currentYearIsQualified: boolean, currentYearIsQualified: boolean,
predictedYearEnd: number, currentYearMileage: number,
yearTarget: number, yearTarget: number,
predictedYearEnd: number,
): VehicleClassification { ): VehicleClassification {
if (currentYearIsQualified || predictedYearEnd / yearTarget >= 1.2) return 'qualified'; // qualified: current year mileage already >= target (actually done, not just predicted)
if (predictedYearEnd / yearTarget < 0.6) return 'hopeless'; const actualRate = yearTarget > 0 ? currentYearMileage / yearTarget : 0;
if (currentYearIsQualified || actualRate >= 1.0) return 'qualified';
// hopeless: even with remaining days, predicted < 60% of target
if (yearTarget > 0 && predictedYearEnd / yearTarget < 0.6) return 'hopeless';
return 'normal'; return 'normal';
} }
@@ -57,6 +62,7 @@ export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
department: v.department, department: v.department,
manager: v.manager, manager: v.manager,
customerAvgDaily: v.customerAvgDaily, customerAvgDaily: v.customerAvgDaily,
customerAvgDaily7d: v.customerAvgDaily7d,
predictedYearEnd: v.predictedYearEnd, predictedYearEnd: v.predictedYearEnd,
daysLeft: v.daysLeft, daysLeft: v.daysLeft,
}; };
@@ -86,13 +92,9 @@ export function generateSuggestions(
// Among those, prefer the one with the smallest gap (easiest to finish). // Among those, prefer the one with the smallest gap (easiest to finish).
// Exclude already-qualified (>= 100%) — no value in swapping those. // Exclude already-qualified (>= 100%) — no value in swapping those.
for (const vehicle of hopeless) { for (const vehicle of hopeless) {
const customerCanAdd = vehicle.customerAvgDaily * vehicle.daysLeft;
const candidates: CandidateVehicle[] = inventoryVehicles const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => { .filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false; if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
if (inv.region !== vehicle.region) return false;
// Exclude already fully qualified
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget; const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false; if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true; return true;
@@ -100,7 +102,8 @@ export function generateSuggestions(
.map((inv) => { .map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget; const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage); const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap = inv.totalMileage + customerCanAdd; const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget; const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return { return {
plateNumber: inv.plateNumber, plateNumber: inv.plateNumber,
@@ -110,28 +113,38 @@ export function generateSuggestions(
totalMileage: inv.totalMileage, totalMileage: inv.totalMileage,
completionRate: inv.completionRate, completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget, yearTarget: inv.yearTarget ?? vehicle.yearTarget,
daysLeft: inv.daysLeft,
region: inv.region, region: inv.region,
province: inv.province, province: inv.province,
mileageGap, mileageGap,
predictedAfterSwap, predictedAfterSwap,
canQualifyAfterSwap, canQualifyAfterSwap,
isSameRegion: inv.region === vehicle.region,
notificationId: null,
notificationStatus: null,
}; };
}) })
.sort((a, b) => { .sort((a, b) => {
// 1. Prefer "can qualify after swap" first // 1. Same-region first (business rule: prefer same-region swaps)
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
// 2. Can-qualify next
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1; return a.canQualifyAfterSwap ? -1 : 1;
// 2. Among qualifiable: smallest gap first (easiest to finish) // 3. Smallest gap (closest to target)
// Among non-qualifiable: smallest gap first (closest to target)
return a.mileageGap - b.mileageGap; return a.mileageGap - b.mileageGap;
}) })
.slice(0, 5); ;
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage); const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
const canAddKm = Math.round(customerCanAdd); const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
const reason = `该车在客户「${vehicle.customer}」处日均仅 ${Math.round(vehicle.customerAvgDaily)} km完成率 ${yearRate}%,还差 ${fmtKmSimple(gap)} km年底无法达标。` const reason: ReasonBlock = {
+ `\n建议将此车换走给高里程客户冲刺换上一辆快达标的车——该客户剩余 ${vehicle.daysLeft} 天还能跑约 ${fmtKmSimple(canAddKm)} km足以帮缺口小的车冲线。`; lines: [
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
{ label: '年度考核剩余', value: `${vehicle.daysLeft}` },
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
],
conclusion: '预估无法达标,需替换',
};
suggestions.push({ suggestions.push({
id: `hopeless-${vehicle.plateNumber}`, id: `hopeless-${vehicle.plateNumber}`,
@@ -144,17 +157,13 @@ export function generateSuggestions(
} }
// --- replace_qualified (medium priority) --- // --- replace_qualified (medium priority) ---
// Swap out the qualified car, swap in a car that NEEDS mileage. // Every qualified vehicle gets a suggestion row so the list count matches
// The high-mileage customer will drive it hard → helps it reach target. // `qualifiedCount`. Candidates may be empty when no inventory vehicle can
// Exclude candidates already at target (gap <= 0) — swapping those in is pointless. // reach target at this customer — the row still surfaces for manual review.
for (const vehicle of qualified) { for (const vehicle of qualified) {
if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue;
const candidates: CandidateVehicle[] = inventoryVehicles const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => { .filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false; if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
if (inv.region !== vehicle.region) return false;
// Must still need mileage — exclude already-qualified inventory
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget; const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false; if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true; return true;
@@ -162,8 +171,8 @@ export function generateSuggestions(
.map((inv) => { .map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget; const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage); const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap = const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget; const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return { return {
plateNumber: inv.plateNumber, plateNumber: inv.plateNumber,
@@ -173,31 +182,39 @@ export function generateSuggestions(
totalMileage: inv.totalMileage, totalMileage: inv.totalMileage,
completionRate: inv.completionRate, completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget, yearTarget: inv.yearTarget ?? vehicle.yearTarget,
daysLeft: inv.daysLeft,
region: inv.region, region: inv.region,
province: inv.province, province: inv.province,
mileageGap, mileageGap,
predictedAfterSwap, predictedAfterSwap,
canQualifyAfterSwap, canQualifyAfterSwap,
isSameRegion: inv.region === vehicle.region,
notificationId: null,
notificationStatus: null,
}; };
}) })
// Only keep candidates that can actually qualify at this customer —
// swapping in a car that still can't reach target wastes the high-mileage customer
.filter(c => c.canQualifyAfterSwap)
.sort((a, b) => { .sort((a, b) => {
// 1. canQualifyAfterSwap first // 1. Same-region first
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
return a.canQualifyAfterSwap ? -1 : 1; // 2. Biggest gap first (most value from the swap)
// 2. Among qualifiable: biggest gap first (most value from the swap)
return b.mileageGap - a.mileageGap; return b.mileageGap - a.mileageGap;
}) })
// Only keep candidates that can actually qualify at this customer ;
.filter(c => c.canQualifyAfterSwap)
.slice(0, 5);
// Skip if no candidate can reach target — swap would be pointless
if (candidates.length === 0) continue;
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0; const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft; const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
const reason = `该车在客户「${vehicle.customer}」处已达标(完成率 ${yearRate}%),客户日均 ${Math.round(vehicle.customerAvgDaily)} km × ${vehicle.daysLeft} 天 ≈ ${fmtKmSimple(canAddKm)} km。` const reason: ReasonBlock = {
+ `\n建议换上里程未达标的车利用该客户的高日均帮新车快速冲线。`; lines: [
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
{ label: '年度完成率', value: `${yearRate}%` },
{ label: '年度考核剩余', value: `${vehicle.daysLeft}` },
{ label: '可为新车贡献', value: `${fmtKmSimple(Math.round(canAddKm))} km` },
],
conclusion: '已达标,建议换上未达标车辆',
};
suggestions.push({ suggestions.push({
id: `qualified-${vehicle.plateNumber}`, id: `qualified-${vehicle.plateNumber}`,
@@ -209,8 +226,11 @@ export function generateSuggestions(
}); });
} }
// Remove suggestions with no candidates // Drop rescue_hopeless with no candidates — no actionable rescue available.
const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0); // Keep every replace_qualified so the list count matches the qualifiedCount card.
const filteredSuggestions = suggestions.filter(
(s) => s.type === 'replace_qualified' || s.candidates.length > 0,
);
// Sort: high priority first // Sort: high priority first
filteredSuggestions.sort((a, b) => { filteredSuggestions.sort((a, b) => {
@@ -218,10 +238,11 @@ export function generateSuggestions(
return a.priority === 'high' ? -1 : 1; return a.priority === 'high' ? -1 : 1;
}); });
// estimatedGain: count suggestions where at least one candidate canQualifyAfterSwap, // estimatedGain uses strict definition: count suggestions that have at least
// plus rescue_hopeless suggestions (each rescued car can potentially qualify at a new customer) // one candidate able to qualify after swap. The API layer recomputes this
// post permission-filtering, so keep both sides consistent.
const estimatedGain = filteredSuggestions.filter((s) => const estimatedGain = filteredSuggestions.filter((s) =>
s.candidates.some((c) => c.canQualifyAfterSwap) || s.type === 'rescue_hopeless', s.candidates.some((c) => c.canQualifyAfterSwap),
).length; ).length;
const summary: SchedulingSummary = { const summary: SchedulingSummary = {
@@ -229,6 +250,7 @@ export function generateSuggestions(
hopelessCount: hopeless.length, hopelessCount: hopeless.length,
suggestionCount: filteredSuggestions.length, suggestionCount: filteredSuggestions.length,
estimatedGain, estimatedGain,
recentInterventionCount: 0,
}; };
return { suggestions: filteredSuggestions, summary }; return { suggestions: filteredSuggestions, summary };

View File

@@ -0,0 +1,34 @@
import pool from '../../db.js';
const CREATE_NOTIFICATIONS_TABLE = `
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
suggestion_id VARCHAR(128) NOT NULL,
current_plate VARCHAR(32) NOT NULL,
candidate_plate VARCHAR(32) NOT NULL,
operator_id VARCHAR(64),
operator_name VARCHAR(128),
status VARCHAR(16) NOT NULL DEFAULT 'sent',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
executed_at DATETIME NULL,
notes VARCHAR(500) NULL,
before_mileage INT NULL,
after_mileage INT NULL,
INDEX idx_suggestion_id (suggestion_id),
INDEX idx_current_plate (current_plate),
INDEX idx_candidate_plate (candidate_plate),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
`;
export async function ensureSchedulingTables(): Promise<void> {
try {
await pool.query(CREATE_NOTIFICATIONS_TABLE);
console.log('[scheduling] notifications table ready');
} catch (e) {
console.error('[scheduling] failed to ensure tables:', e);
throw e;
}
}

View File

@@ -1,9 +1,22 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import suggestionsRouter from './suggestions.js'; import suggestionsRouter from './suggestions.js';
import notifyRouter from './notify.js'; import notifyRouter from './notify.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessScheduling } from '../../auth/types.js';
const app = new Hono(); const app = new Hono();
// Module-level access guard. When auth middleware is active, `user` is set and
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
// When auth is bypassed (dev), `user` is undefined and requests pass through.
app.use('*', async (c, next) => {
const user = (c as any).get('user') as AuthUser | undefined;
if (user && !canAccessScheduling(user.roles)) {
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
}
return next();
});
app.route('/suggestions', suggestionsRouter); app.route('/suggestions', suggestionsRouter);
app.route('/notify', notifyRouter); app.route('/notify', notifyRouter);

View File

@@ -1,16 +1,114 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import type { NotifyRequest } from './types.js'; import type {
NotifyRequest,
NotifyBatchRequest,
NotifyBatchResult,
NotificationRecord,
NotificationStatus,
UpdateNotificationRequest,
} from './types.js';
const app = new Hono(); const app = new Hono();
// In-memory set of processed suggestion IDs // ---------------------------------------------------------------------------
const processedSuggestions = new Set<string>(); // Helpers
// ---------------------------------------------------------------------------
export function isProcessed(suggestionId: string): boolean { function rowToRecord(row: any): NotificationRecord {
return processedSuggestions.has(suggestionId); return {
id: Number(row.id),
suggestionId: row.suggestion_id,
currentPlate: row.current_plate,
candidatePlate: row.candidate_plate,
operatorId: row.operator_id,
operatorName: row.operator_name,
status: row.status,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
notes: row.notes,
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
};
} }
/**
* Count non-cancelled interventions created within the last 7 days.
*/
export async function fetchRecentInterventionCount(): Promise<number> {
const [rows] = (await pool.execute(
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
WHERE status != 'cancelled'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
)) as [any[], unknown];
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
}
/**
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
*/
export async function fetchActiveNotificationMap(): Promise<
Map<string, { id: number; status: NotificationStatus }>
> {
const [rows] = (await pool.execute(
`SELECT id, suggestion_id, candidate_plate, status, created_at
FROM tab_scheduling_notifications
WHERE status != 'cancelled'
ORDER BY created_at DESC`,
)) as [any[], unknown];
const map = new Map<string, { id: number; status: NotificationStatus }>();
for (const row of rows) {
const key = `${row.suggestion_id}::${row.candidate_plate}`;
if (!map.has(key)) {
map.set(key, { id: Number(row.id), status: row.status });
}
}
return map;
}
async function insertNotification(
req: NotifyRequest,
operator: { id: string | null; name: string | null },
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
// active intervention at a time. Any non-cancelled record for the same
// suggestion_id blocks further interventions until it is cancelled.
const [existing] = (await pool.execute(
`SELECT id, candidate_plate FROM tab_scheduling_notifications
WHERE suggestion_id = ? AND status != 'cancelled'
LIMIT 1`,
[req.suggestionId],
)) as [any[], unknown];
if (existing.length > 0) {
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
}
const [result] = (await pool.execute(
`INSERT INTO tab_scheduling_notifications
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
VALUES (?, ?, ?, ?, ?, 'sent')`,
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name],
)) as [any, unknown];
const insertedId = Number(result.insertId);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[insertedId],
)) as [any[], unknown];
return rowToRecord(rows[0]);
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// POST /api/scheduling/notify — single notify
app.post('/', async (c) => { app.post('/', async (c) => {
try { try {
const body = await c.req.json<NotifyRequest>(); const body = await c.req.json<NotifyRequest>();
@@ -20,21 +118,163 @@ app.post('/', async (c) => {
return c.json({ success: false, message: '缺少必要参数' }, 400); return c.json({ success: false, message: '缺少必要参数' }, 400);
} }
if (processedSuggestions.has(suggestionId)) { const user = (c as any).get('user') as AuthUser | undefined;
return c.json({ success: false, message: '该建议已处理' }, 409); const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
const result = await insertNotification(body, operator);
if ('skipped' in result) {
return c.json(
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
409,
);
}
console.log(
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
);
return c.json({
success: true,
message: `干预已登记:${currentPlate}${candidatePlate}`,
record: result,
});
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '登记干预失败' }, 500);
}
});
// POST /api/scheduling/notify/batch — bulk notify
app.post('/batch', async (c) => {
try {
const body = await c.req.json<NotifyBatchRequest>();
if (!Array.isArray(body.items) || body.items.length === 0) {
return c.json({ success: false, message: '缺少 items' }, 400);
} }
const user = (c as any).get('user') as AuthUser | undefined; const user = (c as any).get('user') as AuthUser | undefined;
const operator = user?.userName || '未知'; const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`); const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
for (const item of body.items) {
if (!item.suggestionId || !item.currentPlate || !item.candidatePlate) {
result.failed++;
continue;
}
try {
const r = await insertNotification(item, operator);
if ('skipped' in r) result.skipped++;
else {
result.success++;
result.records.push(r);
}
} catch {
result.failed++;
}
}
processedSuggestions.add(suggestionId); console.log(
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
);
return c.json({ success: true, message: `替换通知已发送:${currentPlate}${candidatePlate}` }); return c.json({
success: true,
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
result,
});
} catch (e: unknown) { } catch (e: unknown) {
console.error('scheduling notify error:', e); console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '发送通知失败' }, 500); return c.json({ success: false, message: '批量干预失败' }, 500);
}
});
// GET /api/scheduling/notify — list all notifications (history)
app.get('/', async (c) => {
try {
const status = c.req.query('status');
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
const where: string[] = [];
const params: (string | number)[] = [];
if (status) {
where.push('status = ?');
params.push(status);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(limit);
const [rows] = (await pool.query(
`SELECT * FROM tab_scheduling_notifications
${whereSql}
ORDER BY created_at DESC
LIMIT ?`,
params,
)) as [any[], unknown];
return c.json({ records: rows.map(rowToRecord) });
} catch (e: unknown) {
console.error('scheduling notifications list error:', e);
return c.json({ records: [] }, 500);
}
});
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
app.patch('/:id', async (c) => {
try {
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) {
return c.json({ success: false, message: 'id 无效' }, 400);
}
const body = await c.req.json<UpdateNotificationRequest>();
if (!body.status) {
return c.json({ success: false, message: '缺少 status' }, 400);
}
const validStatuses: NotificationStatus[] = ['sent', 'executed', 'cancelled'];
if (!validStatuses.includes(body.status)) {
return c.json({ success: false, message: 'status 不合法' }, 400);
}
const fields: string[] = ['status = ?'];
const params: (string | number | null)[] = [body.status];
if (body.status === 'executed') {
fields.push('executed_at = CURRENT_TIMESTAMP');
}
if (body.notes !== undefined) {
fields.push('notes = ?');
params.push(body.notes);
}
if (body.afterMileage !== undefined) {
fields.push('after_mileage = ?');
params.push(body.afterMileage);
}
params.push(id);
await pool.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
params,
);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[id],
)) as [any[], unknown];
if (rows.length === 0) {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({ success: true, record: rowToRecord(rows[0]) });
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
} }
}); });

View File

@@ -5,7 +5,8 @@ import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { mapRegion } from '../vehicles.js'; import { mapRegion } from '../vehicles.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js'; import { classifyVehicle, generateSuggestions } from './algorithm.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse } from './types.js'; import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -113,12 +114,16 @@ app.get('/', async (c) => {
// ---- Collect all plates for Query 6 ---- // ---- Collect all plates for Query 6 ----
const allPlates = assessmentRows.map((r: any) => r.plate_number as string); const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
// ---- Query 6: Customer daily avg (from mileage DB) ---- // ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
const customerAvgDailyMap = new Map<string, number>(); const customerAvgDailyMap = new Map<string, number>();
const customerAvgDaily7dMap = new Map<string, number>();
if (allPlates.length > 0) { if (allPlates.length > 0) {
const placeholders = allPlates.map(() => '?').join(','); const placeholders = allPlates.map(() => '?').join(',');
// Single query returning both windows per plate.
const [dailyRows] = await mileagePool.execute( const [dailyRows] = await mileagePool.execute(
`SELECT plate, AVG(daily_km) as avg_daily `SELECT plate,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
FROM v_vehicle_daily_stats FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND stat_date < CURDATE() AND stat_date < CURDATE()
@@ -127,25 +132,30 @@ app.get('/', async (c) => {
allPlates, allPlates,
) as [any[], unknown]; ) as [any[], unknown];
// Build plate → avg_daily map const plateAvg30Map = new Map<string, number>();
const plateAvgMap = new Map<string, number>(); const plateAvg7Map = new Map<string, number>();
for (const row of dailyRows) { for (const row of dailyRows) {
plateAvgMap.set(row.plate, Number(row.avg_daily) || 0); if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
} }
// Aggregate per customer: average of all plates belonging to each customer const customerPlates30 = new Map<string, number[]>();
const customerPlates = new Map<string, number[]>(); const customerPlates7 = new Map<string, number[]>();
for (const plate of allPlates) { for (const plate of allPlates) {
const info = vehicleInfoMap.get(plate); const info = vehicleInfoMap.get(plate);
const customer = info?.customer || '未知客户'; const customer = info?.customer || '未知客户';
if (!customerPlates.has(customer)) customerPlates.set(customer, []); if (!customerPlates30.has(customer)) customerPlates30.set(customer, []);
const avg = plateAvgMap.get(plate); if (!customerPlates7.has(customer)) customerPlates7.set(customer, []);
if (avg !== undefined) customerPlates.get(customer)!.push(avg); const v30 = plateAvg30Map.get(plate);
const v7 = plateAvg7Map.get(plate);
if (v30 !== undefined) customerPlates30.get(customer)!.push(v30);
if (v7 !== undefined) customerPlates7.get(customer)!.push(v7);
} }
for (const [customer, avgs] of customerPlates) { for (const [customer, avgs] of customerPlates30) {
if (avgs.length > 0) { if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
} }
for (const [customer, avgs] of customerPlates7) {
if (avgs.length > 0) customerAvgDaily7dMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
} }
} }
@@ -207,12 +217,13 @@ app.get('/', async (c) => {
const customer = info?.customer || null; const customer = info?.customer || null;
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0; const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0;
const currentYearMileage = Number(row.current_year_mileage) || 0; const currentYearMileage = Number(row.current_year_mileage) || 0;
const yearTarget = Number(row.current_year_mileage_task) || 0; const yearTarget = Number(row.current_year_mileage_task) || 0;
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft; const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
const currentYearIsQualified = row.current_year_is_qualified === 1; const currentYearIsQualified = row.current_year_is_qualified === 1;
const classification = classifyVehicle(currentYearIsQualified, predictedYearEnd, yearTarget); const classification = classifyVehicle(currentYearIsQualified, currentYearMileage, yearTarget, predictedYearEnd);
enrichedVehicles.push({ enrichedVehicles.push({
plateNumber: plate, plateNumber: plate,
@@ -232,6 +243,7 @@ app.get('/', async (c) => {
department: info?.department || null, department: info?.department || null,
manager: info?.manager || null, manager: info?.manager || null,
customerAvgDaily, customerAvgDaily,
customerAvgDaily7d,
predictedYearEnd, predictedYearEnd,
daysLeft, daysLeft,
classification, classification,
@@ -250,12 +262,21 @@ app.get('/', async (c) => {
// Cross-reference with assessment data // Cross-reference with assessment data
const assessment = assessmentByPlate.get(plate); const assessment = assessmentByPlate.get(plate);
// Compute this vehicle's own daysLeft from its assessment end date
let invDaysLeft = 0;
if (assessment?.current_year_assessment_end_date) {
const endDate = new Date(assessment.current_year_assessment_end_date);
invDaysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
} else {
invDaysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
}
inventoryVehicles.push({ inventoryVehicles.push({
plateNumber: plate, plateNumber: plate,
vehicleType, vehicleType,
region, region,
province, province,
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0, totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
daysLeft: invDaysLeft,
targetId: assessment ? (assessment.target_id as number) : null, targetId: assessment ? (assessment.target_id as number) : null,
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null, targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null, yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,
@@ -266,6 +287,19 @@ app.get('/', async (c) => {
// ---- Run algorithm ---- // ---- Run algorithm ----
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles); const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Attach notification status to candidates ----
const notificationMap = await fetchActiveNotificationMap();
for (const s of suggestions) {
for (const c of s.candidates) {
const key = `${s.id}::${c.plateNumber}`;
const notif = notificationMap.get(key);
if (notif) {
c.notificationId = notif.id;
c.notificationStatus = notif.status;
}
}
}
// ---- Permission filtering & customer name masking ---- // ---- Permission filtering & customer name masking ----
const user = (c as any).get('user') as AuthUser | undefined; const user = (c as any).get('user') as AuthUser | undefined;
@@ -305,8 +339,22 @@ app.get('/', async (c) => {
vehicleCount: targetVehicleCounts.get(t.id) || 0, vehicleCount: targetVehicleCounts.get(t.id) || 0,
})); }));
// Recalculate summary based on permission-filtered results
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
const recentInterventionCount = await fetchRecentInterventionCount();
const filteredSummary: SchedulingSummary = {
qualifiedCount: filteredQualified,
hopelessCount: filteredHopeless,
suggestionCount: masked.length,
estimatedGain: masked.filter((s: any) =>
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
).length,
recentInterventionCount,
};
const response: SchedulingResponse = { const response: SchedulingResponse = {
summary, summary: filteredSummary,
suggestions: masked, suggestions: masked,
targets: targetOptions, targets: targetOptions,
}; };
@@ -316,7 +364,7 @@ app.get('/', async (c) => {
console.error('scheduling suggestions error:', e); console.error('scheduling suggestions error:', e);
return c.json( return c.json(
{ {
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0 }, summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0, recentInterventionCount: 0 },
suggestions: [], suggestions: [],
targets: [], targets: [],
} satisfies SchedulingResponse, } satisfies SchedulingResponse,

View File

@@ -1,70 +1,23 @@
export interface SchedulingVehicleInfo { export type {
plateNumber: string; SchedulingVehicleInfo,
targetId: number; CandidateVehicle,
targetName: string; SchedulingSuggestion,
vehicleType: string; SchedulingSummary,
totalMileage: number; SchedulingTargetOption,
currentYearMileage: number; SchedulingResponse,
completionRate: number; // 本年完成率 currentYearMileage / yearTarget NotifyRequest,
yearTarget: number; NotifyBatchRequest,
region: string; NotifyBatchResult,
province: string; NotificationStatus,
customer: string | null; NotificationRecord,
department: string | null; UpdateNotificationRequest,
manager: string | null; ReasonLine,
customerAvgDaily: number; ReasonBlock,
predictedYearEnd: number; } from '../../../shared/scheduling/types.js';
daysLeft: number;
}
export interface CandidateVehicle { // ---------------------------------------------------------------------------
plateNumber: string; // Server-only types
targetId: number | null; // ---------------------------------------------------------------------------
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
region: string;
province: string;
mileageGap: number;
predictedAfterSwap: number;
canQualifyAfterSwap: boolean;
}
export interface SchedulingSuggestion {
id: string;
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: SchedulingVehicleInfo;
candidates: CandidateVehicle[];
reason: string;
}
export interface SchedulingSummary {
qualifiedCount: number;
hopelessCount: number;
suggestionCount: number;
estimatedGain: number;
}
export interface SchedulingTargetOption {
id: number;
name: string;
vehicleCount: number;
}
export interface SchedulingResponse {
summary: SchedulingSummary;
suggestions: SchedulingSuggestion[];
targets: SchedulingTargetOption[];
}
export interface NotifyRequest {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal'; export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
@@ -86,6 +39,7 @@ export interface EnrichedVehicle {
department: string | null; department: string | null;
manager: string | null; manager: string | null;
customerAvgDaily: number; customerAvgDaily: number;
customerAvgDaily7d: number;
predictedYearEnd: number; predictedYearEnd: number;
daysLeft: number; daysLeft: number;
classification: VehicleClassification; classification: VehicleClassification;
@@ -97,6 +51,7 @@ export interface InventoryVehicle {
region: string; region: string;
province: string; province: string;
totalMileage: number; totalMileage: number;
daysLeft: number;
targetId: number | null; targetId: number | null;
targetName: string | null; targetName: string | null;
yearTarget: number | null; yearTarget: number | null;

View File

@@ -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
@@ -793,11 +793,21 @@ app.get('/region-stats', async (c) => {
cityMap.get(city)!.push(v); cityMap.get(city)!.push(v);
} }
const getTypeBreakdown = (vList: Vehicle[]) => const getTypeBreakdown = (vList: Vehicle[]) => {
['4.5T', '18T', '49T'].map((type) => { const KNOWN = ['4.5T', '18T', '49T'] as const;
const tv = vList.filter((v) => v.type === type); const make = (label: string, tv: Vehicle[]) => ({
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] }; type: label,
}).filter((t) => t.total > 0); total: tv.length,
operating: tv.filter((v) => v.status === 'Operating').length,
inventory: tv.filter((v) => v.status === 'Inventory').length,
pending: tv.filter((v) => v.status === 'Pending').length,
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
});
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
if (other.length > 0) known.push(make('其他', other));
return known.filter((t) => t.total > 0);
};
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他']; const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder const result = regionOrder
@@ -880,6 +890,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 +950,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 +960,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 +1042,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 +1055,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);
}); });

36
src/shared/auth/roles.ts Normal file
View File

@@ -0,0 +1,36 @@
// Role constants and role-based access helpers shared between server (JWT
// issuance / API guards) and client (nav visibility / module gating).
/** 全量权限角色名 */
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
/** 部门级权限角色名 */
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
/** 智能调度模块访问角色 */
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
/** 反馈管理(管理员)访问角色 */
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
/** 能源管理模块访问角色 */
export const ENERGY_ACCESS_ROLES = ['BI-LEADER-ENERGY'];
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
}
/** 用户是否可管理反馈。仅 BI-ADMIN-FEEDBACK 或全量权限角色可访问。 */
export function canManageFeedback(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r));
}
/** 用户是否可访问能源管理模块。仅 BI-LEADER-ENERGY 或「所有权限」可访问。 */
const ENERGY_FULL_ACCESS = '所有权限';
export function canAccessEnergy(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS);
}

View File

@@ -0,0 +1,123 @@
// Shared scheduling types — used by both client (modules/scheduling) and server
// (server/routes/scheduling). Keep server-only types (EnrichedVehicle etc.) in
// server/routes/scheduling/types.ts.
export interface SchedulingVehicleInfo {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
currentYearMileage: number;
completionRate: number;
yearTarget: number;
region: string;
province: string;
customer: string | null;
department: string | null;
manager: string | null;
customerAvgDaily: number;
customerAvgDaily7d: number;
predictedYearEnd: number;
daysLeft: number;
}
export type NotificationStatus = 'sent' | 'executed' | 'cancelled';
export interface CandidateVehicle {
plateNumber: string;
targetId: number | null;
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
daysLeft: number;
region: string;
province: string;
mileageGap: number;
predictedAfterSwap: number;
canQualifyAfterSwap: boolean;
isSameRegion: boolean;
notificationId: number | null;
notificationStatus: NotificationStatus | null;
}
export interface NotificationRecord {
id: number;
suggestionId: string;
currentPlate: string;
candidatePlate: string;
operatorId: string | null;
operatorName: string | null;
status: NotificationStatus;
createdAt: string;
updatedAt: string;
executedAt: string | null;
notes: string | null;
beforeMileage: number | null;
afterMileage: number | null;
}
export interface NotifyBatchRequest {
items: NotifyRequest[];
}
export interface NotifyBatchResult {
success: number;
skipped: number;
failed: number;
records: NotificationRecord[];
}
export interface UpdateNotificationRequest {
status: NotificationStatus;
notes?: string;
afterMileage?: number;
}
export interface ReasonLine {
label: string;
value: string;
}
export interface ReasonBlock {
lines: ReasonLine[];
conclusion: string;
}
export interface SchedulingSuggestion {
id: string;
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: SchedulingVehicleInfo;
candidates: CandidateVehicle[];
reason: ReasonBlock;
}
export interface SchedulingSummary {
qualifiedCount: number;
hopelessCount: number;
suggestionCount: number;
estimatedGain: number;
/** Count of interventions created within the last 7 days (excluding cancelled). */
recentInterventionCount: number;
}
export interface SchedulingTargetOption {
id: number;
name: string;
vehicleCount: number;
}
export interface SchedulingResponse {
summary: SchedulingSummary;
suggestions: SchedulingSuggestion[];
targets: SchedulingTargetOption[];
}
export interface NotifyRequest {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}

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

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