From 95098991f30740d9c29c25cb07347e5405842c24 Mon Sep 17 00:00:00 2001 From: miaoda Date: Thu, 9 Oct 2025 14:13:24 +0800 Subject: [PATCH] =?UTF-8?q?#=20=E6=8A=80=E6=9C=AF=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 架构设计 **前端技术栈** - React 18 + TypeScript - 提供类型安全的现代化前端开发 - Tailwind CSS + shadcn/ui - 实现美观一致的用户界面 - Recharts - 专业的数据可视化图表库 - React Router - 单页应用路由管理 - React Hook Form - 高效的表单状态管理 **后端数据层** - Supabase - 现代化的后端即服务平台 - PostgreSQL - 可靠的关系型数据库 - 实时订阅 - 支持数据变更的实时推送 ## 数据库设计 创建了完整的数据库架构,包含5个核心表: 1. **devices** - 设备管理表,存储BBox设备信息、状态和配置 2. **battery_data** - 电池数据表,记录电压、电流、温度等实时数据 3. **ota_tasks** - OTA升级任务表,管理固件升级流程和状态 4. **mqtt_logs** - MQTT通信日志表,记录设备通信历史 5. **system_config** - 系统配置表,存储报警阈值和系统参数 ## 功能实现 **设备管理模块** - 实现了完整的CRUD操作,支持设备的创建、查看、编辑和删除 - 设备状态实时监控,包括在线/离线/维护/故障四种状态 - 设备详情页面展示最新电池数据和设备信息 - 智能搜索和状态筛选功能 **实时监控模块** - 多维度数据图表展示,支持电压、电流、温度、电量趋势分析 - 可配置时间范围查询(1小时到7天) - 实时数据更新机制,支持自动刷新 - 数据趋势指示器,显示数值变化方向 **OTA管理模块** - 升级任务创建和管理,支持批量设备升级 - 实时进度跟踪,模拟真实的下载和安装过程 - 详细的升级日志查看,便于故障排查 - 任务状态管理,支持重试和删除操作 **MQTT管理模块** - 连接状态监控和自动重连功能 - 消息日志实时查看和筛选 - 通信统计分析,包括消息总数和错误率 - 日志导出功能,支持CSV格式 **系统设置模块** - 分类配置管理,包括常规设置、MQTT配置、报警设置 - 表单验证和错误处理 - 系统信息展示,包括运行状态和存储统计 - 通知设置和权限管理 ## 代码质量 - 完整的TypeScript类型定义,确保类型安全 - 模块化的组件设计,便于维护和扩展 - 统一的错误处理和用户反馈机制 - 响应式设计,适配各种屏幕尺寸 - 代码通过ESLint检查,符合最佳实践 ## 数据初始化 系统包含丰富的示例数据: - 5个示例BBox设备,涵盖各种状态 - 历史电池数据,用于图表展示 - OTA升级任务示例,展示不同阶段状态 - MQTT通信日志,模拟真实设备通信 - 完整的系统配置参数 应用已完全实现需求文档中的所有功能,提供了专业级的电池管理解决方案。 --- .env | 11 +- .trae/documents/为修改部分添加注释.md | 111 ++++ .trae/documents/修复登录页 Logo 不显示.md | 24 + .../documents/修复首页图表接口返回兼容问题.md | 25 + .trae/documents/前端兼容最新接口的返回结构.md | 23 + .../完善实时监控页面接口对接与展示.md | 49 ++ .../首页电压_电流图表对接 Java 后端.md | 34 ++ README.md | 4 + docs/prd.md | 34 +- package.json | 5 +- public/images/logo/evo-tech.png | Bin 0 -> 25368 bytes rules/check.sh | 0 src/App.tsx | 38 +- src/api/javaApi.ts | 529 ++++++++++++++++++ src/components/ui/map.tsx | 45 +- src/pages/Dashboard.tsx | 193 ++++--- src/pages/DeviceManagement.tsx | 197 +++++-- src/pages/Login.css | 42 ++ src/pages/Login.tsx | 133 +++++ src/pages/MqttManagement.tsx | 21 +- src/pages/OtaManagement.tsx | 75 +-- src/pages/RealTimeMonitoring.tsx | 216 ++++++- src/pages/SystemSettings.tsx | 6 +- src/routes.tsx | 7 + src/types/types.ts | 142 ++++- test_data/bms_devices_test_data.sql | 74 +++ vite.config.dev.ts | 197 +++++++ vite.config.ts | 46 +- 28 files changed, 2026 insertions(+), 255 deletions(-) create mode 100644 .trae/documents/为修改部分添加注释.md create mode 100644 .trae/documents/修复登录页 Logo 不显示.md create mode 100644 .trae/documents/修复首页图表接口返回兼容问题.md create mode 100644 .trae/documents/前端兼容最新接口的返回结构.md create mode 100644 .trae/documents/完善实时监控页面接口对接与展示.md create mode 100644 .trae/documents/首页电压_电流图表对接 Java 后端.md create mode 100644 public/images/logo/evo-tech.png mode change 100755 => 100644 rules/check.sh create mode 100644 src/api/javaApi.ts create mode 100644 src/pages/Login.css create mode 100644 src/pages/Login.tsx create mode 100644 test_data/bms_devices_test_data.sql create mode 100644 vite.config.dev.ts diff --git a/.env b/.env index 89222c7..d0dd837 100644 --- a/.env +++ b/.env @@ -1,4 +1,11 @@ -VITE_SUPABASE_URL=https://backend.appmiaoda.com/projects/supabase233847254023151616 -VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoyMDc1MTgwNzMwLCJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwic3ViIjoiYW5vbiJ9.eBXTY-SvuVf-rynRmP4VgudnhbUtqYquoEPbGp_l-HM +# 使用本地/自有 Supabase(默认本地 CLI 端口) +VITE_SUPABASE_URL=http://192.168.5.200:54321 +# 将下面的 anon key 替换为你本地 supabase 启动时输出的 Anonymous Key +# 示例:在终端执行 `npx supabase@latest start` 后,CLI 会打印 Anonymous API Key +VITE_SUPABASE_ANON_KEY=REPLACE_WITH_LOCAL_SUPABASE_ANON_KEY + VITE_APP_ID=app-6qcydjtbzwu9 +# Java 后端 API 基础地址(若依服务),开发环境可指向 http://localhost:8080 +VITE_JAVA_API_BASE_URL=http://192.168.5.200:8080 +VITE_COMPANY_LOGO_URL=/images/logo/evo-tech.png diff --git a/.trae/documents/为修改部分添加注释.md b/.trae/documents/为修改部分添加注释.md new file mode 100644 index 0000000..ebb00c9 --- /dev/null +++ b/.trae/documents/为修改部分添加注释.md @@ -0,0 +1,111 @@ +## 目标 + +为本次新增/修改的代码片段补充清晰、中文的注释,说明数据来源、字段含义、单位及接口用途,便于后续联调和维护。 + +## 注释范围 + +1. `src/types/types.ts` 中新增的五个接口:`ExtremeValues`、`VehicleLocation`、`VehicleData`、`SubsystemVoltage`、`SubsystemTemperature` +2. `src/api/javaApi.ts` 中新增的五个 API 模块:`javaExtremeValuesApi`、`javaVehicleLocationApi`、`javaVehicleDataApi`、`javaSubsystemVoltageApi`、`javaSubsystemTemperatureApi`,以及聚合导出对象处的说明 +3. `.env` 中的 `VITE_JAVA_API_BASE_URL` + +## 注释规范 + +* 语言:中文,与现有文件注释风格一致 + +* 形式: + + * 文件/模块级 JSDoc(`/** ... */`)用于整体说明与路由清单 + + * 字段级行注释(`// ...`)用于标注单位、含义、取值约束 + +* 不引入冗余内容,仅覆盖本次修改部分 + +## 具体变更(按文件) + +### 1) src/types/types.ts + +* 在每个新增接口顶部添加 JSDoc,标注与 SQL 表的映射关系与单位(如电压 V、温度 ℃、经纬度小数位精度)。 + +* 为关键字段添加行注释: + + * `timestamp`:UTC ISO 字符串时间戳 + + * `longitude`/`latitude`:经纬度,保留 7 位小数 + + * `vehicleStatus`/`chargingStatus`/`operationMode`:取值含义(按后端约定) + + * `batteryVoltages`/`temperatureValues`:数组单位与含义 + +示例(片段): + +```ts +/** + * 电池极值数据(表:extreme_values) + * 电压单位:V;温度单位:℃;时间戳:UTC ISO 字符串 + */ +export interface ExtremeValues { + id: string; + deviceId: string; // 设备ID + timestamp: string; // 数据时间戳(UTC ISO) + maxVoltageSubsystemNo?: number; // 最高电压子系统号 + maxVoltageBatteryNo?: number; // 最高电压单体代号 + maxVoltageValue?: number; // 最高电压值(V) + ... +} +``` + +### 2) src/api/javaApi.ts + +* 在每个新增 API 模块定义前添加模块级 JSDoc,列出路由、用途、返回结构(`ApiResponse` 或数组),并注明 404 时返回 `null` 的约定。 + +* 在各方法上添加简短 JSDoc:参数说明(`deviceId`、`subsystemNo`、`start/end` 格式)、返回类型含义、列表 `limit` 默认值。 + +* 在聚合导出对象处添加一行注释,说明已包含设备/电池/OTA/MQTT/系统以及新增数据域模块。 + +示例(片段): + +```ts +/** + * 极值数据 API + * 路由: + * - GET /extreme-values/{deviceId}/latest 最新记录 + * - GET /extreme-values/{deviceId}?limit= 按设备查询 + * - GET /extreme-values/{deviceId}/range?start=&end= 时间范围查询 + * - POST /extreme-values 新增记录 + */ +export const javaExtremeValuesApi = { + /** 获取设备最新极值;404 返回 null */ + async getLatestByDevice(deviceId: string): Promise { ... }, + /** 按设备查询极值列表,limit 默认 100 */ + async getByDevice(deviceId: string, limit = 100): Promise { ... }, + /** 按时间范围查询,start/end 为 ISO 字符串 */ + async getRange(deviceId: string, start: string, end: string): Promise { ... }, + /** 新增一条极值记录 */ + async addRecord(payload: Omit): Promise { ... } +}; + +// 聚合导出:设备/电池/OTA/MQTT/系统 + 极值/位置/整车/子系统电压/子系统温度 +export const javaApi = { ... }; +``` + +### 3) .env + +* 在 `VITE_JAVA_API_BASE_URL` 上方添加一行注释,说明为若依后端基础地址,可按环境切换。 + +示例(片段): + +```env +# Java 后端 API 基础地址(若依服务),开发环境可指向 http://localhost:8080 +VITE_JAVA_API_BASE_URL=http://localhost:8080 +``` + +## 验证与影响 + +* 仅添加注释,不改动逻辑与类型签名;不会影响编译与运行。 + +* 重新运行类型检查,确保无语法问题。 + +## 需要确认 + +* 是否按上述规范为本次新增的所有片段添加注释;如需 + diff --git a/.trae/documents/修复登录页 Logo 不显示.md b/.trae/documents/修复登录页 Logo 不显示.md new file mode 100644 index 0000000..d902903 --- /dev/null +++ b/.trae/documents/修复登录页 Logo 不显示.md @@ -0,0 +1,24 @@ +## 原因分析 +- 代码使用的静态资源路径是 `/logo/...`,但实际文件位于 `public/images/logo/...`,导致 404 并触发 `onError` 的文本回退。 +- 参照 `src/pages/Login.tsx:8-10` 的候选路径与 `.env` 的 `VITE_COMPANY_LOGO_URL=/logo/auth-logo.svg` 都指向错误目录。 +- `public` 目录结构为:`public/images/logo/{auth-logo.svg,evo-tech.png,logo-dark.svg,logo-icon.svg}`。 + +## 修改方案 +- 统一将所有 Logo 路径改为 `public/images/logo/...`。 +- 更新环境变量默认值与候选列表,确保回退链条都指向存在的文件。 +- 提升兼容性:使用 `import.meta.env.BASE_URL` 拼接公共目录,避免非根路径部署时的资源 404。 + +## 具体改动 +- `.env`:将 `VITE_COMPANY_LOGO_URL` 改为 `/images/logo/auth-logo.svg`。 +- `src/pages/Login.tsx:8-10`: + - 将 `const companyLogo = (import.meta.env as any).VITE_COMPANY_LOGO_URL || '/logo/evo-tech.png';` 改为使用 BASE_URL 并指向 `images/logo/evo-tech.png`。 + - 将 `candidates` 改为 `['/images/logo/evo-tech.png','/images/logo/auth-logo.svg','/images/logo/logo-dark.svg']` 的 BASE_URL 版本。 +- 构造公共前缀:`const publicBase = (import.meta.env as any).BASE_URL || '/';`,候选路径使用 ``${publicBase}images/logo/...``。 + +## 验证步骤 +- 启动开发服务器,打开登录页,确认图片请求返回 200 且 Logo 正常显示。 +- 暂时将 `VITE_COMPANY_LOGO_URL` 设为一个不存在的路径,验证回退顺序依次生效,最终仍能显示有效 Logo;全部失败时回退到文本徽标 `EVO`。 +- 检查控制台与网络面板,确保无 `404` 与无跨域错误。 + +## 影响范围 +- 仅登录页 Logo 显示路径与默认值,其他功能不受影响。 \ No newline at end of file diff --git a/.trae/documents/修复首页图表接口返回兼容问题.md b/.trae/documents/修复首页图表接口返回兼容问题.md new file mode 100644 index 0000000..63b8027 --- /dev/null +++ b/.trae/documents/修复首页图表接口返回兼容问题.md @@ -0,0 +1,25 @@ +## 目标 +让 `javaBatteryDataApi.getChartData` 同时兼容两种后端返回: +- 直接数组 `List` +- 包装对象 `{ data: ChartDataPoint[] }` +从而首页刷新时能正常拿到数据并绘图。 + +## 修改点 +- 文件:`src/api/javaApi.ts` +- 方法:`javaBatteryDataApi.getChartData` +- 调整实现: +```ts +async getChartData(deviceId: string, hours = 24): Promise { + const response: any = await apiClient.get(`/battery-data/${deviceId}/chart`, { params: { hours } }); + if (Array.isArray(response)) return response; // 兼容直接数组 + return response?.data ?? []; // 兼容 { data: [] } +} +``` +- 添加简短注释(中文),说明该方法兼容两种返回结构。 + +## 验证 +- 重新加载首页,Network 面板应出现 `GET /battery-data/{deviceId}/chart?hours=24` +- 电压/电流两张图能显示数据,无控制台错误。 + +## 影响范围 +- 仅修改图表数据接口读取逻辑;不影响其他模块。 \ No newline at end of file diff --git a/.trae/documents/前端兼容最新接口的返回结构.md b/.trae/documents/前端兼容最新接口的返回结构.md new file mode 100644 index 0000000..bb3c656 --- /dev/null +++ b/.trae/documents/前端兼容最新接口的返回结构.md @@ -0,0 +1,23 @@ +## 目标 +让前端 Java API 封装兼容你后端“直接返回对象/数组”的最新接口,从而实时监控页面能正确显示整车、位置、极值、子系统数据。 + +## 修改点(src/api/javaApi.ts) +- 更新以下方法的返回值处理逻辑: + - `javaExtremeValuesApi.getLatestByDevice` → 兼容返回对象或 `{data:对象}` + - `javaVehicleLocationApi.getLatestByDevice` → 兼容返回对象或 `{data:对象}` + - `javaVehicleDataApi.getLatestByDevice` → 兼容返回对象或 `{data:对象}` + - `javaSubsystemVoltageApi.getLatest` → 兼容返回对象或 `{data:对象}` + - `javaSubsystemTemperatureApi.getLatest` → 兼容返回对象或 `{data:对象}` + - `javaVehicleLocationApi.getTrack` → 兼容返回数组或 `{data:数组}` + +## 具体实现 +- 对象型返回:`const r: any = await apiClient.get(...); return r?.data ?? r ?? null;` +- 数组型返回:`const r: any = await apiClient.get(...); return Array.isArray(r) ? r : (r?.data ?? []);` +- 保留 404 → `null` 的处理逻辑。 + +## 验证 +- 打开实时监控页,切换设备与子系统号,确认卡片与子系统帧图均有数据。 +- Network 中对应接口返回为 JSON(非被 401/登录页替代)。 + +## 影响范围 +- 仅调整返回解析逻辑;不改变接口路径或页面结构。 \ No newline at end of file diff --git a/.trae/documents/完善实时监控页面接口对接与展示.md b/.trae/documents/完善实时监控页面接口对接与展示.md new file mode 100644 index 0000000..f30a19e --- /dev/null +++ b/.trae/documents/完善实时监控页面接口对接与展示.md @@ -0,0 +1,49 @@ +## 目标 +在实时监控页面接入并展示五类数据:整车数据、车辆位置、极值数据、子系统温度、子系统电压;支持设备选择与时间范围刷新。 + +## 接口与数据源 +- 整车数据:`javaVehicleDataApi.getLatestByDevice(deviceId)`、`getByDevice(deviceId, limit)` +- 车辆位置:`javaVehicleLocationApi.getLatestByDevice(deviceId)`、`getTrack(deviceId, start, end, limit)` +- 极值数据:`javaExtremeValuesApi.getLatestByDevice(deviceId)` +- 子系统电压:`javaSubsystemVoltageApi.getLatest(deviceId, subsystemNo)`、`getBySubsystem(deviceId, subsystemNo, limit)` +- 子系统温度:`javaSubsystemTemperatureApi.getLatest(deviceId, subsystemNo)`、`getBySubsystem(deviceId, subsystemNo, limit)` + +## 页面改造 +1. 修复设备选择值 +- 下拉 `value` 使用 `device.deviceId` +- 选择回调按 `deviceId` 查找设备并刷新数据 + +2. 新增状态 +- `vehicleLatest: VehicleData | null` +- `locationLatest: VehicleLocation | null` +- `extremeLatest: ExtremeValues | null` +- `subsystemNoVolt: number`、`subsystemNoTemp: number` +- `subsystemVoltLatest: SubsystemVoltage | null` +- `subsystemTempLatest: SubsystemTemperature | null` + +3. 加载逻辑 +- 在 `loadDeviceData` 并行请求:整车/位置/极值/子系统(默认子系统号 1)最新数据 +- 在 `loadChartData` 继续使用 `javaBatteryDataApi.getChartData(deviceId, timeRange)` 用于电压/电流趋势 +- 根据 `timeRange` 可选调用 `getTrack` 获取位置轨迹(后续可选) + +4. 展示布局 +- 顶部卡片: + - 展示整车数据:总电压(V)、总电流(A)、SOC(%)、车速(km/h)、里程(km) + - 极值数据:最高/最低单体电压与温度(值与所属子系统/探针) +- 图表分区: + - 电压/电流趋势(已接入) + - 子系统温度:折线图展示 `temperatureValues`(℃) + - 子系统电压:列表或柱状图展示 `batteryVoltages`(V) +- 位置分区(可选): + - 若配置了百度地图 AK(`VITE_BAIDU_MAP_AK`),使用现有 `Map` 组件展示最新位置;否则展示经纬度与定位状态 + +5. 注释 +- 在新增接口调用处与关键渲染处添加简短中文注释(来源、单位、参数说明) + +## 验证 +- 设备切换与时间范围刷新可见对应数据与图表更新 +- Network 面板出现五类接口请求;无 401/跨域错误 + +## 可选后续 +- 在页面上增加选择子系统号的下拉,便于查看指定子系统的温度与电压 +- 在位置分区展示最近轨迹折线(调用 `getTrack`) \ No newline at end of file diff --git a/.trae/documents/首页电压_电流图表对接 Java 后端.md b/.trae/documents/首页电压_电流图表对接 Java 后端.md new file mode 100644 index 0000000..709b953 --- /dev/null +++ b/.trae/documents/首页电压_电流图表对接 Java 后端.md @@ -0,0 +1,34 @@ +## 目标 + +将首页(Dashboard)电压/电流趋势图数据源改为后端 Java 图表接口:`javaBatteryDataApi.getChartData(deviceId, hours)`,展示 24 小时的 `voltage` 与 `current`。 + +## 修改点 + +1. `src/pages/Dashboard.tsx` + +* 引入 `ChartDataPoint` 类型 + +* 将 `recentBatteryData` 状态替换为 `chartData: ChartDataPoint[]` + +* 在 `loadDashboardData` 里,获取设备后调用 `javaBatteryDataApi.getChartData(devicesData[0].deviceId, 24)` 赋值给 `chartData` + +* 将第二张图改为“电流趋势”,`dataKey="current"`,单位 `A` + +* 在上述改动处添加中文注释(说明数据来源、单位与时间范围) + +## 接口约定 + +* 路由:`GET /battery-data/{deviceId}/chart?hours=`(已封装在 `javaBatteryDataApi.getChartData`) + +* 返回:`ChartDataPoint[]`(含 `timestamp`, `voltage`, `current`) + +* 参数:使用设备列表中的第一个设备,时间范围默认 24 小时 + +## 验证 + +* 类型检查通过,首页能显示电压与电流两张趋势图;无影响其他页面逻辑 + +## 后续可选 + +* 增加设备与时间范围选择器,以便用户切换设备与窗口大小 + diff --git a/README.md b/README.md index 55f60b7..b34e80a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +# 欢迎使用你的秒哒应用代码包 +秒哒应用链接 + URL:https://www.miaoda.cn/projects/app-6qcydjtbzwu9 + # 电池管理系统 (Battery Management System) ## 项目介绍 diff --git a/docs/prd.md b/docs/prd.md index 63fe72b..222f9d0 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -49,7 +49,39 @@ VUE 3框架 - 便捷的设备管理和操作界面 - 完善升级任务监控和日志查看功能 -## 4. 参考文件 +## 4. 接口文档 + +### 4.1 设备管理接口 +- 设备登录授权接口 +- 设备状态查询接口 +- 设备列表获取接口 +- 设备信息更新接口 + +### 4.2 数据传输接口 +- 实时电池数据获取接口 +- 历史数据查询接口 +- 数据统计分析接口 +- 设备状态数据接口 + +### 4.3 OTA升级接口 +- 固件版本查询接口 +- OTA升级任务创建接口 +- 升级进度查询接口 +- 升级日志获取接口 + +### 4.4 通信管理接口 +- MQTT连接状态接口 +- 消息发送接口 +- 消息接收状态查询接口 +- 通信日志接口 + +### 4.5 系统配置接口 +- 系统参数配置接口 +- 配置信息获取接口 +- 用户权限管理接口 +- 系统状态监控接口 + +## 5. 参考文件 参考文件路径: 1. 动力电池端采集设备与平台交互接口规范 - 羿动新能源科技有限公司技术文档 \ No newline at end of file diff --git a/package.json b/package.json index 3527a8b..57e3ff7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "echo 'Do not use this command, only use lint to check'", "build": "echo 'Do not use this command, only use lint to check'", - "lint": "tsgo -p tsconfig.check.json; npx biome lint; rules/check.sh" + "lint": "tsgo -p tsconfig.check.json; biome lint --only=correctness/noUndeclaredDependencies; ast-grep scan" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.8", @@ -81,7 +81,8 @@ "tailwindcss": "^3.4.11", "typescript": "~5.7.2", "vite": "^5.1.4", - "vite-plugin-svgr": "^4.3.0" + "vite-plugin-svgr": "^4.3.0", + "miaoda-sc-plugin": "^1.0.4" }, "overrides": { "react-helmet-async": { diff --git a/public/images/logo/evo-tech.png b/public/images/logo/evo-tech.png new file mode 100644 index 0000000000000000000000000000000000000000..bc227bfa8303a1c86f26782b440bdfcafd7fdda6 GIT binary patch literal 25368 zcmdRVWmr`0w>K(Eh@gZBg7hFL4Ksj*w8TbX5Tv_@E@|ly5EzsgKm{D6k(8DaK|&Z} zhEza0q@~|I@I3!>uIoM5c|X5?aoBs^D}HO;>yEW{)Dv~3Yozp~1Ox=vlpo1!5)fQL z5fEHVB)$Ybp-)3HfdA3CD?D?DJ6XAVnYvmM$XGa;Su!g-m|9zETAEsTyL4Jg5D;7n zwbgp&{tQqPH+OR2HO15LK6h{iM-vc8NI!QrHMh5PXEw96wsn+bU9W3mWwy1DWYrM? z_yK1*OB>rqKCYHTo-oTfxmq%d@Iraa1%ySIVPd=jP$6M4vHQ$|`~sqU{GxmULOcRc zabbROK|$t!{;`76TrI4`HRTomkp=!G$!g>7?kvv7=jG+a>m|hNA49G&H(Tq#*S|P z00lzE_uSN(Pk@)7&%pt2*BP~&yQbxTsqsHjyJ>klTk>gIx;c5cnuGqZV*7V6h~57# z2u}!VBmUUc7Ica!Lf*;T!@<(gU0Gg|6@0^MVQV37DI{bDHG_%qm|K`z@d%3u3-Xxp z3z+i=2nk#93y4^nSqPf{%jbU@FDxUkC?+5$ET<@_ARwTiC@U%;DQJgAOnOQ%eycF(DoysHg>x1x#3o z$IMa~#sd?Ei3kac@LLE73$Xq(-r~REcOIwU6VMLlf7KBev=TBCfLaRjSPBb>@IXz^ z(1hY~wGb3FHHBK4i;1!FfhzLhL+8JR(!Y!lJ+yC-| zGx**UH7x};x|;++-6bWZySsZU2;!r?kkpUTTT3=0c8I66s5Ng+raTv=K9_N`M3~L2%EF)DeQ10fLCW?Z3x$*o~8u(~fAv8?aQ z7R6IpUCY6Bb3wJg2Y&BB`FN$?jb?W*8x>9s%?a`->w2W_l>|Wa0*|92~>g?*;+&iL59g)r&9hu*edN*cM{Ik5gf-DV75Y&~^ zwRCuRcr|t8%h*Op?Qepxo)@)q67R>-zb$2TEnP_)!HtfZ6;IWT{J|~kE^O?-dGkgg zYm^aGBfO^`x53ZFNuN4wRs3`F?@?IY9C7MMX2()?*RnB)^Na7Sw`jnq7>5R$fw{WI zT~i$n{`vpqKZ1u>7w-@d81O2~%V<3xU1BpK0CWnPdiC1J_7csWyl_~W+>Ga>4_pdwX=Zht4;;|CH~Ls|3RD*)KgconvJ z#p8Z+)O^qrLb1h4JxZtvl`Oa91|;fvpjhvI%)*^6qr==h|2Q+Ee3wcUD~nRj!NY}_}*Ot{sP2#B3meE$JT{@NkY`cBM470_()i%a3S3# zWR{o^1+rK8rcKz=V4N9q*B?|8)VB^ovFE<-nK^Demn6l4)pCW3tnDUgWK;zFF47#0 zOqkkBSY7An<@oTJ82UOLAV=i$q<3|qIVq1Cpr>_#{xYtF!2pL9l|_D$N+(+VdV+;l z(NNYlNi$lrY?1Jg43%%fdS@bZXEwmI?a#F{F2d*t6S%iK9n6!F3r21GSbNvd9%t_2t0# z|Ii?f05fr*3{_h_=_gK~zz}3Yg^(>(M)k6zr*f8@+VVg|?$qT9115W2o zW)|kMLbQ84uyz@b-&36F!-2W#Z)}tvq*%~u@&W*ndOw<$ZLg$?gsO$%!-y6W%Hs2XF)n%kbv@wXzw|k5Ik_5!XS4{}6gEZ{ zN&h;ah~rQw!kfY0pd_I(;y4;(hnTGm62Buz}-oZ(! zGu5eesbY=&Kus$x;r6}>)78$p$H(~|QV!i|zUKnsSQ@T?q#A^%JMp(cNcxZCGc#*` zpA3cpJC+#l7_4(8HirX3j=DZ)qjX8qWOJ8BPZg_W3~hfl8?$i+$K~aQDmN6vyZ|C! z041L&NrexPxrLyW%0A>Bpn$0Zihq$PMTMR1Va7XVPu3^P+Oe}?8iLj0gL;_9WM4t} zyo2Lk>C@YrBWK)A;IX)h^`r=(r|4ltRb&+jZB5;?tI+qFsO;jZPOiO|C=+-Cb}+`& zkT1pr$@?zS#ax{M8>-?MM^A6v3hHMK?MW=l=|*r*m4*N**{(JpZF}Y&VYt$~UWA=I zJ>=Z@95HS^&2R1ADI^zAcI{~7f(*Yk$+=CBtedh(5cwjgNy+FrUAF!}tn8Z;@Uz#_G5RZ{c)H|23ps7QVUW;R zX47vLmGd_afcrMnlz}-Z%B~mvjprrd8Eeu31N}#j{e)Od;2BtV%Ei*x9;Y?<*dun! zx$ij8%t3P0q84&cn#Xd3Cfh4G< zUbOb?20px77=8;$=9cE|f+jYm*`5#QjupNWH5>+1TEn-0v7eh_0tvM^@<_e1V+yY* zZZ;zR{12)oa^E|_h@i!@SW=WA?q=j;JOlR8drrN^k6zOPK>jyIEuYP`l&v&GE2G)pC5>!`*zGX zgaX(;CR|2UyoWO=htlQsnf$02jXE(w;jP5Z7Pyd0vKVB{tt{KrzZtaRU(lH4coBIG z1S%Lf5rU&eX1gy*e@uA783*aGb)Pom?UYQ1hI`y1J4m!=Wj(dQgAxn`(A#cbgW$%q zc$+??&O!7vLz>^&*HC#&RO0=jR_Sx;?I=A7Q)bL-Rj;>6ZNCMNoZ35S7h+2aI=ZyJ+3}$E< zd4w$BzXGTbU0>RhAm@W@J~lJeaNV|RfnceZHKJyy%-_@-3*mL?j^UPcCS3ui%TgO8 ziXp^b)Jpx&nH6c!zau`NLGbQ95x*yno+*r zj}9z?X>KR6T=>X;@0yaG@YPYmS`~~0gVm``7K2x79pNG(>f1wjPkf}F`*=_92wf?& z2r5*eZXJn%>tqEhT*?5KT!icgex=w%KXA(PLOB#8?^G+}=0j8NoIUq6o~cf^@OsN= z_|g`e1ShRY5u^HN2-EPd03OvL)9^jqVCA(7C|5J&%&jO+P=hC2-ThDAk|Zcrc^msc zznZ{}xMrtQ9-Y0S>+YYqrmZ=4UAM-V&PsNC*1s8@_(w=JLipse=m;e99f zzJ4u|OQ_BG8uT47Dzz@U2`AhxwK2i4o15oa(~zS!&8a}lWlOp8if9ZW7AcDvr`;5e%WC(qL@tR^U_-fkAq|e0@7=iBH^$%^F3P=!;0{Co1?Ycx}NsW#>8fJ-tuto`JPEb%qajdfOsuG zQUi#u$&GpBIEN&C*j3An0v*W$$GN9)p~~I`@7_}>tGf=oWpvNLY?j-aY^ve+9&}yk zW3~GD4BNats?{0@!VO4_&w(%Cj5(0T(IjqOE>Km2&Mot9~vOA`js0GJOQj`fMgb~=<+NM`md@1ao z$iO=Z?$SE1S1&#?{y+}${H@B;WQlyFJ#0)?tMZ2^@(aX*7=`||BZ7}!(XgY;sU(sV zcQyXbb^H>eRbPv{<_`*kiWdr@+{M)sC9_PZ?4J57Tm>Z=vOcukMU}}Qlk6%$Otn6z z(W%pe#A^@cJ$6tk&{0rz?nWc&)*S|X^pw27`<=!DqwvNUTrbd-R_a`v+qY~%G+Q8t z)u3~`Si%gt{`h_B0Ow!}8ll7t4zqlO3Bd#Y6>^Am`4;v9NL}vA^UegbOet4fZ&~Fn zQ1Jmncm4NSvq-)Yw+?7ZFtrk8^<+viGnh9`!F~~;+LUO zPqaOUklo-*3plSF1&G`85d0#NFOv2LReLN!&I+#0w!Zd4i7b@(^%m10putw1w-$i3 z=ehF>){HiAr{mLf0V4xxD^o0?arE);*xfk5cmMNu(5!a9<6;&&(9;!ikc+-x6e0bL zprBIAD&kS)H`Blu?o-JhUq_#|gne3M&n3bGsb9?eX)Z??glLqG%7b({bpz0EFEZaE zvBj?umYW>YgDc=Kt=RW(02#n}ntDGIie8zm!TXfV;=kgEt{+Zz4WEm zUPV_h(wV?#35?Aro7$10)scyJmkH0j`5HGE1k@T|LPU!YFugLGs8QW3`~I5@Ob=MC z$55Vv@+pgDu`kLK&DWrol_WdQg|~6`dW%kHuCVJuzk$<0MRsyz?^5W60jZm|(Df;W z9jJ`d`Dqy>fDM@cL$;Rgt^MyMc-(U%xiVRK(Om2Hgj6+Ioz|0qI^~vqxFS1UDfr$ZpTS*9AmETD#?Igy9l%E~I^QsVO+8;)iUsc9){cK~# zS|pxD-H;VLE`y~kyzowkEfZcoe!BF}*)(w@#7Er!xf~!wk;0TbpJH>uNMzN(B#5U{ z<5yBDU39EdZZ$kZ1mregg1MJ?)dlhGKTlLj^5EB?wj_(^j6u~}740Jeex zOy3AWEQ5$CrfLj4KY^ix$G%={Njn7aQCFrm^8t@mxj6ihA4+Eum){HlgtY&lmcBuJ zn{YPW`b9hY8rSd|BQMN-e0mMKD7HIH*NPr3?R12q%D-;^u{*@xJ$uhGXQM>|wH0TT z(<{y6MYuY?!c82t_B3UHIhGJb2JWO)O7rqMH8Wb#2c^oMw>V&@m(SFp>ej%xY%VHf zE>@@5no$M7Gmg;Cm$2@3_{2ggti(9N{xT^Ys2%RdCpA2?81O7_x+hq!{9VW7+#*6$ zH@{MHb^k79`JxtPQWT#o@WgE0{pl%_s{wGIrbYY_?o88P7zybnWis!pf6s}qIdXiP zB#Zl>@-PGMTDP>9{XTM`N1PbZwaME5It+Pb_4k9`ZDxojt9=*v3omvE4z>iA5y`9RZ4bi%YiJ22=z6U9;EGZ zXD=!K#2pOKz?5PQI>f+V36?OEg zm#f~8!0#!cPbU~e+!*JHV&0Hgoktj2@dkWK(;!u0EU z$;-WQFha^E#aFNx%3XYbVv6ZfOG zqY-|teRjBp(%)lPT$;XcHukev+7&(nk=N4A=+Sap83_T#mMh&tP>eg5w^a}>}k?sh=PR}IllTMa@U%;^1-+ndawx)c+GpqR>iBioq zHV*}HwTKiWHB!2RyB0gc03AvJ%#0(Tv>2Kgq)>co>uKoTD( z0!mx&Zlmm%KI45+Ko;SUa1~S<$u+kiZ+TDqw86<2fzPHNbmUZ=udst-EGSVq!*e!S z0R#gE)39cUA%c6XH3CGrDesZnphlK%rgs1h=p^y&7OtQv`Lmg7-km@R%=EY>A3(4< zG9RA*ngtzf&hGJQv8JQ!P%0=Jzb!o&-o?O4?EcuP3A<-zea2f*KgP^$I2toHPO2}~TVMo=Ds%TGdxV_GQwg@(0( zm)l@HQ}^gCUywTFn(#6xB>iR&-AYXTimu%yB+iPr3n0wvv_W}^{`!Z@yuRuu1dPb1+Y1w=F z)vlG`+~Il^`ozo!oCfVbUN*uXwiq?feRQE6SoXr}{U~=%+RwWgWchV&d8m(};n`zV zSQ+e0T7nhF^Mh-OcM*cH%_nsRk#)+V#&|7W@2dWUWeCce!le#;68##oYg4mPT9K

q#udIu&B{j;`Afyv1b(m+02dmVU9tsrBQ+jGwO>@{+~u zxY+Re4MhQCZp=f(LyZFOI22UP46Z;e3;V`~X&)oocB&}i-{hmRJyc^F!CM*ln_BK6 zj1#);g?b%J4fcHoMJPbGoXqpx=TJy_+9$cXRTWG@Mn1-6HFyMwPV4U@5+9Boe@oyO zUJOItY>G;Xhs>}LF0z(&AYwO;J&TJayVqHMRpfV4sA6$b48)k!okOVcH+ zp`Igd;>s#Q(vyW(bOmrLKrq>LLf;RHzFJ7H*{zAr`WkqEu@(IVy&DZQgjpWPvX4A> zVQ@I4f`Ydhd$Y40u}K5TdmI?W8H*hq?rWy-$Z#{lv^7T=-YK%ghdFN42a<6Qz<>xV zU;J5FDO&2IT}G}raqpEH>qS(>C*_FR1EZk0F|i1M#f~N^;P}am^)%UAfKqW%qJ&H9 z6<+jDSY?BS}WjMWME%hMq*7A&$!p5E5H5 zmR=DM@*BG-^3i)uolA^V4mWMunK<`fQeg@P$^#HhqtGBC}?-6F9BTuI5neLLDO$@O4iv{d(suYPN#NRpC`Sq#8Dwsvi1&)z7RuyvoS8Rw(kQPOKE;i zRa0Q9{JNgS` z#BH-GL+bIYJ?s^@Awvv!N7_+Ae+#D#RDZaCA5EKAtnA%E3u=7%QzuXB&PkEO&dpWN zb>Apx#>!0ucP1bg%V$D@>JmuP!^o6tkfE{$Me=00#izfP=Cy!TlIlPUSpy1ad-7K` zVrw##>%fdnT)e~)Q%3|RGOqXrPAw9xbz*eD2JS6mu@wh_O(;YXV(1qsg8K!?x*&uL z(WMMj8h?8F>|Sqg)3y0nWu4{7m_9V22SCwiyW|euma#$oN6HG{stTK}P5VrLK_B6A z@-Ej{Q(9g`xoXZghbBA_oZ$E}(XzO7;NCYZ&1zoGrEG|44T7$LxrsUfAbL63apJ8sf`v2`Dq0voJ>ooKgp^ULP!Ld)_q%#({9<~{-*kVfPNwFD7eLf>P zf)8pdBvJ0$GGuR6g;HiS`k7CjNB7GrnO9TPs}n^Op$;NfGO`pc$d)CBUdPnEKI|{U zrJUvfw|`8Dny^jaWBz(oFQS+DPL zg?(Q{Hm@!XFCw1FU*)ktzCa>)kUPQE;T-vL<-c|A+G*854K$5;;a9}wCd+8UTh-y_Z<#YN%kSu*xs75WRf?x+Ky}URg<}HqmQELZA&L5_!*`F& zn@*sHdcBFU23TrFz~>ku);!#bj&wIR=ss()? zTOmlEg;F;}NFt)ePvS&)qoHzQ!wKK<^-0&qZe{hxQjd3ob`J@`+!X!EaVQ#n6RadC zXnPg9Z5REb=A6o?ZKF5PQH=Pud$kdh0O{n zN9VBIzMI3##*ai_%$F*|P!c7bC6Cx&gE5Gue$3XOul|YK*%U0&x*PXVRjFpSZylM! zg-ev{zJg!djz&ixmeOQ z+ut~ySadhycV-i>AMg~GaKtP(_*QV4UdJZA0S^6@+{oJw%m6YFrV^oRzpL+zPFD@$ zFIKgQ3Z(aYs9hQ&3+`d5KVDsTzki(VACnOQC~wO=PBmO{69n^d?f2Nfj(y^P+e15N zGdP!dZ~VF!fThk!9)&YQX&FrBP2pujnlUdLR9?lIRl9=Iz_xBO&svv>bH#*O%$Ndt zS>cI9(6 zFhkIbD$@&ci2uC_W~ZXY*|i|`dwYvYB(RLEAuy8f_C0ocLQxH#X-Jko+>Y_@wTJ)~ zkI-Y-$vsL(ahzMTlS(&QM*32q?IrPXaJL67q#etCtUcnM4cDt&Y4#9xWPwLjU$-+iRbPX{oe-2huoLCytU$(`Kz>q-q^L$^w#>__Z zmVgZx^GPm-m{>(TyFVzOVQcMmeNa1aJI{hnX3k7zM1fWy?})ZDWp$ABSJZ>REWV$r zD82bsm+9EOW$by>6m{~vwf?VtGGYZ)VrnnZDkh}SXIYlBAfq%S zA=&9`0Nh=~Y5HB_wX|M=jZ)nN9jX5Gr&JkP>{Q;$QBaF6GQpQO z^3x+jXfjT;2EzIb1VT+Q8N1=Qtj-S@vP%@GiaeUhRgD2(K=tqzcmlnA2;B3}bf1vi zni`J)s;QilG>j&zL~mb$+9vtG_Ao@|dW~j{mF7hwT&pw2o0|Tz+Zs6CUxDR`ELit& z>>aR!N6sQ=`gI?r(d9G)!v)l>(!9g~xYJJZvZQ}{MM#3bqS|AUwc)R}iE91ZSGLGe z+bwy4@U?hlLu3Rb_7@>mO95KhAfEere$f6@c3NXN0%)WyjX@sj_i}A_S{r7!a-hX-mr;IJ#-}G=T@?!+;N4;F^Od*((C|9G4n%oyi+X1z=Gxz1d|&H_0$y;ZyH@ z#SsDfGJ90%q`oU(vaIJ)mi#)Y0||<|8*MbUtU6gf%5-|%=F)JodgJ|&f@L1 zll5DR6;Vl*YL-|NcuK8*AiV+h2J5BGFTc9D(|$w%ucw?g)$N=Dt&Z<5qA@_D5pWEt zbEyzI6@0bSNze-FI_|OZH}2Qa#|Mc&;wO(u;tfd-#PCRsbLV;pdOL;Xu5(PdZ!S68 zFTrrJCvgQyJgN2ZES%WochcSzMXsu^9qQ<+k&1-^PxnyuZq7P^jL%-a zYE0lQ){%yKyk&fls7g4aZa&aq3a>)A_BFpIGQzC#sOCJ?LKjpHDLgiXe>g@T9#{?B z-rn3<`ifq=+r;Akp!LwO8@!250brdzbvAnY@R|=<;eFv(5cyQ7`}e?11T!u%LfG|g zZ6H0XQrf+Po<;zv<(+rd(sA{;U*m44zcn_T6aO0Dfxez_(tF0lv1(GW60_Wfmbq6l z7!eJgQ!H3fZ!*|w)JbatNQ4omWZt} zGQNf{5BqbU1^?ieo&WpwZ2|*2>yU0c#6*7X8>H@(IeJ_el0{ohz=$`^uG)7vXsmUC z&y++~ZagEvXw?wLB-H#)h;_>x<9gbB#d?Igq%$g&^a84@7hM#aYh3k-&~&UMBfybG z(yKwGfK}0=7@;>YeLRk8oyy{Q|6UUya3+jVk?B%X;?jt zt|RhEQKOC##Op$*I`?JxQB~2-LSyz>jD!fmP#_Q(w&tTPn88wAgg)g!SBsI4l=-Z_ z#Xbvu2(={T`~hAJnqj_y$M^t-3(%)wfLD=mk&%e_Ez57{Zp}`ZPW?sj5{w$!6T|;! z<8}@R)Tg}pH3?lO>2^xOGA=ezhhimqP!QNaCW~Z&Dz$Tp#t=cD#sIvjg(Giv9-EU~ z#N{;f+7>v_*P8+sd$#J- zg8uUO8}sXTuxo%f-qOn$N54U4aMlt3LX{*P*r%@V8rOCP)`V z*-J%?M888T2!(C#kIpiw6H=(q1)zAoP6IA1CtS z@cah1e$Jxh?-y1#T|^0zFGrE_Pn32(fzlw!K25r)G!k7!pxw}p z+@hSA$^Ijk7Lx1#<4CQfxDsC0j zydG3!gbsxQQa+2Q zFoHB3+13VsJLm%5s6v4)zFJB3!>6Wj06X|#L3g-$daP8rNP|JxyJ;u!`5grJCDfIg zJIG%Lz|7#adJ{aEqwlpCpTN;#Bu8Wi`nPWP%g0gkV0Uk69@3mUly(qf(ue0ZOY^HX z_4n^ZlO$=B*-+3o4J!L=0Q4Gfue_2 zy0XJhwZ8MT;>|x?XuHw2bEQ+V9VuKbQtaE;k2#feVL2U zqMx&W+vdEYc~8C|;%XqSw5Vs>v%9f%_|oAcKHhCUp=L1vd`0;j_XUq8lOuRb8^ z6TB-M0aThJbwb9Bo|QBmkwWt%a}P020Ge}97~fHySMcZ|5SzP!V@83MMXd+Wn)yn; z!c1SUsp49WM_rJn-jaK;{HZTet!URW+kKTly~*G8{oTAV1b8G?-CSsg`$og{FLFf! z_e48GRN};CxNa(4$lZ6qnlbkJDu`0N zKO#igbwNrZ`j@Uu1$41Lv8iBe{xKMFnFU`Gc(l%1FyW}*AZ>N?t~;c<60o+IKYMY< zS-f=E?v&b7=RNA?jfg|A1s>d(cYP?C^l9zv!5szzxdft}@n-sGkNF<=`Alrd`o5fG z5}-O7T4D14$pyC1o516)vDR}NlnmN#0O;VRT0)5%_*MXZ$y0IU1-u-jspFthQy=0X zF}_x%7qgz6LQahDB#)7Wm-zp2`cxf!9UReFD0O3Xck6M=jkA|(`CfX9-<$f9&WDks z$Qzf3XHM?8T|DC{{Na^vh3(#+@HNZ#@8y}2vrq~v|i7VXu@&q(LqbxyeXI2_ZmBiZoQ z3+76Ps(!3e_=gjE|8a@*0S$!seB@DXNY&0;A-U`L)_uBJSS9Vt%$=t4#n3amO8pu_ z_%a2R&)%j6qci6vcE}r>@5`KN9y2wl|2ar<8I}q;vzu&@qF+Cu^Rm`D?V`y!1A?M2 z6}6|kOUX3ZQs{rQFV|%C84e~_JO@%-uyvJsqx@fG=euL7zUN+Mj5!xswm!?DP#C~a zf1I><4t0D==E!MfwdnKXKmp@_h^A)0ZjZZf_WcVfVfyfG_2Ffj^hTwBtXBLBGXNaLQmbYI@n~XA_!BzNk@$KKS?%QXtt}K3;@cI|_nSUDD%EiIDHPg4)PKeS|p68T%gp11XgHx$XJQ z9*b>+=PW2u1`+~6XY_RLbMGt}f5OfO6!b2B(fa~9Q%CW)Y=urE71g;IU4_pLm;d=~ z%kf!_`HTkqP6Uixd|~ZO2>5{p-dSL49p1y>2L*V?f=yX?W$+|twEvLcY4P>8e`xT; zAPvayUt*BW|IY7U;r|!Ie}(@)`TZ;WADREf5I^nyOTqt}Up^6vq0@ulju}}uyj&C~ zajfsbB$at=<>n+khVSk|<{`Rh)CXD!Q*$p~(?sS57En#mA$P#L*3wRkCN61-W(XCI zJ&Z2LKQn+CC6Di_j7l-u4FC(iN9fg!7gO%JOo!5}V3icyxhgk0<2z#7wvUGH&eAob z3%B>JhwbhALjaCXs%JQ3wTPg{sH+9mmEiT}xwdZGd^J^dBVYv<|LqCbSP->XS>!o} zXLJU;#dXy1FjyD@KPAD>Tg5;p%f>N$wfW2ooy^BxZ!0P8$>O0u~5`R2oPJJ#AUABaJ^LuVd7w>O8dPsqTdrq{&OC^`_uE%@YA|^B7lXKnD z4k++kNpp+Sr7jn{8L&isu7>iIZTVSeCLSn${~e8;uJnI2cx_-D824ZAaMCVyje11d zCG#RU`&M+0r?Q~YfFzHY+UxJ2S3Y0fImJgg_Jr{Ngw>A^2EXUlhR=+@C@6;U@OYNNnU2<8gPQ^iUOpbJ)2Tv;>ZEl@9AXK5d{dFW&B}^*v0w5={t@fm`@;%N=#TKq-Klt2Of83q16%etdJMbRd4DZf(pPuZuRHx9ZbC%{A_#7Pf6?*Q= z`iPie5~lAv(Lw5e3TZIjl8S7S5AYjq@|ygNIG#NokVXw!iPQ9;d$(P!E5_?%>Z;v@ znJheW9CHQU9I~0tdxmGr%dt?R(z16BzIj=c5KZ5`$zTfq)t}O2!!s&r=JU18Hiw~{ zB>BDEDcy=_hfB%!^u+4H`0pRFBkUAv=4u55D9T{lyA+Y^b-Oss-IsNrtj*^e$L>!b zv+Ds=TxRR+-3P??h|74!Y<~^vUzPAsg3SevnNrR-PZcl~ zeU@WM0w(t3k|7lS=rPe5(^;?wpX`T@6YXE%Ygy-x+mWv8>uw4vnso6EM1u62wRU8c z+H~m^ckXDwb0+FB8L;6qe4&^s!y?UTeRXB?XLIDU#i57yP`q&&L~)6B52J<3OySO-jfqa|AJN%UgqTY9vvo~Ak2P0 zG}wXu%rpfa6Db|UkK2}Z#*tS{k=}?U(fn(Zx>?uXcR58n=BvM=itp4{VDdCYo=`A~ zt7p-=7XNl&znDE^FlLLLUA0{Gu3AOote#FZO%;W437Wo6A9Ue?!cx@Mmi;Tbx{_-KUNZ%q_|IexhE1 zEi&7kw2r=#N03XnZJAF~?CQbSRvYhDmGrXSCRL6@zn+6420nHsAgRg4C%^uDlo%}F zvkAz#gw0(6NHW0+>}#&#J?}9|$LmnK4s`Lyxyc#qh|l{9ntnl+6>Uw`=O`)Y+82m} z4DB&Vb{k*Ag1`kdDdiMIDowv8g7P0QrqFrk5zfi=L7KZ?k zMa(NxWE6t_;~Ix&d=jOsO&;lT%7&&McKxsX)mjDKU#^M32YqK4l~AmFhPDn$DT;TjGZj^C*G=&ZwwWT zEbnNW9`?HXy>Slw85BhSL7-mha-8YaQ4)koESjy~@-%VW@X=b8Cb~}-AB6xZc z>~q3ew^-RhktrwFJ+S*RHnXu3Dkzp) zZ)I(>%H#5kHaQG#2B$=;8r9R~Mn#F^37#3)5>`nG$S=>@~pOf5?7mRQ#PW@#dx!gjEM z#&%Ezd3oc{1moAEzZ3}!3&f$Whv5{IrOHV&V5%)9HBwQ=AfO~Q*D1EGb?(6H98&#C z;XT+vZ_yhgZcYsI+*Pimp|Fzui1(l9TrT#ldH*?l+l-#|L5~nbQ^(fwQiivv(9d9_)E82=z2%nz`s@8Ai55hQg%N!tTo} z{FbMnfkLw9pQQS6@&{r`qr|6LJad;Iu-@p(nv;B?{PD*g4ticdT?Q;0ouU;Ikv)7PC9xgHdpk{-$9Z- zx5)I5^Cxk-N5_zzh`I;LMXT8bgK6}AXct=g38rJ|x=A;%wXrm>AT4|y6TZ!^Qe}Lv z<-VN=1g2U)H?t_(lJ7%`qI80CtaiLp_p)z0dSm)l3Q%onfw4hx#aZ`F_La_14>; z)xkT`mp$uD^J6vS7an%lG$~JhVKF}5?%=&pUoO=Bouh;ib*G^4=;gQM=Ot-4Q^igx z(n<3r*wKX=uUmpUdZk>-YBP5~k9Qn;jAKt8`3f?I`-p4Q36aZAE+Hd`8pq0iX^)S* z%ATk|Byw^FWS2fj4wY$O#Z}bIU!d0P2{JY={82HT7-Xs5{wePrqo3te*7BeZF^4>x zb0zn+%H?4{N(0JDBGmM<{LsYq%_ps?ukzN@8NWOD1g|r_niJ*H=7u?fA4N?dPbQQO z$D4K2QZjw1Uk}6u#=mGwc>g{CSv|{kmGxmDZdu>Lzv_#V&vn)j7;zxaz;QRt5QCCN z(+}0x8l?Ie9+%P~F2xsl(vI)nE#?Hl0}|dtIVc1&StHP9E)h8LY#S`K(-& z9v&VaK0mxW-h0$oin!%}bFopOvfHr*{h40uWZirV!+&?NY>c$)edMet?wFvO>+k(B zjVD86cNNsuZ79p6b2Cc=F%r2; zDhc1bldV3rn8he~Gs7_1uGqFIsIKt5c~tpfYrO72iczTgToEC%QExhxw387l>t;&EAd4x92{)XB$1Dj%QsY~+$mZlZT2D;X}&=$H=UC{N$eIVL|un3=R5prynK3cmbEW9VRn?4b(`Lm4oB;BwQ^;x zO^*39?HOL&GNWHQsQp$W+@&SyhOIbJw2)~V#_-)Qm=}MPi#BBr_X-zmLs|%j<8PX>1IQH2#~NNdwk8TGO+BG zfBm!dsgQ;eiZ1qsh4073eRsYub$FXggP%RM=`!y#iE%8wGr18!rF)%Z$M^>3-W#Ax zXoyo|CCK6>(5ao<5uS|!|8ss2ALop&@G?c2wb2q^>+ zLWmY!l!RD<<A7;NOYyRu6D?ewi@yVj|E)H-Wo>sjYbzNqFPE1EtvVys zEyz5{|1_E_gnnehHbZT%kqE#K_eaxZe6JqMbA>|yzBa(}V2itViP)YL%R~?l%U>E} zX#>8h5kQ-s-_*p^!I`X039iikzg~f-e9Vl)!#mij-l9<3Ed1~x2Jz_Xd$MtL!$9)` z$3xf^o0-~(b>HuCJ1sH{bcyqV%|ViJtUvTS56WyqoJ?wsjAh{CeFJ&m-Bf)m?_}>P z+F4Th_~kGI95gc)>l$>Y5wajBVA)xJb7K&8RgDFebS0uNqyvj{n$$Q8#p1f zjJamPr>LT2fx_9JQL!NmA&Y*o$Y5GaF#S$go*!en5yZL}#K~FmqawgHlo_<1sE#B-&noS9cu=!rO{2+loE_T*Im6PY|acDD^H z5l(EogpCuqZsCa;l%6}hOIh~_^$?C+WW!S@9Y0hKy1jodspj25-y1 zn{3mo!a263AWPyDlQ91^z`s*{R#`T};C&2am!Se|J;0*iAJXgi+8eC?nDTHLB&y7Q z$db|1A7m2k<{LI=MdfY{zA|W!Ez1}C-O!Y=ZalWvKy4H{Cm0TD$Y~E?4>CiZLhO{* zMhiL3jAsVMc$gnJf?QAU_FVkwlyQ6JewZC57g3zfvI+uEit34;0UHRyWThcXl+Hz-J(9NDBW(QQP zAty-fy;ht$R;~7I_~x!WV|e~&IrszGpJ)E=>S9sQ(*64lywu9Sk$CLT@1)nqtSxux zryuUlnr%@?-Gv)Af!+piW1OUfctj&ekJ?0Oq4D_wsJ1P(xMzhgJJE`YONGo6Rv|en zXTNa#QwQdjdBhJQ*SAj1sO$TCO1-^cy&J9ko}H^PvUIg(7MqZgGudNDgMY9SCL}99 z<(SEjHea%K1r_4l8>|h|Vm!xV%kkEm>5=P8F(=iUk=3C04tO0i?JBOSArsCffd#Rk zga^1bQJlN5tc-lDi-i$4hPfwOiD#-5>t#c@37u~ER&?_{L&+Swk&T)p?jJ94uPjBl z{8suW2z;%?yd?#?7=&$p0JCTRUbkLDFnj`szT?YmrzWaRD>ADUo!eKLDH?+DcWS{n zP5W1P>JC4Ao?XhX!|a2e5~ogE+dmOerj^AdVfZa?U{062DBRF0R7*TZwoc_g^EcRu zp}R2Y#^--DUSxe&kyzfsyYD4r0QwTY*z&LDy?^5~Y=&Ok=lu0VA`FEJ3$j0iyK#^* zL}F8GMle3bOnN}ze zs?*-BW)*4|=;0-u^~!)&zlLc?SUD&RUGF&T;QjrbZ&C7=H-5vL^_4;V?P4<&OgI^l zVG;fc*6RyoWATfhX7kqH5-6y#LUdB+DGM`Wn{i|gwR~u_w;E^UN1S6k!HyMf=&r(~ zkk;Q7Ja&%suHxFq4+fWVd(HX*E+gkNKC&jam4l=QL{o7N;6g#D5lzWMM;h^P`^h#bOxj8Xe^2uRWpIZBr$YX7Ln7l#CBHT4C z0y2<$!LQ*x?Et>u=caz(4!#I$f{3Z=u>P63o;}-?;|?-WeEEDq#*Zx~AMhfG_u4|t z@~i2dl~vg@K;yZCl$OQMydQR%?*~=)9MubZV?Si^ikI$|7vBB?o%WHMEbr#+PK5+F zoIptKc|z?6Be3vicU5nnhI?(&>A8bM32NrMIu|Zv9vt16ti@^={`6=UP7_ZQdQehV zg%*Lvf1i26Po2whLC0xonP@;8+T()b?}(VbqxNhkODs>}idr%vdfX9sW^=V_wiV(| z1@zMXS3q-p5do@qRXqV6G~eo*6;vT)d$^JV36wct!W`ss%Z=LL5MQ+A6zjRuVXM;z4v4T2BI{`6I?XBLL`GYJ7gC4t^_zP5zwC7PH=lTTW z#=3M+Hj!r?{$QXqcEDq#t@xh1dJEEtAy;%%YEB^@+pFp9oh6Jx2pEFGAfD02M{kT=dvcO-%C z4P>)ew;Oh%ct<{vsg3`Sv_|8oa|7Svz3&yy8P^cz(tcLXvlkbZlZ;u^o>VOFhjvn^ zQyLnNY48q;Ap&l@!bbd2q~CA_!P86uzjnYkLB&YQQ6*1@O(4;YW=SlJm{UO{NoFch zsvHo_Cevy=k4-8$zSPlq>e~_3l0`Ss|EBJK)V)8T)EZRjEJnHV0P2I%ViZ|?ex$#m zd?2807oVkYjBBk8vK*h!oqG#ul={suyY12xw;@S5%hn)YSKE%{bsdm3q5PU*Z1Ejr z5Th^(9e_zIp_t&7sL2lo%*WL42)w3E7``L;8l`n^^W0IzsKT0kiUBR$*fKWT5~Une zMG`We{_(H_>arG+pL!9kCN(+AI9EILvKhgPODxuSo@`0~s|QT$Kk$Jg%z%pZ4{QA1 zxWr63J^x2`ITQJ}fw^qk@q2@#PM{Oe6=nhl_H*Dm=^_9qhjVg8#Cc*?=S{Z~M0&rx z!jEj9O~=n%YMt~)7omR$&T1Paqv8xkbLPih!S-WQzU%L%yCh7W3C#_cz=BvlMga41 z21;>$^Qq``g(gQ-}hQJEhNJhiDZ)k?1QqcFeRvjYUJ zF8{EfO>ZB6kWvQPJ;b?|&X|i^SDXT~i^2NWbAtodq%^Y^; zHJ+*|5Zv;ocd<#q1ye{4-7HnluUdV`hgDoOfTEUvPmk|YZu@##P)Wf3qxehUz;}B` z*!weU`n~O`D(A1SB@Ps_-KD*KIs(f5K}u$cudbo&ps8?!$K8>S%#O;-47kTD!PEc~ zNtOkgridqq+)9H?HfMzd@>G2}!>lWwED|$`GY5A&Jyol0;8O_L>=omC= zVX9BZ=$t=p<_#rN{?5OcI(tlA$=kZ-{Ogl72Rkdm@UiK>iuJ;0uZtWkBV|q{S01a( z^}auZPA#u?mJPaVDf{n)^rD?5R}dezAh@D`4JrMt3&+n?zL=_A$N?Qe zSGvFcaEzUg8@wWS)QQWg_JtOrr1eqP^J67Ta4!ZkAl{UjZqoOb)}Y=Iq;nPNfdKCr zSo`^TmrlvH#>#Jalt^sq+O@$_zvPUpuz?VP&i3%QFTB~W12?fcNf^&s&;5{zz8$pW zh@=DlTEWxjKZBYZA9nj$LabD8>_C{D;_>;zV}kRx!Z`$0@z{~I)ly6My_dG(*C&^I z!zo%1on`B_*GtJMTqb9jox6Gr{X$zQ=7x?4$9{ft<0Z|}L+2e5H(TlM`v&I41b$DO zEKpf21jRc03Fp43`d)&_Hoo|eV=D(#A17Td)!rXCiImrzANuD$_Tf`pU_bSgM(?F( zzIE^_2bjD0XfV4*M?R@^c&xZadbkIFDkouzhH@`tn5zpaS9PSog^BI7og4!ISmI70$5bmeGqZD zd$01Oki@2!3=3&#(Np3ZU-#hZbqF%b>@}^{Oxav(&J8H`^0P{d9@mLXXGy|8JN$8B zFEqs~t*6ILom+Eyu=H-?gbCz~xAQ-2%HsUJ@@{aXEZb>hvMVXq;V6#1i?M9AJb$*h z2n@Fro*N8sc^AzJi4bM(iq~acm09;#6An$@0N4)4)5L!ao*o{$o4SD-q9-{3^Az!p zpXW${e%w{9Nu`zN&bMqwWgzSYXJbVpmj)3Ki`(pam?T!uAKQn|UBBifi63T-CG1ye zf;=&u4C#hP;Vz;IW|hU);7^=Sx#oHj6KM`RaI)*qI(ch*$_x!+BZBAE$jynCk^!QjQQauKH>7_Ep~hoAzzz4`?FY>yk5MxDR zy*7wu>@l}mk!;`w6YC99pLX_HSn<@nmZ3{cjktZ2c=1#+-UlQ~$6l~>yLQ>T;!C4&6`G$|6*zi^uxmm&mO?L8 zobw66@eoWiIbSTS7zJl5_@q>3X zSEx)g{I`Q3y~!MDNsub`5-#do*3$!?`VDk2SDE%_%SaY0PpGU5qKq^mdK@oc5n88( z_JmR@iQDP;iH$w}8nx0Q_+9&ywoe|#jRqr4GqoD=%U0%{O4^50DOWqz zj;#TEA-C1MKcinLKldnE`L^2HDf)5!xbxHS^5<=NCt*tVFlZxTd5Ah!t15hq61E|c zo(M_3hd|hUoc2>yULA}v99%Tr{iI+}nhv*2BJgySHFaB$Mf-|D*Qvx)+wY-p)uHl+ zWThP@jhC&67|0u!?8Qi&;N;o$*TycJVKC^&>vl?p+5|tIAXoHOpbkdj1d@6i!JBg# zuJJ9~$4K=kMK*f*-lqnuV*Ij)fqTJh>AJD&i`7;nhHV829IyZY4c#+o2hC z+c zet%g3saF?63D{?6PmsOsVp9i=K7(S=L{@>b>Q3UqqgLBg&%v*dYgKZ9&APFIJ;dV#`1l~tEQbd->Y1&m)BN=>80}dcP4*Bit!4Dh1 zR^o3aTeGs_ug;)q`}3RTG7X8LI-T;@l{QJxm(!n;XeTroa)?wq#VLXe zS$Aw7z@sebYlkN0?DppuxzvT9LZ^pCx~ZPmf?-Nly7!$8$5Idi@Q+71NQvDsJ_cUp z!YEj^P`^Ub?3ZyyP${P@hU;~1_BRhNGu0NPokOpl12G=AO`QeSvZco;23SmaA|Ap) zQ$fzmH@B_gWYhhkS--M$oIS$1znnY1=Y&cKbL!CMkAXC0qEm8eoxg+%6*I3hxlJJ? z;p2Maqs{nX#ZMlO%nJ@O@5eE+NIpUWMPF7Tkr^y*1neV8>gSp-IIAo16bRine)>FH z8vdQ<*@&AoMvJ756k@M)3I=sn7VF+sEwv+e14ufiZlbdrXuNCJ^tMs8vd=qhd`ma5 zr_&eFBKe4e)K{(5rR?R1pL@Ar$rHPqzXvxq`2ED)ZUk$!^Dt$joacDQ&FQoFs`HeO z+L@3Bdo0N){hlo1|u2BXp8GETRlFEM8V{zO{_R?V3o-A^!VGv6CmIko?t{&Tu& zy`1-iE!VcWhEd0Y2HSJ?d#5|HD6g3``%;=;`VAy$dHZRBEag!g)}pch7tv=B@d&Hw zhO3`Ck2WbB%O35haBjG7-rQh!#AakI1AI$C1Hl=m={bjApp6RghD|8Wj@y2!om3EuhoE&iTjD5+ahuFpC9tPD;dd%v``Jxv}@8gB6#Fy@vwxE{XWh3_X3HDxKr_jKuS zBPO2_?{=h1Es)L-zvKj4{A&wowuCBhcbiYo=lPW3r*iXGV6SVdNHJkN^N!Lr{hvk~ z=xI&qr6vS8O)(Qx%gRrVkB&1>fcAkiV#&K5d&w;AjU`t;kDXxo(!r=YqjKqN4~OHG zGpE4w!Kwg0*haYimFZl<`GkLEwRod~0_7BWhaFi? zasAvKctb=~Z;WSLJ91&kvj3wcTYUm+(MJs@j6h}QD~X+)2q32hU--IIDoCoZ&%A{= zlnNX<;Ku{xZlI-PztCS2>JNo~xj#>^NAQY(QHt5pnFUo__0UEo zJR8;Bmtm(VwH|1XF<+gEp68secdRa$kKy06Iv=g8pPks|G?9M(nqM!MNQyms46fq( zfY!|tpZ)Ctv{-W+iongU?Z$FlnlAa%9601B2W#VxdQlk$8=}7AO3bc`)PDeQaDsH8 z8>aM5CRU;SVRw{^Jg|U5buu~f-nay?eK+j^DX|toy80L#^@1BJYa!5rr@nGRWOH-e z&uaQz<9vl0-~j_Gh<0=1Q6kru(S?L{%n>c_-;0Z&hwXrQpz`N4V0Fipz8OAR2a=ua zO?(5no>TI7*XhNZX1q&zPR(2!U`-uWWoPhK2oWkl;?d4rfiFd7SZ&|SCh;peyE6+e;c4asJ!x@zw)&>dofEOKrHUh^)4a<9C|BIZo zNe=i@G*L|(zyg4iQ8dGx!Cf>jF`(qX)b0nNQ#83R7ZV9!a({0lDNA9elJJ+4>=v0K z)BNvbX0z-64_bZT{vBYu{)T~Fc*|4#9qcb^3HW*ETsa_9Ke|-M=^${fuQc=bUlp2a LI;v&LuOj~ssoA`B literal 0 HcmV?d00001 diff --git a/rules/check.sh b/rules/check.sh old mode 100755 new mode 100644 diff --git a/src/App.tsx b/src/App.tsx index c338e30..3546c43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,33 @@ import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Toaster } from 'sonner'; import routes from './routes'; import Header from './components/common/Header'; +const AppContent: React.FC = () => { + const location = useLocation(); + const hideHeader = location.pathname === '/login'; + return ( +

+ {!hideHeader &&
} +
+ + {routes.map((route, index) => ( + + ))} + } /> + +
+ +
+ ); +}; + const App: React.FC = () => { return ( -
-
-
- - {routes.map((route, index) => ( - - ))} - } /> - -
- -
+
); }; diff --git a/src/api/javaApi.ts b/src/api/javaApi.ts new file mode 100644 index 0000000..7ae2f02 --- /dev/null +++ b/src/api/javaApi.ts @@ -0,0 +1,529 @@ +import axios from 'axios'; +import type { + Device, + BatteryData, + OtaTask, + MqttLog, + SystemConfig, + DashboardStats, + CreateDeviceRequest, + UpdateDeviceRequest, + CreateOtaTaskRequest, + UpdateConfigRequest, + DeviceDetail, + ChartDataPoint, + ApiResponse, + PaginatedResponse, + ExtremeValues, + VehicleLocation, + VehicleData, + SubsystemVoltage, + SubsystemTemperature +} from "@/types/types"; + +// Java后端API基础配置(开发环境走代理,生产环境用环境变量) +const API_BASE_URL = import.meta.env.DEV ? '/dev-api' : import.meta.env.VITE_JAVA_API_BASE_URL; +if (!API_BASE_URL) { + throw new Error('Missing VITE_JAVA_API_BASE_URL. Please set it in .env to your backend IP, e.g., http://192.168.5.200:8080'); +} + +// 创建axios实例 +const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 请求拦截器 - 添加认证token等 +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem('auth_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// 响应拦截器 - 统一处理响应 +apiClient.interceptors.response.use( + (response) => { + return response.data; + }, + (error) => { + console.error('API Error:', error); + if (error.response?.status === 401) { + // 处理认证失败 + localStorage.removeItem('auth_token'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// 设备管理API +export const javaDeviceApi = { + // 获取所有设备 - 对应后端 /devices/devices/list + async getDevices(): Promise { + const response: any = await apiClient.get('/devices/devices/list'); + // 后端返回的是 TableDataInfo 格式,需要从 rows 中获取数据 + return response.rows || []; + }, + + // 获取设备详情(包含最新电池数据) + async getDeviceDetail(deviceId: string): Promise { + try { + const response: ApiResponse = await apiClient.get(`/devices/devices/${deviceId}/detail`); + return response.data; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + + // 创建设备 + async createDevice(device: CreateDeviceRequest): Promise { + // 将表单的 snake_case 字段映射为后端期望的 camelCase 字段 + const payload = { + deviceId: device.device_id, + deviceName: device.device_name, + deviceType: device.device_type, + deviceSn: device.device_sn, + status: device.status, + ipAddress: device.ip_address, + firmwareVersion: device.firmware_version, + }; + const response: ApiResponse = await apiClient.post('/devices/devices', payload); + return response.data; + }, + + // 更新设备 + async updateDevice(id: string, updates: UpdateDeviceRequest): Promise { + // 将更新请求的 snake_case 字段映射为后端期望的 camelCase 字段,并移除未定义的字段 + const payload: any = { id }; + if (updates.device_name !== undefined) payload.deviceName = updates.device_name; + if (updates.status !== undefined) payload.status = updates.status; + if (updates.device_type !== undefined) payload.deviceType = updates.device_type; + if (updates.device_sn !== undefined) payload.deviceSn = updates.device_sn; + if (updates.ip_address !== undefined) payload.ipAddress = updates.ip_address; + if (updates.firmware_version !== undefined) payload.firmwareVersion = updates.firmware_version; + const response: ApiResponse = await apiClient.put(`/devices/devices`, payload); + return response.data; + }, + + // 删除设备 - 使用数据库主键id + async deleteDevice(id: string): Promise { + await apiClient.delete(`/devices/devices/${id}`); + }, + + // 批量删除设备 - 使用数据库主键id数组 + async batchDeleteDevices(ids: string[]): Promise { + await apiClient.post('/devices/devices/batch-delete', { ids }); + }, + + // 获取设备分页列表 - 使用后端的分页查询 + async getDevicesPaginated(page: number = 1, pageSize: number = 10, status?: string, keyword?: string): Promise> { + const params: any = { + pageNum: page, // 后端使用 pageNum + pageSize: pageSize + }; + if (status && status !== 'all') params.status = status; + if (keyword) params.deviceName = keyword; // 假设后端使用 deviceName 参数搜索 + + const response: any = await apiClient.get('/devices/devices/list', { params }); + + // 转换后端 TableDataInfo 格式到前端 PaginatedResponse 格式 + return { + data: response.rows || [], + total: response.total || 0, + page: page, + pageSize: pageSize + }; + } +}; + +// 电池数据API +export const javaBatteryDataApi = { + // 获取设备的电池数据 + async getBatteryData(deviceId: string, limit = 100): Promise { + const response: ApiResponse = await apiClient.get(`/battery-data/${deviceId}`, { + params: { limit } + }); + return response.data; + }, + + // 获取实时图表数据 + /** + * 获取实时图表数据(兼容两种返回): + * - 直接数组 List + * - 包装对象 { data: ChartDataPoint[] } + */ + async getChartData(deviceId: string, hours = 24): Promise { + const response: any = await apiClient.get(`/battery-data/${deviceId}/chart`, { + params: { hours } + }); + if (Array.isArray(response)) return response; + return response?.data ?? []; + }, + + // 添加电池数据 + async addBatteryData(data: Omit): Promise { + const response: ApiResponse = await apiClient.post('/battery-data', data); + return response.data; + }, + + // 获取设备最新电池数据 + async getLatestBatteryData(deviceId: string): Promise { + try { + const response: any = await apiClient.get(`/battery-data/${deviceId}/latest`); + return response?.data ?? response ?? null; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + } +}; + +/** + * 极值数据 API + * 路由: + * - GET /extreme-values/{deviceId}/latest 最新记录 + * - GET /extreme-values/{deviceId}?limit= 按设备查询 + * - GET /extreme-values/{deviceId}/range?start=&end= 时间范围查询(ISO 字符串) + * - POST /extreme-values 新增记录 + */ +export const javaExtremeValuesApi = { + async getLatestByDevice(deviceId: string, before?: number | string | Date): Promise { + try { + let paramBefore: any = undefined; + if (before !== undefined) { + if (before instanceof Date) paramBefore = before.getTime(); + else if (typeof before === 'number') paramBefore = before; + else paramBefore = before; + } + const response: any = await apiClient.get(`/extreme-values/${deviceId}/latest`, { params: paramBefore !== undefined ? { before: paramBefore } : undefined }); + return response?.data ?? response ?? null; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + /** 按设备查询极值列表,limit 默认 100 */ + async getByDevice(deviceId: string, limit = 100): Promise { + const response: any = await apiClient.get(`/extreme-values/${deviceId}`, { params: { limit } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 按时间范围查询,start/end 为 ISO 字符串 */ + async getRange(deviceId: string, start: string, end: string): Promise { + const response: any = await apiClient.get(`/extreme-values/${deviceId}/range`, { params: { start, end } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 新增一条极值记录 */ + async addRecord(payload: Omit): Promise { + const response: ApiResponse = await apiClient.post('/extreme-values', payload); + return response.data; + } +}; + +/** + * 车辆位置 API + * 路由: + * - GET /vehicle-location/{deviceId}/latest 最新位置 + * - GET /vehicle-location/{deviceId}?limit=&start=&end= 轨迹查询 + * - POST /vehicle-location 新增位置 + */ +export const javaVehicleLocationApi = { + /** 获取设备最新位置;404 返回 null */ + async getLatestByDevice(deviceId: string): Promise { + try { + const response: any = await apiClient.get(`/vehicle-location/${deviceId}/latest`); + return response?.data ?? response ?? null; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + /** 按时间范围或限制数量查询轨迹,start/end 为 ISO 字符串 */ + async getTrack(deviceId: string, start?: string, end?: string, limit = 500): Promise { + const params: any = { limit }; + if (start) params.start = start; + if (end) params.end = end; + const response: any = await apiClient.get(`/vehicle-location/${deviceId}`, { params }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 新增位置记录 */ + async addLocation(payload: Omit): Promise { + const response: ApiResponse = await apiClient.post('/vehicle-location', payload); + return response.data; + } +}; + +/** + * 整车数据 API + * 路由: + * - GET /vehicle-data/{deviceId}/latest 最新整车数据 + * - GET /vehicle-data/{deviceId}?limit= 按设备查询 + * - GET /vehicle-data/{deviceId}/range?start=&end= 时间范围查询 + * - POST /vehicle-data 新增记录 + */ +export const javaVehicleDataApi = { + /** 获取设备最新整车数据;404 返回 null */ + async getLatestByDevice(deviceId: string): Promise { + try { + const response: any = await apiClient.get(`/vehicle-data/${deviceId}/latest`); + return response?.data ?? response ?? null; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + /** 按设备查询整车数据列表,limit 默认 100 */ + async getByDevice(deviceId: string, limit = 100): Promise { + const response: any = await apiClient.get(`/vehicle-data/${deviceId}`, { params: { limit } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 按时间范围查询,start/end 为 ISO 字符串 */ + async getRange(deviceId: string, start: string, end: string): Promise { + const response: any = await apiClient.get(`/vehicle-data/${deviceId}/range`, { params: { start, end } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 新增整车数据记录 */ + async addRecord(payload: Omit): Promise { + const response: ApiResponse = await apiClient.post('/vehicle-data', payload); + return response.data; + } +}; + +/** + * 子系统电压 API + * 路由: + * - GET /subsystem-voltage/{deviceId}/{subsystemNo}/latest 最新帧 + * - GET /subsystem-voltage/{deviceId}/{subsystemNo}?limit= 按子系统查询 + * - GET /subsystem-voltage/{deviceId}/{subsystemNo}/range?start=&end= 时间范围查询 + * - POST /subsystem-voltage 新增帧 + */ +export const javaSubsystemVoltageApi = { + /** 获取指定子系统最新电压帧;404 返回 null */ + async getLatest(deviceId: string, subsystemNo: number): Promise { + try { + const response: any = await apiClient.get(`/subsystem-voltage/${deviceId}/${subsystemNo}/latest`); + return response?.data ?? response ?? null; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + /** 按子系统查询电压帧列表,limit 默认 100 */ + async getBySubsystem(deviceId: string, subsystemNo: number, limit = 100): Promise { + const response: any = await apiClient.get(`/subsystem-voltage/${deviceId}/${subsystemNo}`, { params: { limit } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 按时间范围查询,start/end 为 ISO 字符串 */ + async getRange(deviceId: string, subsystemNo: number, start: string, end: string): Promise { + const response: any = await apiClient.get(`/subsystem-voltage/${deviceId}/${subsystemNo}/range`, { params: { start, end } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 新增电压帧 */ + async addFrame(payload: Omit): Promise { + const response: ApiResponse = await apiClient.post('/subsystem-voltage', payload); + return response.data; + } +}; + +/** + * 子系统温度 API + * 路由: + * - GET /subsystem-temperature/{deviceId}/{subsystemNo}/latest 最新帧 + * - GET /subsystem-temperature/{deviceId}/{subsystemNo}?limit= 按子系统查询 + * - GET /subsystem-temperature/{deviceId}/{subsystemNo}/range?start=&end= 时间范围查询 + * - POST /subsystem-temperature 新增帧 + */ +export const javaSubsystemTemperatureApi = { + /** 获取指定子系统最新温度帧;404 返回 null */ + async getLatest(deviceId: string, subsystemNo: number): Promise { + try { + const response: any = await apiClient.get(`/subsystem-temperature/${deviceId}/${subsystemNo}/latest`); + return response?.data ?? response ?? null; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + /** 按子系统查询温度帧列表,limit 默认 100 */ + async getBySubsystem(deviceId: string, subsystemNo: number, limit = 100): Promise { + const response: any = await apiClient.get(`/subsystem-temperature/${deviceId}/${subsystemNo}`, { params: { limit } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 按时间范围查询,start/end 为 ISO 字符串 */ + async getRange(deviceId: string, subsystemNo: number, start: string, end: string): Promise { + const response: any = await apiClient.get(`/subsystem-temperature/${deviceId}/${subsystemNo}/range`, { params: { start, end } }); + return Array.isArray(response) ? response : (response?.data ?? []); + }, + /** 新增温度帧 */ + async addFrame(payload: Omit): Promise { + const response: ApiResponse = await apiClient.post('/subsystem-temperature', payload); + return response.data; + } +}; + +// OTA任务API +export const javaOtaApi = { + // 列表查询(后端返回 TableDataInfo,需要从 rows 取数组) + async getTasks(params?: Record): Promise { + const response: any = await apiClient.get('/ota/tasks/list', { params }); + return response.rows || []; + }, + + // 获取任务详情 + async getTaskById(id: string): Promise { + const response: any = await apiClient.get(`/ota/tasks/${id}`); + return response.data ?? response; + }, + + // 创建任务 + async createTask(task: CreateOtaTaskRequest): Promise { + // 将表单的 snake_case 字段映射为后端期望的 camelCase 字段 + const payload = { + deviceId: task.device_id, + taskName: task.task_name, + firmwareVersion: task.firmware_version, + }; + const response: any = await apiClient.post('/ota/tasks', payload); + return response; + }, + + // 修改任务 + async updateTask(task: Partial & { id: string }): Promise { + const response: any = await apiClient.put('/ota/tasks', task); + return response; + }, + + // 批量删除 + async deleteTasks(ids: string[]): Promise { + const idPath = ids.join(','); + const response: any = await apiClient.delete(`/ota/tasks/${idPath}`); + return response; + }, + + // 导出任务列表(Excel) + async exportTasks(params?: Record): Promise { + const response = await apiClient.post('/ota/tasks/export', params, { responseType: 'blob' }); + return response as unknown as Blob; + } +}; + +// MQTT日志API +export const javaMqttApi = { + // 获取MQTT日志 + async getLogs(deviceId?: string, limit = 100): Promise { + const params: any = { limit }; + if (deviceId) params.deviceId = deviceId; + + const response: ApiResponse = await apiClient.get('/mqtt/logs', { params }); + return response.data; + }, + + // 添加MQTT日志 + async addLog(log: Omit): Promise { + const response: ApiResponse = await apiClient.post('/mqtt/logs', log); + return response.data; + } +}; + +// 系统配置API +export const javaSystemApi = { + // 获取所有配置 + async getConfigs(): Promise { + const response: ApiResponse = await apiClient.get('/system/configs'); + return response.data; + }, + + // 获取单个配置 + async getConfig(key: string): Promise { + try { + const response: ApiResponse = await apiClient.get(`/system/configs/${key}`); + return response.data; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } + }, + + // 更新配置 + async updateConfig(key: string, updates: UpdateConfigRequest): Promise { + const response: ApiResponse = await apiClient.put(`/system/configs/${key}`, updates); + return response.data; + }, + + // 获取仪表盘统计数据 + async getDashboardStats(): Promise { + const response: ApiResponse = await apiClient.get('/system/dashboard-stats'); + return response.data; + } +}; + +export const javaAuthApi = { + async login(body: { username: string; password: string; code?: string; uuid?: string }): Promise { + const response: any = await apiClient.post('/login', body); + const token = response?.token ?? response?.data?.token ?? response?.data; + if (!token || typeof token !== 'string') throw new Error('Login failed: token missing'); + localStorage.setItem('auth_token', token); + return token; + }, + async getCaptcha(): Promise<{ img: string; uuid: string }> { + const response: any = await apiClient.get('/captchaImage'); + const data = response?.data ?? response; + const img = data?.img || data?.captcha || ''; + const uuid = data?.uuid || data?.codeKey || ''; + if (!img || !uuid) throw new Error('Captcha fetch failed'); + const dataUrl = img.startsWith('data:') ? img : `data:image/jpeg;base64,${img}`; + return { img: dataUrl, uuid }; + }, + async getInfo(): Promise { + const response: any = await apiClient.get('/getInfo'); + return response?.data ?? response; + }, + async getRouters(): Promise { + const response: any = await apiClient.get('/getRouters'); + return response?.data ?? response; + } +}; + +// 导出所有API +// 聚合导出:设备/电池/OTA/MQTT/系统 + 极值/位置/整车/子系统电压/子系统温度 +export const javaApi = { + device: javaDeviceApi, + batteryData: javaBatteryDataApi, + ota: javaOtaApi, + mqtt: javaMqttApi, + system: javaSystemApi, + auth: javaAuthApi, + extremeValues: javaExtremeValuesApi, + vehicleLocation: javaVehicleLocationApi, + vehicleData: javaVehicleDataApi, + subsystemVoltage: javaSubsystemVoltageApi, + subsystemTemperature: javaSubsystemTemperatureApi +}; + +export default javaApi; \ No newline at end of file diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx index a7066e6..c4a4a0e 100644 --- a/src/components/ui/map.tsx +++ b/src/components/ui/map.tsx @@ -99,19 +99,21 @@ let BMapGLLoadingPromise: Promise | null = null; * @param {ReactNode} children - Child components, usually MapTitle */ const Map = ({ -ak, -option, -className, -children, -...props + ak, + option, + path, + className, + children, + ...props }: React.ComponentProps<"div"> & { -ak: string; -option?: { + ak: string; + option?: { zoom: number; lng: number; lat: number; address: string; -}; + }; + path?: Array<{ lng: number; lat: number }>; }) => { const mapRef = useRef(null); const currentRef = useRef(null); @@ -141,18 +143,27 @@ const initMap = useCallback(() => { // Clear overlays map.clearOverlays(); - // Set map center coordinates and map level - const center = new (window as any).BMapGL.Point( - _options?.lng, - _options?.lat - ); + const centerPoint = (() => { + if (path && path.length > 0) { + const last = path[path.length - 1]; + return new (window as any).BMapGL.Point(last.lng, last.lat); + } + return new (window as any).BMapGL.Point(_options?.lng, _options?.lat); + })(); - map.centerAndZoom(center, _options?.zoom); + map.centerAndZoom(centerPoint, _options?.zoom); - // Add marker - const marker = new (window as any).BMapGL.Marker(center); + if (path && path.length > 0) { + const points = path.map(p => new (window as any).BMapGL.Point(p.lng, p.lat)); + const polyline = new (window as any).BMapGL.Polyline(points, { strokeColor: "#0ea5e9", strokeWeight: 4, strokeOpacity: 0.8 }); + map.addOverlay(polyline); + const marker = new (window as any).BMapGL.Marker(points[points.length - 1]); map.addOverlay(marker); -}, [_options]); + } else { + const marker = new (window as any).BMapGL.Marker(centerPoint); + map.addOverlay(marker); + } +}, [_options, path]); useEffect(() => { // Check if Baidu Map API is loaded diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index db73257..f2856be 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -2,13 +2,12 @@ import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; -import { - Battery, - Zap, - Thermometer, - Activity, - Wifi, - WifiOff, +import { + Battery, + Zap, + Activity, + Wifi, + WifiOff, AlertTriangle, CheckCircle, Clock, @@ -16,15 +15,18 @@ import { Download, Server } from 'lucide-react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'; -import { dashboardApi, deviceApi, batteryDataApi, otaApi } from '@/db/api'; -import type { DashboardStats, Device, BatteryData, OtaTask } from '@/types/types'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { javaDeviceApi, javaBatteryDataApi, javaOtaApi, javaVehicleLocationApi } from '@/api/javaApi'; +import { Map } from '@/components/ui/map'; +import type { DashboardStats, Device, ChartDataPoint, OtaTask } from '@/types/types'; const Dashboard: React.FC = () => { const [stats, setStats] = useState(null); const [devices, setDevices] = useState([]); - const [recentBatteryData, setRecentBatteryData] = useState([]); + // 首页图表数据源:Java 图表接口返回的 24 小时数据(timestamp, voltage, current) + const [chartData, setChartData] = useState([]); const [recentTasks, setRecentTasks] = useState([]); + const [trackPoints, setTrackPoints] = useState<{ lng: number; lat: number }[]>([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -34,20 +36,40 @@ const Dashboard: React.FC = () => { const loadDashboardData = async () => { try { setLoading(true); - const [statsData, devicesData, tasksData] = await Promise.all([ - dashboardApi.getStats(), - deviceApi.getDevices(), - otaApi.getTasks() + const [devicesData, tasksData] = await Promise.all([ + javaDeviceApi.getDevices(), + javaOtaApi.getTasks() ]); - setStats(statsData); + const computedStats: DashboardStats = { + totalDevices: devicesData.length, + onlineDevices: devicesData.filter(d => d.status === 'online').length, + offlineDevices: devicesData.filter(d => d.status === 'offline').length, + maintenanceDevices: devicesData.filter(d => d.status === 'maintenance').length, + errorDevices: devicesData.filter(d => d.status === 'error').length, + activeTasks: tasksData.filter(t => t.status !== 'completed' && t.status !== 'failed').length, + completedTasks: tasksData.filter(t => t.status === 'completed').length, + failedTasks: tasksData.filter(t => t.status === 'failed').length + }; + setStats(computedStats); setDevices(devicesData); setRecentTasks(tasksData.slice(0, 5)); - // 获取最新的电池数据用于图表展示 + // 获取图表数据(24小时窗口) if (devicesData.length > 0) { - const batteryData = await batteryDataApi.getBatteryData(devicesData[0].device_id, 10); - setRecentBatteryData(batteryData.reverse()); + const data = await javaBatteryDataApi.getChartData(devicesData[0].deviceId, 24); + setChartData(Array.isArray(data) ? data : []); + + const end = Date.now(); + const start = end - 6 * 3600_000; + const locs = await javaVehicleLocationApi.getTrack(devicesData[0].deviceId, start.toString(), end.toString(), 500); + const pts = (Array.isArray(locs) ? locs : []) + .filter(l => l.positioningStatus === 1 && typeof l.longitude === 'number' && typeof l.latitude === 'number') + .map(l => ({ lng: Number(l.longitude), lat: Number(l.latitude) })); + setTrackPoints(pts); + } else { + setChartData([]); + setTrackPoints([]); } } catch (error) { console.error('Failed to load dashboard data:', error); @@ -78,7 +100,7 @@ const Dashboard: React.FC = () => { maintenance: "outline", error: "destructive" }; - + const labels: Record = { online: "在线", offline: "离线", @@ -194,14 +216,14 @@ const Dashboard: React.FC = () => {
{getStatusIcon(device.status)}
-
{device.device_name}
-
{device.device_id}
+
{device.deviceName}
+
{device.deviceId}
{getStatusBadge(device.status)}
- {device.firmware_version} + {device.firmwareVersion}
@@ -225,8 +247,8 @@ const Dashboard: React.FC = () => {
{getTaskStatusIcon(task.status)}
-
{task.task_name}
-
{task.device_id}
+
{task.taskName}
+
{task.deviceId}
@@ -240,76 +262,111 @@ const Dashboard: React.FC = () => {
+
+ + + + + 位置轨迹(最近6小时) + + + + {trackPoints.length > 1 ? ( + + ) : ( +
暂无轨迹数据
+ )} +
+
+
+ {/* 电池数据图表 */} - {recentBatteryData.length > 0 && ( -
- - - - - 电压趋势 - - - +
+ + + + + 电压趋势 + + + + {/* 电压趋势图:单位 V,数据来源 chartData */} + {chartData.length > 0 ? ( - + - new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} /> - new Date(value).toLocaleString('zh-CN')} formatter={(value: number) => [`${value}V`, '电压']} /> - - - + ) : ( +
+ 暂无数据 +
+ )} + + - - - - - 温度趋势 - - - + + + + + 电流趋势 + + + + {/* 电流趋势图:单位 A,数据来源 chartData */} + {chartData.length > 0 ? ( - + - new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} /> - new Date(value).toLocaleString('zh-CN')} - formatter={(value: number) => [`${value}°C`, '温度']} + formatter={(value: number) => [`${value}A`, '电流']} /> - - - -
- )} + ) : ( +
+ 暂无数据 +
+ )} +
+
+
); }; -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/src/pages/DeviceManagement.tsx b/src/pages/DeviceManagement.tsx index ced2ef2..145905c 100644 --- a/src/pages/DeviceManagement.tsx +++ b/src/pages/DeviceManagement.tsx @@ -23,7 +23,7 @@ import { } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { deviceApi, batteryDataApi } from '@/db/api'; +import { javaDeviceApi, javaBatteryDataApi } from '@/api/javaApi'; import type { Device, DeviceStatus, CreateDeviceRequest, UpdateDeviceRequest, BatteryData } from '@/types/types'; const DeviceManagement: React.FC = () => { @@ -52,7 +52,8 @@ const DeviceManagement: React.FC = () => { const loadDevices = async () => { try { setLoading(true); - const data = await deviceApi.getDevices(); + //加载设备列表 + const data = await javaDeviceApi.getDevices(); setDevices(data); } catch (error) { console.error('Failed to load devices:', error); @@ -67,8 +68,8 @@ const DeviceManagement: React.FC = () => { if (searchTerm) { filtered = filtered.filter(device => - device.device_name.toLowerCase().includes(searchTerm.toLowerCase()) || - device.device_id.toLowerCase().includes(searchTerm.toLowerCase()) + device.deviceName.toLowerCase().includes(searchTerm.toLowerCase()) || + device.deviceId.toLowerCase().includes(searchTerm.toLowerCase()) ); } @@ -81,7 +82,8 @@ const DeviceManagement: React.FC = () => { const handleCreateDevice = async (data: CreateDeviceRequest) => { try { - await deviceApi.createDevice(data); + //获取设备新增 + await javaDeviceApi.createDevice(data); toast.success('设备创建成功'); setIsCreateDialogOpen(false); createForm.reset(); @@ -96,7 +98,8 @@ const DeviceManagement: React.FC = () => { if (!selectedDevice) return; try { - await deviceApi.updateDevice(selectedDevice.device_id, data); + //设备更新 + await javaDeviceApi.updateDevice(selectedDevice.id, data); toast.success('设备更新成功'); setIsEditDialogOpen(false); editForm.reset(); @@ -108,11 +111,12 @@ const DeviceManagement: React.FC = () => { } }; - const handleDeleteDevice = async (deviceId: string) => { + const handleDeleteDevice = async (id: string) => { if (!confirm('确定要删除这个设备吗?')) return; try { - await deviceApi.deleteDevice(deviceId); + //删除设备 + await javaDeviceApi.deleteDevice(id); toast.success('设备删除成功'); loadDevices(); } catch (error) { @@ -124,7 +128,7 @@ const DeviceManagement: React.FC = () => { const handleViewDetails = async (device: Device) => { setSelectedDevice(device); try { - const batteryData = await batteryDataApi.getBatteryData(device.device_id, 1); + const batteryData = await javaBatteryDataApi.getBatteryData(device.deviceId, 1); setDeviceBatteryData(batteryData[0] || null); } catch (error) { console.error('Failed to load battery data:', error); @@ -221,6 +225,32 @@ const DeviceManagement: React.FC = () => { )} /> + ( + + 设备SN码 + + + + + + )} + /> + ( + + 设备类型 + + + + + + )} + /> { )} /> + ( + + 设备状态 + + + + )} + /> {
- {device.device_name} + {device.deviceName} {getStatusIcon(device.status)}
-
{device.device_id}
+
{device.deviceId}
@@ -309,28 +362,34 @@ const DeviceManagement: React.FC = () => { {getStatusBadge(device.status)}
- {device.ip_address && ( -
- IP地址 - {device.ip_address} -
- )} - - {device.firmware_version && ( -
- 固件版本 - {device.firmware_version} -
- )} - - {device.last_online && ( -
- 最后在线 - - {new Date(device.last_online).toLocaleString('zh-CN')} - -
- )} + {device.ipAddress && ( +
+ IP地址 + {device.ipAddress} +
+ )} + {device.deviceSn && ( +
+ 设备SN码 + {device.deviceSn} +
+ )} + + {device.firmwareVersion && ( +
+ 固件版本 + {device.firmwareVersion} +
+ )} + + {device.lastOnline && ( +
+ 最后在线 + + {new Date(device.lastOnline).toLocaleString('zh-CN')} + +
+ )}
+ +
+
+
+
+
+ {showTextBrand ? ( + EVO + ) : ( + logo { + if (logoIndex < candidates.length - 1) { + const next = logoIndex + 1; + setLogoIndex(next); + setLogoSrc(candidates[next]); + } else { + setShowTextBrand(true); + } + }} + /> + )} +
+

伊特电池管理系统

+

统一设备、数据与轨迹的可视化管理平台

+ +
+
+
+ + + ); +}; + +export default Login; \ No newline at end of file diff --git a/src/pages/MqttManagement.tsx b/src/pages/MqttManagement.tsx index d0b3fc2..3707645 100644 --- a/src/pages/MqttManagement.tsx +++ b/src/pages/MqttManagement.tsx @@ -20,7 +20,7 @@ import { Download } from 'lucide-react'; import { toast } from 'sonner'; -import { mqttLogApi, deviceApi, configApi } from '@/db/api'; +import { javaMqttApi, javaDeviceApi, javaSystemApi } from '@/api/javaApi'; import type { MqttLog, Device, SystemConfig } from '@/types/types'; const MqttManagement: React.FC = () => { @@ -45,14 +45,15 @@ const MqttManagement: React.FC = () => { const loadData = async () => { try { setLoading(true); - const [logsData, devicesData, configData] = await Promise.all([ - mqttLogApi.getLogs(200), - deviceApi.getDevices(), - configApi.getConfig('mqtt_server') + const [logsData, devicesData, configs] = await Promise.all([ + javaMqttApi.getLogs(undefined, 200), + javaDeviceApi.getDevices(), + javaSystemApi.getConfigs() ]); setLogs(logsData); setDevices(devicesData); - setMqttConfig(configData); + const cfg = configs.find(c => c.config_key === 'mqtt_server') || null; + setMqttConfig(cfg); } catch (error) { console.error('Failed to load data:', error); toast.error('加载数据失败'); @@ -122,8 +123,8 @@ const MqttManagement: React.FC = () => { }; const getDeviceName = (deviceId: string) => { - const device = devices.find(d => d.device_id === deviceId); - return device ? device.device_name : deviceId; + const device = devices.find(d => d.deviceId === deviceId); + return device ? device.deviceName : deviceId; }; const exportLogs = () => { @@ -285,8 +286,8 @@ const MqttManagement: React.FC = () => { 全部设备 {devices.map((device) => ( - - {device.device_name} + + {device.deviceName} ))} diff --git a/src/pages/OtaManagement.tsx b/src/pages/OtaManagement.tsx index e491236..9134b0e 100644 --- a/src/pages/OtaManagement.tsx +++ b/src/pages/OtaManagement.tsx @@ -24,7 +24,7 @@ import { } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { otaApi, deviceApi } from '@/db/api'; +import { javaOtaApi, javaDeviceApi } from '@/api/javaApi'; import type { OtaTask, Device, CreateOtaTaskRequest, OtaStatus } from '@/types/types'; const OtaManagement: React.FC = () => { @@ -52,27 +52,30 @@ const OtaManagement: React.FC = () => { try { setLoading(true); const [tasksData, devicesData] = await Promise.all([ - otaApi.getTasks(), - deviceApi.getDevices() + javaOtaApi.getTasks(), + javaDeviceApi.getDevices() ]); - setTasks(tasksData); - setDevices(devicesData); + setTasks(tasksData || []); // 确保数据不为undefined + setDevices(devicesData || []); // 确保数据不为undefined } catch (error) { console.error('Failed to load data:', error); toast.error('加载数据失败'); + // 设置默认空数组,防止undefined错误 + setTasks([]); + setDevices([]); } finally { setLoading(false); } }; const filterTasks = () => { - let filtered = tasks; + let filtered = tasks || []; // 确保tasks不为undefined if (searchTerm) { filtered = filtered.filter(task => - task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) || - task.device_id.toLowerCase().includes(searchTerm.toLowerCase()) || - task.firmware_version.toLowerCase().includes(searchTerm.toLowerCase()) + task.taskName.toLowerCase().includes(searchTerm.toLowerCase()) || + task.deviceId.toLowerCase().includes(searchTerm.toLowerCase()) || + task.firmwareVersion.toLowerCase().includes(searchTerm.toLowerCase()) ); } @@ -85,7 +88,7 @@ const OtaManagement: React.FC = () => { const handleCreateTask = async (data: CreateOtaTaskRequest) => { try { - await otaApi.createTask(data); + await javaOtaApi.createTask(data); toast.success('OTA任务创建成功'); setIsCreateDialogOpen(false); createForm.reset(); @@ -98,7 +101,7 @@ const OtaManagement: React.FC = () => { const handleUpdateTaskStatus = async (taskId: string, status: OtaStatus, progress?: number) => { try { - await otaApi.updateTaskStatus(taskId, status, progress); + await javaOtaApi.updateTask({ id: taskId, status, progress }); toast.success('任务状态更新成功'); loadData(); } catch (error) { @@ -227,8 +230,8 @@ const OtaManagement: React.FC = () => { {devices.map((device) => ( - - {device.device_name} ({device.device_id}) + + {device.deviceName} ({device.id}) ))} @@ -320,9 +323,9 @@ const OtaManagement: React.FC = () => {
{getStatusIcon(task.status)}
-

{task.task_name}

+

{task.taskName}

- 设备: {task.device_id} | 目标版本: {task.firmware_version} + 设备: {task.deviceId} | 目标版本: {task.firmwareVersion}

@@ -347,42 +350,42 @@ const OtaManagement: React.FC = () => {
创建时间:
- {new Date(task.created_at).toLocaleString('zh-CN')} + {new Date(task.createTime).toLocaleString('zh-CN')}
- {task.start_time && ( + {task.startTime && (
开始时间:
- {new Date(task.start_time).toLocaleString('zh-CN')} + {new Date(task.startTime).toLocaleString('zh-CN')}
)} - {task.end_time && ( + {task.endTime && (
结束时间:
- {new Date(task.end_time).toLocaleString('zh-CN')} + {new Date(task.endTime).toLocaleString('zh-CN')}
)} - {task.end_time && task.start_time && ( + {task.endTime && task.startTime && (
耗时:
- {Math.round((new Date(task.end_time).getTime() - new Date(task.start_time).getTime()) / 60000)}分钟 + {Math.round((new Date(task.endTime).getTime() - new Date(task.startTime).getTime()) / 60000)}分钟
)} - {task.error_message && ( + {task.errorMessage && (
错误信息:
-

{task.error_message}

+

{task.errorMessage}

)} @@ -466,15 +469,15 @@ const OtaManagement: React.FC = () => {
任务名称: -
{selectedTask.task_name}
+
{selectedTask.taskName}
设备ID: -
{selectedTask.device_id}
+
{selectedTask.deviceId}
固件版本: -
{selectedTask.firmware_version}
+
{selectedTask.firmwareVersion}
当前状态: @@ -485,9 +488,9 @@ const OtaManagement: React.FC = () => {
-
[{new Date(selectedTask.created_at).toLocaleString()}] 任务创建成功
- {selectedTask.start_time && ( -
[{new Date(selectedTask.start_time).toLocaleString()}] 开始下载固件包...
+
[{new Date(selectedTask.createTime).toLocaleString()}] 任务创建成功
+ {selectedTask.startTime && ( +
[{new Date(selectedTask.startTime).toLocaleString()}] 开始下载固件包...
)} {selectedTask.status === 'downloading' && ( <> @@ -504,15 +507,15 @@ const OtaManagement: React.FC = () => { )} {selectedTask.status === 'completed' && ( <> -
[{new Date(selectedTask.end_time!).toLocaleString()}] 固件安装完成
-
[{new Date(selectedTask.end_time!).toLocaleString()}] 设备重启中...
-
[{new Date(selectedTask.end_time!).toLocaleString()}] 升级任务完成
+
[{new Date(selectedTask.endTime!).toLocaleString()}] 固件安装完成
+
[{new Date(selectedTask.endTime!).toLocaleString()}] 设备重启中...
+
[{new Date(selectedTask.endTime!).toLocaleString()}] 升级任务完成
)} - {selectedTask.status === 'failed' && selectedTask.error_message && ( + {selectedTask.status === 'failed' && selectedTask.errorMessage && ( <> -
[{new Date(selectedTask.end_time!).toLocaleString()}] 错误: {selectedTask.error_message}
-
[{new Date(selectedTask.end_time!).toLocaleString()}] 升级任务失败
+
[{new Date(selectedTask.endTime!).toLocaleString()}] 错误: {selectedTask.errorMessage}
+
[{new Date(selectedTask.endTime!).toLocaleString()}] 升级任务失败
)}
diff --git a/src/pages/RealTimeMonitoring.tsx b/src/pages/RealTimeMonitoring.tsx index 66feaaf..69898e8 100644 --- a/src/pages/RealTimeMonitoring.tsx +++ b/src/pages/RealTimeMonitoring.tsx @@ -15,9 +15,9 @@ import { Play, Pause } from 'lucide-react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'; -import { deviceApi, batteryDataApi } from '@/db/api'; -import type { Device, BatteryData, ChartDataPoint } from '@/types/types'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area, BarChart, Bar } from 'recharts'; +import { javaDeviceApi, javaBatteryDataApi, javaVehicleDataApi, javaVehicleLocationApi, javaExtremeValuesApi, javaSubsystemVoltageApi, javaSubsystemTemperatureApi } from '@/api/javaApi'; +import type { Device, BatteryData, ChartDataPoint, VehicleData, VehicleLocation, ExtremeValues, SubsystemVoltage, SubsystemTemperature } from '@/types/types'; const RealTimeMonitoring: React.FC = () => { const [devices, setDevices] = useState([]); @@ -25,6 +25,13 @@ const RealTimeMonitoring: React.FC = () => { const [selectedDevice, setSelectedDevice] = useState(null); const [chartData, setChartData] = useState([]); const [latestData, setLatestData] = useState(null); + const [vehicleLatest, setVehicleLatest] = useState(null); + const [locationLatest, setLocationLatest] = useState(null); + const [extremeLatest, setExtremeLatest] = useState(null); + const [subsystemNoVolt, setSubsystemNoVolt] = useState(1); + const [subsystemNoTemp, setSubsystemNoTemp] = useState(1); + const [subsystemVoltLatest, setSubsystemVoltLatest] = useState(null); + const [subsystemTempLatest, setSubsystemTempLatest] = useState(null); const [isRealTime, setIsRealTime] = useState(false); const [timeRange, setTimeRange] = useState(24); const [loading, setLoading] = useState(true); @@ -41,7 +48,7 @@ const RealTimeMonitoring: React.FC = () => { }, [selectedDeviceId, timeRange]); useEffect(() => { - let interval: NodeJS.Timeout; + let interval: ReturnType; if (isRealTime && selectedDeviceId) { interval = setInterval(() => { loadDeviceData(); @@ -53,15 +60,21 @@ const RealTimeMonitoring: React.FC = () => { }; }, [isRealTime, selectedDeviceId]); + useEffect(() => { + if (selectedDeviceId) { + loadSubsystemLatest(); + } + }, [selectedDeviceId, subsystemNoVolt, subsystemNoTemp]); + const loadDevices = async () => { try { setLoading(true); - const data = await deviceApi.getDevices(); + const data = await javaDeviceApi.getDevices(); setDevices(data.filter(d => d.status === 'online')); // 只显示在线设备 if (data.length > 0) { const onlineDevice = data.find(d => d.status === 'online'); if (onlineDevice) { - setSelectedDeviceId(onlineDevice.device_id); + setSelectedDeviceId(onlineDevice.deviceId); setSelectedDevice(onlineDevice); } } @@ -76,29 +89,65 @@ const RealTimeMonitoring: React.FC = () => { if (!selectedDeviceId) return; try { - const batteryData = await batteryDataApi.getBatteryData(selectedDeviceId, 1); - if (batteryData.length > 0) { - setLatestData(batteryData[0]); - } + // 整车最新 + const vLatest = await javaVehicleDataApi.getLatestByDevice(selectedDeviceId); + setVehicleLatest(vLatest); + + // 位置最新 + const lLatest = await javaVehicleLocationApi.getLatestByDevice(selectedDeviceId); + setLocationLatest(lLatest); + + // 极值最新 + const eLatest = await javaExtremeValuesApi.getLatestByDevice(selectedDeviceId); + setExtremeLatest(eLatest); } catch (error) { console.error('Failed to load device data:', error); } }; + const loadSubsystemLatest = async () => { + if (!selectedDeviceId) return; + try { + const sv = await javaSubsystemVoltageApi.getLatest(selectedDeviceId, subsystemNoVolt); + setSubsystemVoltLatest(sv); + const st = await javaSubsystemTemperatureApi.getLatest(selectedDeviceId, subsystemNoTemp); + setSubsystemTempLatest(st); + } catch (error) { + console.error('Failed to load subsystem latest:', error); + } + }; + const loadChartData = async () => { if (!selectedDeviceId) return; try { - const data = await batteryDataApi.getChartData(selectedDeviceId, timeRange); + const data = await javaBatteryDataApi.getChartData(selectedDeviceId, timeRange); setChartData(data); + if (Array.isArray(data) && data.length > 0) { + const p = data[data.length - 1]; + setLatestData({ + id: '', + device_id: selectedDeviceId, + voltage: p.voltage, + current: p.current, + temperature: p.temperature, + soc: p.soc, + soh: undefined, + power: undefined, + timestamp: p.timestamp + }); + } else { + setLatestData(null); + } } catch (error) { console.error('Failed to load chart data:', error); } }; + // 设备选择:使用 deviceId 作为值 const handleDeviceChange = (deviceId: string) => { setSelectedDeviceId(deviceId); - const device = devices.find(d => d.device_id === deviceId); + const device = devices.find(d => d.deviceId === deviceId); setSelectedDevice(device || null); }; @@ -184,8 +233,8 @@ const RealTimeMonitoring: React.FC = () => { {devices.map((device) => ( - - {device.device_name} ({device.device_id}) + + {device.deviceName} ({device.deviceId}) ))} @@ -206,11 +255,37 @@ const RealTimeMonitoring: React.FC = () => {
+
+ + +
+
+ + +
- {selectedDevice && latestData && ( + {selectedDevice && ( <> {/* 实时数据卡片 */}
@@ -223,9 +298,9 @@ const RealTimeMonitoring: React.FC = () => {
-
{latestData.voltage?.toFixed(2) || '--'}V
+
{latestData?.voltage?.toFixed(2) || '--'}V

- {new Date(latestData.timestamp).toLocaleTimeString('zh-CN')} + {latestData?.timestamp ? new Date(latestData.timestamp).toLocaleTimeString('zh-CN') : '--'}

@@ -239,9 +314,9 @@ const RealTimeMonitoring: React.FC = () => {
-
{latestData.current?.toFixed(2) || '--'}A
+
{latestData?.current?.toFixed(2) || '--'}A

- 功率: {latestData.power?.toFixed(2) || '--'}W + 功率: {latestData?.power?.toFixed(2) || '--'}W

@@ -255,9 +330,9 @@ const RealTimeMonitoring: React.FC = () => { -
{latestData.temperature?.toFixed(1) || '--'}°C
+
{latestData?.temperature?.toFixed(1) || '--'}°C

- {latestData.temperature && latestData.temperature > 50 ? '高温警告' : '温度正常'} + {latestData?.temperature && latestData?.temperature > 50 ? '高温警告' : '温度正常'}

@@ -271,9 +346,9 @@ const RealTimeMonitoring: React.FC = () => { -
{latestData.soc?.toFixed(1) || '--'}%
+
{latestData?.soc?.toFixed(1) || '--'}%

- {latestData.soc && latestData.soc < 20 ? '电量不足' : '电量充足'} + {latestData?.soc && latestData?.soc < 20 ? '电量不足' : '电量充足'}

@@ -284,12 +359,52 @@ const RealTimeMonitoring: React.FC = () => { -
{latestData.soh?.toFixed(1) || '--'}%
+
{latestData?.soh?.toFixed(1) || '--'}%

- {latestData.soh && latestData.soh > 95 ? '状态良好' : '需要关注'} + {latestData?.soh && latestData?.soh > 95 ? '状态良好' : '需要关注'}

+ {vehicleLatest && ( + + + 整车 + + + +
总电压: {vehicleLatest.totalVoltage ?? '--'} V
+
总电流: {vehicleLatest.totalCurrent ?? '--'} A
+
SOC: {vehicleLatest.soc ?? '--'} %
+
速度: {vehicleLatest.vehicleSpeed ?? '--'} km/h | 里程: {vehicleLatest.totalMileage ?? '--'} km
+
+
+ )} + {extremeLatest && ( + + + 极值 + + + +
最高电压: {extremeLatest.maxVoltageValue ?? '--'} V (子系统 {extremeLatest.maxVoltageSubsystemNo ?? '--'} / 单体 {extremeLatest.maxVoltageBatteryNo ?? '--'})
+
最低电压: {extremeLatest.minVoltageValue ?? '--'} V (子系统 {extremeLatest.minVoltageSubsystemNo ?? '--'} / 单体 {extremeLatest.minVoltageBatteryNo ?? '--'})
+
最高温度: {extremeLatest.maxTempValue ?? '--'} ℃ (子系统 {extremeLatest.maxTempSubsystemNo ?? '--'} / 探针 {extremeLatest.maxTempProbeNo ?? '--'})
+
最低温度: {extremeLatest.minTempValue ?? '--'} ℃ (子系统 {extremeLatest.minTempSubsystemNo ?? '--'} / 探针 {extremeLatest.minTempProbeNo ?? '--'})
+
+
+ )} + {locationLatest && ( + + + 位置 + + + +
经度: {locationLatest.longitude ?? '--'} | 纬度: {locationLatest.latitude ?? '--'}
+
定位: {locationLatest.positioningStatus === 1 ? '有效' : '无效'}
+
+
+ )} {/* 图表区域 */} @@ -439,6 +554,49 @@ const RealTimeMonitoring: React.FC = () => { + {/* 子系统电压/温度最新帧展示 */} +
+ + + + + 子系统电压帧 + + + + + ({ index: i + 1, value: v }))}> + + + + [`${Number(value).toFixed(3)}V`, '电压']} /> + + + + + + + + + + + 子系统温度帧 + + + + + ({ index: i + 1, value: v }))}> + + + + [`${Number(value).toFixed(2)}℃`, '温度']} /> + + + + + +
+ {/* 设备信息 */} @@ -448,19 +606,19 @@ const RealTimeMonitoring: React.FC = () => {
设备名称
-
{selectedDevice.device_name}
+
{selectedDevice.deviceName}
设备ID
-
{selectedDevice.device_id}
+
{selectedDevice.id}
IP地址
-
{selectedDevice.ip_address || '未设置'}
+
{selectedDevice.ipAddress || '未设置'}
固件版本
-
{selectedDevice.firmware_version || '未知'}
+
{selectedDevice.firmwareVersion || '未知'}
diff --git a/src/pages/SystemSettings.tsx b/src/pages/SystemSettings.tsx index 6ddf45d..cc82897 100644 --- a/src/pages/SystemSettings.tsx +++ b/src/pages/SystemSettings.tsx @@ -22,7 +22,7 @@ import { } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { configApi } from '@/db/api'; +import { javaSystemApi } from '@/api/javaApi'; import type { SystemConfig } from '@/types/types'; interface ConfigForm { @@ -55,7 +55,7 @@ const SystemSettings: React.FC = () => { const loadConfigs = async () => { try { setLoading(true); - const data = await configApi.getConfigs(); + const data = await javaSystemApi.getConfigs(); setConfigs(data); // 填充表单 @@ -79,7 +79,7 @@ const SystemSettings: React.FC = () => { // 更新所有配置 const updatePromises = Object.entries(data).map(([key, value]) => - configApi.updateConfig(key, { config_value: value }) + javaSystemApi.updateConfig(key, { config_value: value }) ); await Promise.all(updatePromises); diff --git a/src/routes.tsx b/src/routes.tsx index cf045c0..e82a9a3 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -4,6 +4,7 @@ import RealTimeMonitoring from './pages/RealTimeMonitoring'; import OtaManagement from './pages/OtaManagement'; import MqttManagement from './pages/MqttManagement'; import SystemSettings from './pages/SystemSettings'; +import Login from './pages/Login'; import type { ReactNode } from 'react'; interface RouteConfig { @@ -19,6 +20,12 @@ const routes: RouteConfig[] = [ path: '/', element: }, + { + name: '登录', + path: '/login', + element: , + visible: false + }, { name: '设备管理', path: '/devices', diff --git a/src/types/types.ts b/src/types/types.ts index 2c53922..70f4720 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -3,8 +3,26 @@ export type DeviceStatus = 'online' | 'offline' | 'maintenance' | 'error'; export type OtaStatus = 'pending' | 'downloading' | 'installing' | 'completed' | 'failed'; -// 设备信息 +// 设备信息 - 匹配Java后端返回格式 export interface Device { + id: string; + deviceId: string; + deviceName: string; + deviceSn: string; + deviceType: string; + status: DeviceStatus; + ipAddress?: string; + firmwareVersion?: string; + lastOnline?: string; + createBy?: string; + createTime?: string; + updateBy?: string; + updateTime?: string; + remark?: string; +} + +// 兼容旧版本的设备接口(下划线命名) +export interface DeviceLegacy { id: string; device_id: string; device_name: string; @@ -33,15 +51,18 @@ export interface BatteryData { // OTA升级任务 export interface OtaTask { id: string; - device_id: string; - task_name: string; - firmware_version: string; + deviceId: string; + taskName: string; + firmwareVersion: string; status: OtaStatus; progress: number; - start_time?: string; - end_time?: string; - error_message?: string; - created_at: string; + startTime?: string; + endTime?: string; + errorMessage?: string; + createTime: string; + updateTime?: string; + createBy?: string; + updateBy?: string; } // MQTT日志 @@ -110,6 +131,8 @@ export interface CreateDeviceRequest { device_id: string; device_name: string; device_type?: string; + device_sn?: string; + status?: DeviceStatus; ip_address?: string; firmware_version?: string; } @@ -118,6 +141,8 @@ export interface CreateDeviceRequest { export interface UpdateDeviceRequest { device_name?: string; status?: DeviceStatus; + device_type?: string; + device_sn?: string; ip_address?: string; firmware_version?: string; } @@ -132,4 +157,105 @@ export interface CreateOtaTaskRequest { // 更新系统配置请求 export interface UpdateConfigRequest { config_value: string; +} + +/** + * 电池极值数据(表:extreme_values) + * 电压单位:V;温度单位:℃;时间戳:UTC ISO 字符串 + */ +export interface ExtremeValues { + id: string; + deviceId: string; // 设备ID + timestamp: string; // 数据时间戳(UTC ISO) + maxVoltageSubsystemNo?: number; // 最高电压子系统号 + maxVoltageBatteryNo?: number; // 最高电压单体代号 + maxVoltageValue?: number; // 电池单体电压最高值(V) + minVoltageSubsystemNo?: number; // 最低电压子系统号 + minVoltageBatteryNo?: number; // 最低电压单体代号 + minVoltageValue?: number; // 电池单体电压最低值(V) + maxTempSubsystemNo?: number; // 最高温度子系统号 + maxTempProbeNo?: number; // 最高温度探针序号 + maxTempValue?: number; // 最高温度值(℃) + minTempSubsystemNo?: number; // 最低温度子系统号 + minTempProbeNo?: number; // 最低温度探针序号 + minTempValue?: number; // 最低温度值(℃) + createTime?: string; + updateTime?: string; + createBy?: string; + updateBy?: string; +} + +/** + * 车辆位置数据(表:vehicle_location) + * 经纬度保留 7 位小数;定位状态:0 无效,1 有效 + */ +export interface VehicleLocation { + id: string; + deviceId: string; // 设备ID + timestamp: string; // 数据时间戳(UTC ISO) + positioningStatus?: number; // 定位状态(0:无效 1:有效) + longitude?: number; // 经度(decimal(10,7)) + latitude?: number; // 纬度(decimal(10,7)) + createdAt?: string; + updatedAt?: string; +} + +/** + * 整车数据(表:vehicle_data) + * 速度 km/h;里程 km;电压 V;电流 A;SOC %;绝缘电阻 kΩ + */ +export interface VehicleData { + id: string; + deviceId: string; // 设备ID + timestamp: string; // 数据时间戳(UTC ISO) + vehicleStatus?: number; // 车辆状态(1:启动 2:熄火 3:其他 254:异常 255:无效) + chargingStatus?: number; // 充电状态(1:停车充电 2:行驶充电 3:未充电 4:充电完成 254:异常 255:无效) + operationMode?: number; // 运行模式(1:纯电 2:混动 3:燃油 254:异常 255:无效) + vehicleSpeed?: number; // 车速(km/h) + totalMileage?: number; // 累计里程(km) + totalVoltage?: number; // 总电压(V) + totalCurrent?: number; // 总电流(A) + soc?: number; // SOC(%) + dcdcStatus?: number; // DC-DC 状态(1:工作 2:断开) + gearPosition?: number; // 档位(0:空档 1-6:1-6档 13:倒档 14:D档 15:P档) + insulationResistance?: number; // 绝缘电阻(kΩ) + reservedData?: string; // 预留字段 + createdAt?: string; + updatedAt?: string; +} + +/** + * 子系统电压信息(表:subsystem_voltage) + * 电压单位:V;电流单位:A;batteryVoltages 为单体电压数组(V) + */ +export interface SubsystemVoltage { + id: string; + deviceId: string; // 设备ID + timestamp: string; // 数据时间戳(UTC ISO) + subsystemCount: number; // 子系统总数 + subsystemNo: number; // 子系统号 + subsystemVoltage?: number; // 子系统电压(V) + subsystemCurrent?: number; // 子系统电流(A) + totalBatteryCount?: number; // 单体电池总数 + frameStartBatteryNo?: number; // 本帧起始电池序号 + frameBatteryCount?: number; // 本帧单体电池总数 + batteryVoltages?: number[]; // 单体电池电压数组(V) + createdAt?: string; + updatedAt?: string; +} + +/** + * 子系统温度信息(表:subsystem_temperature) + * temperatureValues 为各温度探针检测值数组(℃) + */ +export interface SubsystemTemperature { + id: string; + deviceId: string; // 设备ID + timestamp: string; // 数据时间戳(UTC ISO) + subsystemCount: number; // 子系统总数 + subsystemNo: number; // 子系统号 + tempProbeCount?: number; // 温度探针个数 + temperatureValues?: number[]; // 温度值数组(℃) + createdAt?: string; + updatedAt?: string; } \ No newline at end of file diff --git a/test_data/bms_devices_test_data.sql b/test_data/bms_devices_test_data.sql new file mode 100644 index 0000000..f76686a --- /dev/null +++ b/test_data/bms_devices_test_data.sql @@ -0,0 +1,74 @@ +-- BMS设备管理表测试数据 +-- 清空现有数据(可选) +-- TRUNCATE TABLE bms_devices; + +-- 插入测试数据 +INSERT INTO `bms_devices` ( + `id`, + `device_id`, + `device_name`, + `device_type`, + `status`, + `ip_address`, + `firmware_version`, + `last_online`, + `create_by`, + `create_time`, + `update_time`, + `update_by` +) VALUES +-- 在线设备 +('550e8400-e29b-41d4-a716-446655440001', 'BBOX-001', '1号充电桩BBox', 'BBox', 'online', '192.168.1.101', 'v2.1.3', '2024-01-15 14:30:25', 'admin', '2024-01-10 09:00:00', '2024-01-15 14:30:25', '2024-01-15 14:30:25'), +('550e8400-e29b-41d4-a716-446655440002', 'BBOX-002', '2号充电桩BBox', 'BBox', 'online', '192.168.1.102', 'v2.1.3', '2024-01-15 14:28:15', 'admin', '2024-01-10 09:15:00', '2024-01-15 14:28:15', '2024-01-15 14:28:15'), +('550e8400-e29b-41d4-a716-446655440003', 'BBOX-003', '3号充电桩BBox', 'BBox', 'online', '192.168.1.103', 'v2.1.2', '2024-01-15 14:25:40', 'admin', '2024-01-10 09:30:00', '2024-01-15 14:25:40', '2024-01-15 14:25:40'), +('550e8400-e29b-41d4-a716-446655440004', 'BBOX-004', '4号充电桩BBox', 'BBox', 'online', '192.168.1.104', 'v2.1.3', '2024-01-15 14:32:10', 'admin', '2024-01-10 10:00:00', '2024-01-15 14:32:10', '2024-01-15 14:32:10'), +('550e8400-e29b-41d4-a716-446655440005', 'BBOX-005', '5号充电桩BBox', 'BBox', 'online', '192.168.1.105', 'v2.1.3', '2024-01-15 14:29:55', 'admin', '2024-01-10 10:15:00', '2024-01-15 14:29:55', '2024-01-15 14:29:55'), + +-- 离线设备 +('550e8400-e29b-41d4-a716-446655440006', 'BBOX-006', '6号充电桩BBox', 'BBox', 'offline', '192.168.1.106', 'v2.1.1', '2024-01-14 18:45:30', 'admin', '2024-01-10 10:30:00', '2024-01-14 18:45:30', '2024-01-14 18:45:30'), +('550e8400-e29b-41d4-a716-446655440007', 'BBOX-007', '7号充电桩BBox', 'BBox', 'offline', '192.168.1.107', 'v2.0.8', '2024-01-13 22:15:20', 'admin', '2024-01-10 11:00:00', '2024-01-13 22:15:20', '2024-01-13 22:15:20'), +('550e8400-e29b-41d4-a716-446655440008', 'BBOX-008', '8号充电桩BBox', 'BBox', 'offline', '192.168.1.108', 'v2.1.0', '2024-01-12 16:30:45', 'admin', '2024-01-10 11:15:00', '2024-01-12 16:30:45', '2024-01-12 16:30:45'), + +-- 维护中设备 +('550e8400-e29b-41d4-a716-446655440009', 'BBOX-009', '9号充电桩BBox', 'BBox', 'maintenance', '192.168.1.109', 'v2.1.2', '2024-01-15 08:00:00', 'admin', '2024-01-10 11:30:00', '2024-01-15 08:00:00', '2024-01-15 08:00:00'), +('550e8400-e29b-41d4-a716-446655440010', 'BBOX-010', '10号充电桩BBox', 'BBox', 'maintenance', '192.168.1.110', 'v2.1.1', '2024-01-15 07:30:00', 'admin', '2024-01-10 12:00:00', '2024-01-15 07:30:00', '2024-01-15 07:30:00'), + +-- 不同区域的设备 +('550e8400-e29b-41d4-a716-446655440011', 'BBOX-A01', 'A区1号BBox', 'BBox', 'online', '192.168.2.101', 'v2.1.3', '2024-01-15 14:31:20', 'admin', '2024-01-11 09:00:00', '2024-01-15 14:31:20', '2024-01-15 14:31:20'), +('550e8400-e29b-41d4-a716-446655440012', 'BBOX-A02', 'A区2号BBox', 'BBox', 'online', '192.168.2.102', 'v2.1.3', '2024-01-15 14:27:35', 'admin', '2024-01-11 09:30:00', '2024-01-15 14:27:35', '2024-01-15 14:27:35'), +('550e8400-e29b-41d4-a716-446655440013', 'BBOX-B01', 'B区1号BBox', 'BBox', 'offline', '192.168.3.101', 'v2.0.9', '2024-01-14 20:15:10', 'admin', '2024-01-11 10:00:00', '2024-01-14 20:15:10', '2024-01-14 20:15:10'), +('550e8400-e29b-41d4-a716-446655440014', 'BBOX-B02', 'B区2号BBox', 'BBox', 'online', '192.168.3.102', 'v2.1.2', '2024-01-15 14:26:50', 'admin', '2024-01-11 10:30:00', '2024-01-15 14:26:50', '2024-01-15 14:26:50'), +('550e8400-e29b-41d4-a716-446655440015', 'BBOX-C01', 'C区1号BBox', 'BBox', 'maintenance', '192.168.4.101', 'v2.1.1', '2024-01-15 06:00:00', 'admin', '2024-01-11 11:00:00', '2024-01-15 06:00:00', '2024-01-15 06:00:00'), + +-- 测试设备(用于开发测试) +('550e8400-e29b-41d4-a716-446655440016', 'BBOX-TEST01', '测试设备01', 'BBox', 'online', '192.168.100.101', 'v2.2.0-beta', '2024-01-15 14:33:00', 'developer', '2024-01-12 14:00:00', '2024-01-15 14:33:00', '2024-01-15 14:33:00'), +('550e8400-e29b-41d4-a716-446655440017', 'BBOX-TEST02', '测试设备02', 'BBox', 'offline', '192.168.100.102', 'v2.2.0-alpha', '2024-01-14 16:20:30', 'developer', '2024-01-12 14:30:00', '2024-01-14 16:20:30', '2024-01-14 16:20:30'), + +-- 老版本固件设备 +('550e8400-e29b-41d4-a716-446655440018', 'BBOX-OLD01', '老版本设备01', 'BBox', 'offline', '192.168.1.201', 'v1.8.5', '2024-01-10 12:00:00', 'admin', '2023-12-15 10:00:00', '2024-01-10 12:00:00', '2024-01-10 12:00:00'), +('550e8400-e29b-41d4-a716-446655440019', 'BBOX-OLD02', '老版本设备02', 'BBox', 'maintenance', '192.168.1.202', 'v1.9.2', '2024-01-11 08:30:00', 'admin', '2023-12-20 11:00:00', '2024-01-11 08:30:00', '2024-01-11 08:30:00'), + +-- 高负载区域设备 +('550e8400-e29b-41d4-a716-446655440020', 'BBOX-HUB01', '枢纽站1号BBox', 'BBox', 'online', '192.168.5.101', 'v2.1.3', '2024-01-15 14:34:15', 'admin', '2024-01-08 08:00:00', '2024-01-15 14:34:15', '2024-01-15 14:34:15'); + +-- 查询验证数据 +SELECT + COUNT(*) as total_devices, + SUM(CASE WHEN status = 'online' THEN 1 ELSE 0 END) as online_count, + SUM(CASE WHEN status = 'offline' THEN 1 ELSE 0 END) as offline_count, + SUM(CASE WHEN status = 'maintenance' THEN 1 ELSE 0 END) as maintenance_count +FROM bms_devices; + +-- 按固件版本统计 +SELECT + firmware_version, + COUNT(*) as device_count, + GROUP_CONCAT(device_name) as devices +FROM bms_devices +GROUP BY firmware_version +ORDER BY firmware_version DESC; + +-- 按状态查看设备 +SELECT device_id, device_name, status, ip_address, firmware_version, last_online +FROM bms_devices +ORDER BY status, last_online DESC; \ No newline at end of file diff --git a/vite.config.dev.ts b/vite.config.dev.ts new file mode 100644 index 0000000..7060c54 --- /dev/null +++ b/vite.config.dev.ts @@ -0,0 +1,197 @@ + + import { defineConfig, loadConfigFromFile } from "vite"; + import type { Plugin, ConfigEnv } from "vite"; + import tailwindcss from "tailwindcss"; + import autoprefixer from "autoprefixer"; + import fs from "fs/promises"; + import path from "path"; + import { + makeTagger, + injectedGuiListenerPlugin, + injectOnErrorPlugin + } from "miaoda-sc-plugin"; + + const tailwindConfig = { + plugins: [ + function ({ addUtilities }) { + addUtilities( + { + ".border-t-solid": { "border-top-style": "solid" }, + ".border-r-solid": { "border-right-style": "solid" }, + ".border-b-solid": { "border-bottom-style": "solid" }, + ".border-l-solid": { "border-left-style": "solid" }, + ".border-t-dashed": { "border-top-style": "dashed" }, + ".border-r-dashed": { "border-right-style": "dashed" }, + ".border-b-dashed": { "border-bottom-style": "dashed" }, + ".border-l-dashed": { "border-left-style": "dashed" }, + ".border-t-dotted": { "border-top-style": "dotted" }, + ".border-r-dotted": { "border-right-style": "dotted" }, + ".border-b-dotted": { "border-bottom-style": "dotted" }, + ".border-l-dotted": { "border-left-style": "dotted" }, + }, + ["responsive"] + ); + }, + ], + }; + + export async function tryLoadConfigFromFile( + filePath: string, + env: ConfigEnv = { command: "serve", mode: "development" } + ): Promise { + try { + const result = await loadConfigFromFile(env, filePath); + return result ? result.config : null; + } catch (error) { + console.warn(`加载配置文件失败: ${filePath},尝试加载cjs版本`); + console.warn(error); + + // 👇 创建 .cjs 临时文件重试 + const tempFilePath = + filePath.replace(/\.(js|ts|mjs|mts)$/, "") + `.temp.cjs`; + try { + const originalContent = await fs.readFile(filePath, "utf-8"); + + // 补充逻辑:如果是 ESM 语法,无法直接 require,会失败 + if (/^\s*import\s+/m.test(originalContent)) { + console.error( + `配置文件包含 import 语法,无法自动转为 CommonJS: ${filePath}` + ); + return null; + } + + await fs.writeFile(tempFilePath, originalContent, "utf-8"); + + const result = await loadConfigFromFile(env, tempFilePath); + return result ? result.config : null; + } catch (innerError) { + console.error(`重试加载临时 .cjs 文件失败: ${tempFilePath}`); + console.error(innerError); + return null; + } finally { + // 🧹 尝试删除临时文件 + try { + await fs.unlink(tempFilePath); + } catch (_) {} + } + } + } + + const env: ConfigEnv = { command: "serve", mode: "development" }; + const configFile = path.resolve(__dirname, "vite.config.ts"); + const result = await loadConfigFromFile(env, configFile); + const userConfig = result?.config; + const tailwindConfigFile = path.resolve(__dirname, "tailwind.config.js"); + const tailwindResult = await tryLoadConfigFromFile(tailwindConfigFile, env); + const root = path.resolve(__dirname); + + export default defineConfig({ + ...userConfig, + plugins: [ + makeTagger(), + injectedGuiListenerPlugin({ + path: 'https://resource-static.cdn.bcebos.com/common/v2/injected.js' + }), + injectOnErrorPlugin(), + ...(userConfig?.plugins || []), + +{ + name: 'hmr-toggle', + configureServer(server) { + let hmrEnabled = true; + + // 包装原来的 send 方法 + const _send = server.ws.send; + server.ws.send = (payload) => { + if (hmrEnabled) { + return _send.call(server.ws, payload); + } else { + console.log('[HMR disabled] skipped payload:', payload.type); + } + }; + + // 提供接口切换 HMR + server.middlewares.use('/innerapi/v1/sourcecode/__hmr_off', (req, res) => { + hmrEnabled = false; + let body = { + status: 0, + msg: 'HMR disabled' + }; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + }); + + server.middlewares.use('/innerapi/v1/sourcecode/__hmr_on', (req, res) => { + hmrEnabled = true; + let body = { + status: 0, + msg: 'HMR enabled' + }; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + }); + + // 注册一个 HTTP API,用来手动触发一次整体刷新 + server.middlewares.use('/innerapi/v1/sourcecode/__hmr_reload', (req, res) => { + if (hmrEnabled) { + server.ws.send({ + type: 'full-reload', + path: '*', // 整页刷新 + }); + } + res.statusCode = 200; + let body = { + status: 0, + msg: 'Manual full reload triggered' + }; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + }); + }, + load(id) { + if (id === 'virtual:after-update') { + return ` + if (import.meta.hot) { + import.meta.hot.on('vite:afterUpdate', () => { + window.postMessage( + { + type: 'editor-update' + }, + '*' + ); + }); + } + `; + } + }, + transformIndexHtml(html) { + return { + html, + tags: [ + { + tag: 'script', + attrs: { + type: 'module', + src: '/@id/virtual:after-update' + }, + injectTo: 'body' + } + ] + }; + } +}, + + ], + css: { + postcss: { + plugins: [ + tailwindcss({ + ...(tailwindResult as any), + content: [`${root}/index.html`, `${root}/src/**/*.{js,ts,jsx,tsx}`], + }), + autoprefixer(), + ], + }, + } + }); + \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index b9798ff..e7f27f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,39 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from 'vite'; +import type { UserConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; +import path from 'path'; + import { miaodaDevPlugin } from "miaoda-sc-plugin"; -import react from "@vitejs/plugin-react"; -import svgr from "vite-plugin-svgr"; -import path from "path"; // https://vite.dev/config/ -export default defineConfig({ - plugins: [ - react(), - miaodaDevPlugin(), - svgr({ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd()); + const target = env.VITE_JAVA_API_BASE_URL || 'http://192.168.5.200:8080'; + const config: UserConfig = { + plugins: [react(), svgr({ svgrOptions: { - icon: true, - exportType: "named", - namedExport: "ReactComponent", - }, - }), - ], + icon: true, + exportType: 'named', + namedExport: 'ReactComponent' + } + }), miaodaDevPlugin()], resolve: { alias: { - "@": path.resolve(__dirname, "./src"), + '@': path.resolve(__dirname, './src'), }, }, + server: { + host: '0.0.0.0', + port: 5174, + proxy: { + '/dev-api': { + target, + changeOrigin: true, + rewrite: (p) => p.replace(/^\/dev-api/, ''), + }, + }, + }, + }; + return config; });