From 06fe75b4c711e3612dee411f280202308464d56f Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 13 Aug 2026 12:03:34 +0800 Subject: [PATCH] refactor: modularize application domains --- .../2026-08-13-codebase-refactor.md | 97 + package.json | 1 + src/App.tsx | 156 +- src/app/modules.test.ts | 38 + src/app/modules.ts | 72 + src/app/routing.test.ts | 20 + src/app/routing.ts | 56 + src/components/FeedbackFab.tsx | 390 +--- src/components/feedback/FeedbackFormStep.tsx | 148 ++ src/components/feedback/FeedbackLauncher.tsx | 88 + src/components/feedback/FeedbackModal.tsx | 177 ++ .../feedback/FeedbackSuccessStep.tsx | 24 + src/components/feedback/FeedbackTypeStep.tsx | 78 + src/components/feedback/types.ts | 8 + src/modules/assets/AssetsModule.tsx | 1852 ++--------------- .../assets/components/AssetsHeader.tsx | 261 +++ .../assets/components/FlowDetailModal.tsx | 144 ++ .../assets/components/OverviewView.tsx | 64 + .../assets/components/VehicleDetailModal.tsx | 281 +++ .../overview/AssetSummaryDesktopTable.tsx | 195 ++ .../overview/AssetSummaryMobileCards.tsx | 127 ++ .../overview/AssetSummarySection.tsx | 26 + .../overview/FlowStatisticsCard.tsx | 153 ++ .../overview/InventoryDesktopTable.tsx | 153 ++ .../overview/InventoryMobileList.tsx | 117 ++ .../overview/InventoryStatisticsSection.tsx | 203 ++ .../components/overview/SummaryMetrics.tsx | 91 + .../assets/components/overview/types.ts | 71 + src/modules/assets/model.test.ts | 310 +++ src/modules/assets/model.ts | 412 ++++ src/modules/ele/EleImportPage.tsx | 367 +--- src/modules/ele/import-page/BatchTable.tsx | 56 + src/modules/ele/import-page/RecordFilters.tsx | 51 + src/modules/ele/import-page/RecordTable.tsx | 54 + src/modules/ele/import-page/SummaryKpis.tsx | 41 + .../ele/import-page/UploadDropzone.tsx | 62 + .../ele/import-page/UploadFeedback.tsx | 65 + src/modules/ele/import-page/model.test.ts | 44 + src/modules/ele/import-page/model.ts | 41 + src/modules/ele/import-page/types.ts | 52 + src/modules/energy/ElectricDaily.tsx | 141 +- src/modules/energy/HydrogenDaily.tsx | 146 +- src/modules/energy/HydrogenOverview.tsx | 710 +------ .../energy/daily-range/DailyRangeControls.tsx | 92 + src/modules/energy/daily-range/model.ts | 48 + .../energy/electric-daily/model.test.ts | 41 + src/modules/energy/electric-daily/model.ts | 25 + .../energy/hydrogen-daily/model.test.ts | 84 + src/modules/energy/hydrogen-daily/model.ts | 41 + .../components/DistributionCharts.tsx | 133 ++ .../components/HydrogenOverviewSkeleton.tsx | 80 + .../components/InsightCards.tsx | 88 + .../components/KpiSection.tsx | 114 + .../components/MonthlyCharts.tsx | 101 + .../components/OverviewHeader.tsx | 53 + .../components/RefreshOverlay.tsx | 31 + .../components/SummaryTables.tsx | 123 ++ .../energy/hydrogen-overview/model.test.ts | 72 + src/modules/energy/hydrogen-overview/model.ts | 75 + src/modules/mileage/DailyReportView.tsx | 1120 +--------- src/modules/mileage/MonitoringView.tsx | 1550 ++------------ src/modules/mileage/StatisticsView.tsx | 795 +------ .../DailyReportLoadingOverlay.tsx | 45 + src/modules/mileage/daily-report/GroupRow.tsx | 192 ++ .../daily-report/MileageBandDefinition.tsx | 16 + .../mileage/daily-report/ReportCharts.tsx | 122 ++ .../mileage/daily-report/ReportDatePicker.tsx | 113 + .../mileage/daily-report/ReportHeader.tsx | 69 + .../mileage/daily-report/ReportInsights.tsx | 38 + .../mileage/daily-report/ReportOverview.tsx | 141 ++ .../mileage/daily-report/VehicleTable.tsx | 302 +++ .../mileage/daily-report/utils.test.ts | 107 + src/modules/mileage/daily-report/utils.ts | 170 ++ .../components/ActiveFilterTags.tsx | 121 ++ .../components/BatchMultiSelect.tsx | 126 ++ .../monitoring/components/FilterPanel.tsx | 272 +++ .../components/FullscreenMonitoring.tsx | 330 +++ .../components/MonitoringSummary.tsx | 116 ++ .../components/MonitoringToolbar.tsx | 173 ++ .../components/SearchableSelect.tsx | 83 + .../components/SourcePriorityControl.tsx | 75 + .../monitoring/components/VehicleList.tsx | 157 ++ src/modules/mileage/monitoring/date-range.ts | 45 + .../monitoring/monitoring-model.test.ts | 180 ++ src/modules/mileage/monitoring/oneos-time.ts | 74 + src/modules/mileage/monitoring/query-model.ts | 96 + .../mileage/monitoring/source-display.ts | 47 + .../mileage/statistics/AllVehiclesPanel.tsx | 151 ++ .../statistics/FullscreenTargetTable.tsx | 129 ++ .../mileage/statistics/StatisticsOverview.tsx | 79 + .../mileage/statistics/TargetDetailPanel.tsx | 237 +++ src/modules/mileage/statistics/TrendPanel.tsx | 133 ++ src/modules/mileage/statistics/model.test.ts | 195 ++ src/modules/mileage/statistics/model.ts | 185 ++ src/modules/scheduling/SchedulingModule.tsx | 386 +--- .../scheduling-module/ActiveFilterTags.tsx | 24 + .../scheduling-module/BatchActionBar.tsx | 40 + .../scheduling-module/BatchConfirmModal.tsx | 82 + .../scheduling-module/ListHeader.tsx | 107 + .../scheduling-module/ListLoadingSkeleton.tsx | 28 + .../scheduling-module/SchedulingSkeleton.tsx | 38 + .../scheduling-module/SummaryCards.tsx | 35 + .../scheduling-module/model.test.ts | 175 ++ .../scheduling/scheduling-module/model.ts | 171 ++ src/server/app.test.ts | 12 + src/server/app.ts | 47 + src/server/bootstrap.ts | 10 + src/server/index.ts | 44 +- src/server/routes/energy/constants.ts | 8 + src/server/routes/energy/electric.ts | 191 ++ src/server/routes/energy/hydrogen-daily.ts | 145 ++ src/server/routes/energy/hydrogen-overview.ts | 273 +++ src/server/routes/energy/index.ts | 650 +----- src/server/routes/energy/query-model.test.ts | 53 + src/server/routes/energy/query-model.ts | 76 + src/server/routes/energy/routes.test.ts | 167 ++ .../routes/hydrogen-heatmap-model.test.ts | 121 ++ src/server/routes/hydrogen-heatmap-model.ts | 147 ++ src/server/routes/hydrogen-heatmap.ts | 148 +- src/server/routes/mileage/cache-model.test.ts | 89 + src/server/routes/mileage/cache-model.ts | 220 ++ src/server/routes/mileage/cache.ts | 245 +-- .../routes/mileage/daily-report-scheduler.ts | 4 +- .../routes/mileage/daily-report-service.ts | 9 +- src/server/routes/mileage/index.ts | 19 +- src/server/routes/mileage/oneos-api.ts | 138 +- src/server/routes/mileage/oneos-model.test.ts | 88 + src/server/routes/mileage/oneos-model.ts | 137 ++ .../scheduling/notification-model.test.ts | 59 + .../routes/scheduling/notification-model.ts | 97 + src/server/routes/scheduling/notify.ts | 71 +- .../routes/vehicle-heatmap-model.test.ts | 147 ++ src/server/routes/vehicle-heatmap-model.ts | 165 ++ src/server/routes/vehicle-heatmap.ts | 163 +- src/server/routes/vehicles.ts | 839 +------- src/server/routes/vehicles/model.test.ts | 249 +++ src/server/routes/vehicles/model.ts | 254 +++ src/server/routes/vehicles/repository.test.ts | 116 ++ src/server/routes/vehicles/repository.ts | 462 ++++ src/server/routes/vehicles/types.ts | 26 + src/server/routes/vehicles/utils.ts | 125 ++ woodpecker.yml | 2 + 142 files changed, 14645 insertions(+), 8885 deletions(-) create mode 100644 docs/refactoring/2026-08-13-codebase-refactor.md create mode 100644 src/app/modules.test.ts create mode 100644 src/app/modules.ts create mode 100644 src/app/routing.test.ts create mode 100644 src/app/routing.ts create mode 100644 src/components/feedback/FeedbackFormStep.tsx create mode 100644 src/components/feedback/FeedbackLauncher.tsx create mode 100644 src/components/feedback/FeedbackModal.tsx create mode 100644 src/components/feedback/FeedbackSuccessStep.tsx create mode 100644 src/components/feedback/FeedbackTypeStep.tsx create mode 100644 src/components/feedback/types.ts create mode 100644 src/modules/assets/components/AssetsHeader.tsx create mode 100644 src/modules/assets/components/FlowDetailModal.tsx create mode 100644 src/modules/assets/components/OverviewView.tsx create mode 100644 src/modules/assets/components/VehicleDetailModal.tsx create mode 100644 src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx create mode 100644 src/modules/assets/components/overview/AssetSummaryMobileCards.tsx create mode 100644 src/modules/assets/components/overview/AssetSummarySection.tsx create mode 100644 src/modules/assets/components/overview/FlowStatisticsCard.tsx create mode 100644 src/modules/assets/components/overview/InventoryDesktopTable.tsx create mode 100644 src/modules/assets/components/overview/InventoryMobileList.tsx create mode 100644 src/modules/assets/components/overview/InventoryStatisticsSection.tsx create mode 100644 src/modules/assets/components/overview/SummaryMetrics.tsx create mode 100644 src/modules/assets/components/overview/types.ts create mode 100644 src/modules/assets/model.test.ts create mode 100644 src/modules/assets/model.ts create mode 100644 src/modules/ele/import-page/BatchTable.tsx create mode 100644 src/modules/ele/import-page/RecordFilters.tsx create mode 100644 src/modules/ele/import-page/RecordTable.tsx create mode 100644 src/modules/ele/import-page/SummaryKpis.tsx create mode 100644 src/modules/ele/import-page/UploadDropzone.tsx create mode 100644 src/modules/ele/import-page/UploadFeedback.tsx create mode 100644 src/modules/ele/import-page/model.test.ts create mode 100644 src/modules/ele/import-page/model.ts create mode 100644 src/modules/ele/import-page/types.ts create mode 100644 src/modules/energy/daily-range/DailyRangeControls.tsx create mode 100644 src/modules/energy/daily-range/model.ts create mode 100644 src/modules/energy/electric-daily/model.test.ts create mode 100644 src/modules/energy/electric-daily/model.ts create mode 100644 src/modules/energy/hydrogen-daily/model.test.ts create mode 100644 src/modules/energy/hydrogen-daily/model.ts create mode 100644 src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/InsightCards.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/KpiSection.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx create mode 100644 src/modules/energy/hydrogen-overview/components/SummaryTables.tsx create mode 100644 src/modules/energy/hydrogen-overview/model.test.ts create mode 100644 src/modules/energy/hydrogen-overview/model.ts create mode 100644 src/modules/mileage/daily-report/DailyReportLoadingOverlay.tsx create mode 100644 src/modules/mileage/daily-report/GroupRow.tsx create mode 100644 src/modules/mileage/daily-report/MileageBandDefinition.tsx create mode 100644 src/modules/mileage/daily-report/ReportCharts.tsx create mode 100644 src/modules/mileage/daily-report/ReportDatePicker.tsx create mode 100644 src/modules/mileage/daily-report/ReportHeader.tsx create mode 100644 src/modules/mileage/daily-report/ReportInsights.tsx create mode 100644 src/modules/mileage/daily-report/ReportOverview.tsx create mode 100644 src/modules/mileage/daily-report/VehicleTable.tsx create mode 100644 src/modules/mileage/daily-report/utils.test.ts create mode 100644 src/modules/mileage/daily-report/utils.ts create mode 100644 src/modules/mileage/monitoring/components/ActiveFilterTags.tsx create mode 100644 src/modules/mileage/monitoring/components/BatchMultiSelect.tsx create mode 100644 src/modules/mileage/monitoring/components/FilterPanel.tsx create mode 100644 src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx create mode 100644 src/modules/mileage/monitoring/components/MonitoringSummary.tsx create mode 100644 src/modules/mileage/monitoring/components/MonitoringToolbar.tsx create mode 100644 src/modules/mileage/monitoring/components/SearchableSelect.tsx create mode 100644 src/modules/mileage/monitoring/components/SourcePriorityControl.tsx create mode 100644 src/modules/mileage/monitoring/components/VehicleList.tsx create mode 100644 src/modules/mileage/monitoring/date-range.ts create mode 100644 src/modules/mileage/monitoring/monitoring-model.test.ts create mode 100644 src/modules/mileage/monitoring/oneos-time.ts create mode 100644 src/modules/mileage/monitoring/query-model.ts create mode 100644 src/modules/mileage/monitoring/source-display.ts create mode 100644 src/modules/mileage/statistics/AllVehiclesPanel.tsx create mode 100644 src/modules/mileage/statistics/FullscreenTargetTable.tsx create mode 100644 src/modules/mileage/statistics/StatisticsOverview.tsx create mode 100644 src/modules/mileage/statistics/TargetDetailPanel.tsx create mode 100644 src/modules/mileage/statistics/TrendPanel.tsx create mode 100644 src/modules/mileage/statistics/model.test.ts create mode 100644 src/modules/mileage/statistics/model.ts create mode 100644 src/modules/scheduling/scheduling-module/ActiveFilterTags.tsx create mode 100644 src/modules/scheduling/scheduling-module/BatchActionBar.tsx create mode 100644 src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx create mode 100644 src/modules/scheduling/scheduling-module/ListHeader.tsx create mode 100644 src/modules/scheduling/scheduling-module/ListLoadingSkeleton.tsx create mode 100644 src/modules/scheduling/scheduling-module/SchedulingSkeleton.tsx create mode 100644 src/modules/scheduling/scheduling-module/SummaryCards.tsx create mode 100644 src/modules/scheduling/scheduling-module/model.test.ts create mode 100644 src/modules/scheduling/scheduling-module/model.ts create mode 100644 src/server/app.test.ts create mode 100644 src/server/app.ts create mode 100644 src/server/bootstrap.ts create mode 100644 src/server/routes/energy/constants.ts create mode 100644 src/server/routes/energy/electric.ts create mode 100644 src/server/routes/energy/hydrogen-daily.ts create mode 100644 src/server/routes/energy/hydrogen-overview.ts create mode 100644 src/server/routes/energy/query-model.test.ts create mode 100644 src/server/routes/energy/query-model.ts create mode 100644 src/server/routes/energy/routes.test.ts create mode 100644 src/server/routes/hydrogen-heatmap-model.test.ts create mode 100644 src/server/routes/hydrogen-heatmap-model.ts create mode 100644 src/server/routes/mileage/cache-model.test.ts create mode 100644 src/server/routes/mileage/cache-model.ts create mode 100644 src/server/routes/mileage/oneos-model.test.ts create mode 100644 src/server/routes/mileage/oneos-model.ts create mode 100644 src/server/routes/scheduling/notification-model.test.ts create mode 100644 src/server/routes/scheduling/notification-model.ts create mode 100644 src/server/routes/vehicle-heatmap-model.test.ts create mode 100644 src/server/routes/vehicle-heatmap-model.ts create mode 100644 src/server/routes/vehicles/model.test.ts create mode 100644 src/server/routes/vehicles/model.ts create mode 100644 src/server/routes/vehicles/repository.test.ts create mode 100644 src/server/routes/vehicles/repository.ts create mode 100644 src/server/routes/vehicles/types.ts create mode 100644 src/server/routes/vehicles/utils.ts diff --git a/docs/refactoring/2026-08-13-codebase-refactor.md b/docs/refactoring/2026-08-13-codebase-refactor.md new file mode 100644 index 0000000..e8815de --- /dev/null +++ b/docs/refactoring/2026-08-13-codebase-refactor.md @@ -0,0 +1,97 @@ +# LN-BI 渐进式重构方案 + +## 目标 + +本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约: + +- 页面文案、布局、交互和 URL 行为 +- API 路径、请求参数、状态码和响应字段 +- SQL、数据源优先级、权限与脱敏顺序 +- 里程、资产、能源和调度的统计口径 +- 缓存刷新频率与日报归档时间 + +## 当前结构 + +项目已经按业务模块组织,但部分文件仍承担了过多职责: + +| 热点 | 规模 | 主要职责混合 | +| --- | ---: | --- | +| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 | +| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 | +| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 | +| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 | + +目标结构遵循同一方向: + +```text +route / page + -> controller / hook # 流程与状态 + -> service / model # 业务规则与纯计算 + -> repository / api # 数据访问 +``` + +React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。 + +## 实施顺序 + +1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。 +2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。 +3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。 +4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。 +5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。 + +每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。 + +## 模块迭代路线 + +| 批次 | 模块 | 主要边界 | 风险控制 | +| --- | --- | --- | --- | +| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 | +| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 | +| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 | +| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 | +| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 | + +高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。 + +## 当前进度 + +截至 2026-08-13,前五轮可独立验证的边界已经完成: + +- 应用路由配置与服务器启动副作用已经从入口文件分离。 +- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。 +- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。 +- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。 +- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。 +- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。 +- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。 +- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。 +- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。 +- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。 +- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。 + +下一批继续只处理能够独立回归的边界: + +1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。 +2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。 +3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。 +4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。 + +## 注释约定 + +注释解释“为什么”,不复述语句本身。优先说明: + +- 统计边界和历史口径 +- 时区、日期范围和特殊状态映射 +- 外部 API 的兼容处理 +- 看似可合并但必须保持差异的流程 + +命名和类型能够表达清楚的代码不额外添加注释。 + +## 已识别但不在本轮修复的问题 + +- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。 +- 监控页不同入口的请求参数目前不完全一致。 +- 数据库连接配置与部署凭据需要独立安全治理。 + +这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。 diff --git a/package.json b/package.json index 50c62ba..17c14ca 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "vite build", "start": "node --import tsx src/server/index.ts", "lint": "tsc --noEmit", + "test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)", "test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index d9d15be..a40f88d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,142 +1,29 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react"; -import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react"; -import { Shell, type ModuleConfig } from "./components/Shell"; +import { Shell } from "./components/Shell"; import AuthProvider from "./auth/AuthProvider"; import { useAuth } from "./auth/useAuth"; import UnauthorizedPage from "./auth/UnauthorizedPage"; -import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles"; +import { canAccessEnergy } from "./shared/auth/roles"; import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; +import { buildModules } from "./app/modules"; +import { + normalizeBrowserPath, + readBrowserRoute, +} from "./app/routing"; -const AssetsModule = lazy(() => import("./modules/assets/AssetsModule")); -const MileageModule = lazy(() => import("./modules/mileage/MileageModule")); -const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule")); -const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule")); -const ElectricModule = lazy(() => import("./modules/energy/ElectricModule")); -const EtcModule = lazy(() => import("./modules/energy/EtcModule")); const EleImportPage = lazy(() => import("./modules/ele/EleImportPage")); const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); -const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule")); -const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule")); - -const ASSETS_MODULE: ModuleConfig = { - id: "assets", - label: "资产管理", - icon: Truck, - component: AssetsModule, -}; - -const MILEAGE_MODULE: ModuleConfig = { - id: "mileage", - label: "里程管理", - icon: Route, - component: MileageModule, -}; - -const VEHICLE_HEATMAP_MODULE: ModuleConfig = { - id: "vehicle-heatmap", - label: "车辆", - icon: MapPinned, - component: VehicleHeatmapModule, -}; - -const SCHEDULING_MODULE: ModuleConfig = { - id: "scheduling", - label: "智能调度", - icon: Activity, - component: SchedulingModule, -}; - -const HYDROGEN_MODULE: ModuleConfig = { - id: "hydrogen", - label: "氢能", - icon: Fuel, - component: HydrogenModule, -}; - -const HYDROGEN_HEATMAP_MODULE: ModuleConfig = { - id: "hydrogen-heatmap", - label: "加氢", - icon: MapPinned, - component: HydrogenHeatmapModule, -}; - -const ELECTRIC_MODULE: ModuleConfig = { - id: "electric", - label: "电能", - icon: BatteryCharging, - component: ElectricModule, -}; - -const ETC_MODULE: ModuleConfig = { - id: "etc", - label: "ETC", - icon: Receipt, - component: EtcModule, -}; - -/** - * 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径, - * 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。 - */ -function normalizePath() { - if (typeof window === "undefined") return; - const { pathname, hash, search } = window.location; - - // 主路径 & 隐藏后台页保持不变 - if (pathname === "/asset" || pathname === "/energy") return; - if (pathname === "/ele/import" || pathname === "/admin/feedback") return; - - const legacyMap: Record = { - "/": { path: "/asset" }, - "/vehicle": { path: "/asset", hash: "assets" }, - "/assets": { path: "/asset", hash: "assets" }, - "/mileage": { path: "/asset", hash: "mileage" }, - "/scheduling": { path: "/asset", hash: "scheduling" }, - }; - - // 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析) - const target = legacyMap[pathname] ?? { path: "/asset" }; - const finalHash = target.hash ? `#${target.hash}` : hash || ""; - window.history.replaceState(null, "", `${target.path}${search}${finalHash}`); -} - -normalizePath(); - -type PathSet = "asset" | "energy"; - -function getPathSet(): PathSet { - return window.location.pathname === "/energy" ? "energy" : "asset"; -} - -function getRouteKey(): string { - if (typeof window === "undefined") return ""; - const path = window.location.pathname; - const hash = window.location.hash; - if ( - path === "/ele/import" || - hash === "#/ele/import" || - hash === "#ele/import" - ) - return "ele/import"; - if ( - path === "/admin/feedback" || - hash === "#/admin/feedback" || - hash === "#admin/feedback" - ) - return "admin/feedback"; - return ""; -} +normalizeBrowserPath(); function AuthGate() { const { isLoading, isAuthenticated, error, user } = useAuth(); - const [routeKey, setRouteKey] = useState(getRouteKey); - const [pathSet, setPathSet] = useState(getPathSet); + const [route, setRoute] = useState(readBrowserRoute); + const { routeKey, pathSet } = route; // 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效 useEffect(() => { const update = () => { - setRouteKey(getRouteKey()); - setPathSet(getPathSet()); + setRoute(readBrowserRoute()); }; window.addEventListener("hashchange", update); window.addEventListener("popstate", update); @@ -150,23 +37,10 @@ function AuthGate() { document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; }, [pathSet]); - const modules = useMemo(() => { - if (pathSet === "energy") { - return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE]; - } - const heatmapChildren = canAccessEnergy(user?.roles) - ? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE] - : [VEHICLE_HEATMAP_MODULE]; - const heatmapModule: ModuleConfig = { - id: "heatmap", - label: "热力图", - icon: MapPinned, - children: heatmapChildren, - }; - const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule]; - if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE); - return result; - }, [pathSet, user?.roles]); + const modules = useMemo( + () => buildModules(pathSet, user?.roles), + [pathSet, user?.roles], + ); if (isLoading) { return ( diff --git a/src/app/modules.test.ts b/src/app/modules.test.ts new file mode 100644 index 0000000..02b8d40 --- /dev/null +++ b/src/app/modules.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildModules } from './modules.js'; + +function moduleIds(roles?: string[]): string[] { + return buildModules('asset', roles).map(module => module.id); +} + +function heatmapChildIds(roles?: string[]): string[] { + const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap'); + return heatmap?.children?.map(module => module.id) ?? []; +} + +test('keeps the base asset navigation and role-gated scheduling module', () => { + assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']); + assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [ + 'assets', + 'mileage', + 'heatmap', + 'scheduling', + ]); +}); + +test('keeps energy heatmap visibility and the independent energy navigation', () => { + assert.deepEqual(heatmapChildIds(), ['vehicle-heatmap']); + assert.deepEqual(heatmapChildIds(['BI-LEADER-ENERGY']), [ + 'hydrogen-heatmap', + 'vehicle-heatmap', + ]); + assert.deepEqual(heatmapChildIds(['所有权限']), [ + 'hydrogen-heatmap', + 'vehicle-heatmap', + ]); + assert.deepEqual( + buildModules('energy', []).map(module => module.id), + ['hydrogen', 'electric', 'etc'], + ); +}); diff --git a/src/app/modules.ts b/src/app/modules.ts new file mode 100644 index 0000000..143c9d3 --- /dev/null +++ b/src/app/modules.ts @@ -0,0 +1,72 @@ +import { lazy } from 'react'; +import { + Activity, + BatteryCharging, + Fuel, + MapPinned, + Receipt, + Route, + Truck, +} from 'lucide-react'; +import type { ModuleConfig } from '../components/Shell'; +import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles'; +import type { PathSet } from './routing'; + +const AssetsModule = lazy(() => import('../modules/assets/AssetsModule')); +const MileageModule = lazy(() => import('../modules/mileage/MileageModule')); +const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule')); +const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule')); +const ElectricModule = lazy(() => import('../modules/energy/ElectricModule')); +const EtcModule = lazy(() => import('../modules/energy/EtcModule')); +const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule')); +const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/HydrogenHeatmapModule')); + +const ASSET_MODULES: ModuleConfig[] = [ + { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, + { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, +]; + +const VEHICLE_HEATMAP_MODULE: ModuleConfig = { + id: 'vehicle-heatmap', + label: '车辆', + icon: MapPinned, + component: VehicleHeatmapModule, +}; + +const HYDROGEN_HEATMAP_MODULE: ModuleConfig = { + id: 'hydrogen-heatmap', + label: '加氢', + icon: MapPinned, + component: HydrogenHeatmapModule, +}; + +const SCHEDULING_MODULE: ModuleConfig = { + id: 'scheduling', + label: '智能调度', + icon: Activity, + component: SchedulingModule, +}; + +const ENERGY_MODULES: ModuleConfig[] = [ + { id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule }, + { id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule }, + { id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule }, +]; + +/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */ +export function buildModules( + pathSet: PathSet, + roles: readonly string[] | null | undefined, +): ModuleConfig[] { + if (pathSet === 'energy') return [...ENERGY_MODULES]; + + const heatmapChildren = canAccessEnergy(roles) + ? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE] + : [VEHICLE_HEATMAP_MODULE]; + const modules: ModuleConfig[] = [ + ...ASSET_MODULES, + { id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren }, + ]; + if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE); + return modules; +} diff --git a/src/app/routing.test.ts b/src/app/routing.test.ts new file mode 100644 index 0000000..d2f5a64 --- /dev/null +++ b/src/app/routing.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js'; + +test('normalizes legacy asset paths without losing search parameters', () => { + assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage'); + assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage'); +}); +test('keeps canonical and hidden administration paths unchanged', () => { + assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null); + assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null); +}); + +test('derives path groups and hidden routes from path or hash', () => { + assert.equal(getPathSet('/energy'), 'energy'); + assert.equal(getPathSet('/asset'), 'asset'); + assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import'); + assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback'); + assert.equal(getRouteKey('/asset', '#mileage'), ''); +}); diff --git a/src/app/routing.ts b/src/app/routing.ts new file mode 100644 index 0000000..c7abf66 --- /dev/null +++ b/src/app/routing.ts @@ -0,0 +1,56 @@ +export type PathSet = 'asset' | 'energy'; + +interface BrowserLocation { + pathname: string; + search: string; + hash: string; +} +const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']); + +const LEGACY_PATHS: Record = { + '/': { path: '/asset' }, + '/vehicle': { path: '/asset', hash: 'assets' }, + '/assets': { path: '/asset', hash: 'assets' }, + '/mileage': { path: '/asset', hash: 'mileage' }, + '/scheduling': { path: '/asset', hash: 'scheduling' }, +}; + +/** 返回旧地址应替换成的规范地址;主路径返回 null。 */ +export function resolveCanonicalUrl(location: BrowserLocation): string | null { + if (ROOT_PATHS.has(location.pathname)) return null; + + const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' }; + const finalHash = target.hash ? `#${target.hash}` : location.hash; + return `${target.path}${location.search}${finalHash}`; +} + +export function normalizeBrowserPath(): void { + if (typeof window === 'undefined') return; + const target = resolveCanonicalUrl(window.location); + if (target) window.history.replaceState(null, '', target); +} + +export function getPathSet(pathname: string): PathSet { + return pathname === '/energy' ? 'energy' : 'asset'; +} + +export function getRouteKey(pathname: string, hash: string): string { + if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') { + return 'ele/import'; + } + if ( + pathname === '/admin/feedback' + || hash === '#/admin/feedback' + || hash === '#admin/feedback' + ) { + return 'admin/feedback'; + } + return ''; +} + +export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } { + return { + routeKey: getRouteKey(window.location.pathname, window.location.hash), + pathSet: getPathSet(window.location.pathname), + }; +} diff --git a/src/components/FeedbackFab.tsx b/src/components/FeedbackFab.tsx index ab3207c..a527733 100644 --- a/src/components/FeedbackFab.tsx +++ b/src/components/FeedbackFab.tsx @@ -1,24 +1,15 @@ 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'; +import FeedbackLauncher from './feedback/FeedbackLauncher'; +import FeedbackModal from './feedback/FeedbackModal'; +import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types'; const MAX_SCREENSHOTS = 6; const MAX_IMG_SIZE_MB = 5; -interface UploadedImg { - url: string; - thumbDataUrl: string; -} - async function uploadImage(file: File): Promise { const fd = new FormData(); fd.append('file', file); @@ -42,38 +33,6 @@ function readAsDataUrl(file: File): Promise { }); } -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 = { - assets: '资产管理', - mileage: '里程管理', - energy: '能源管理', - scheduling: '智能调度', - ele: '充电导入', - '': '通用', -}; - function detectModule(): string { const hash = (window.location.hash || '').slice(1); const path = window.location.pathname; @@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) { 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 [step, setStep] = useState(1); const [type, setType] = useState(null); const [mod, setMod] = useState(''); const [content, setContent] = useState(''); @@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) { } }; - // 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; @@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) { const close = () => { setOpen(false); - setTimeout(reset, 300); // 等动画完 + setTimeout(reset, 300); }; const submit = async () => { @@ -193,319 +150,48 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) { } }; - const back = () => setStep((Math.max(1, step - 1)) as typeof step); + const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep); const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true; const progress = step >= 3 ? 100 : (step / 2) * 100; return ( <> - {/* Floating Action Button */} -
- 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="提建议 / 我的反馈" - > - - - - - - {menuOpen && ( - - - - {isAdmin && ( - <> -
- 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" - > - 反馈管理 - - - )} - - )} - - - {/* 点击外面关菜单 */} - {menuOpen && ( -
setMenuOpen(false)} - /> - )} -
+ setMenuOpen(false)} + onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }} + onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }} + onToggleMenu={() => setMenuOpen(current => !current)} + /> setHistoryOpen(false)} /> - {/* Modal */} - - {open && ( - - e.stopPropagation()} - > - {/* Drag handle */} -
-
-
- - {/* Header + progress */} -
-
-
-
- -
-
-
- {step === 3 ? '收到啦~' : '提个建议'} -
-
- {step === 1 && '第一步 / 共 2 步'} - {step === 2 && '第二步 / 共 2 步'} - {step === 3 && '感谢你的反馈'} -
-
-
- -
-
- -
-
- - {/* Body */} -
- - {step === 1 && ( - -

想反馈点什么?

- -
- {TYPE_OPTIONS.map((opt, i) => { - const Icon = opt.icon; - const selected = type === opt.key; - return ( - { 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'}`} - > -
- -
-
-
{opt.label}
-
{opt.sub}
-
- -
- ); - })} -
-
- )} - - {step === 2 && ( - -
-

说说具体内容

-