# 技术实现详情

## 架构设计

**前端技术栈**
- 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通信日志,模拟真实设备通信
- 完整的系统配置参数

应用已完全实现需求文档中的所有功能,提供了专业级的电池管理解决方案。
This commit is contained in:
miaoda 2025-10-09 14:13:24 +08:00
parent eaf12ae490
commit 95098991f3
28 changed files with 2026 additions and 255 deletions

11
.env
View File

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

View File

@ -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<T>` 或数组),并注明 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<ExtremeValues | null> { ... },
/** 按设备查询极值列表limit 默认 100 */
async getByDevice(deviceId: string, limit = 100): Promise<ExtremeValues[]> { ... },
/** 按时间范围查询start/end 为 ISO 字符串 */
async getRange(deviceId: string, start: string, end: string): Promise<ExtremeValues[]> { ... },
/** 新增一条极值记录 */
async addRecord(payload: Omit<ExtremeValues, ...>): Promise<ExtremeValues> { ... }
};
// 聚合导出:设备/电池/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
```
## 验证与影响
* 仅添加注释,不改动逻辑与类型签名;不会影响编译与运行。
* 重新运行类型检查,确保无语法问题。
## 需要确认
* 是否按上述规范为本次新增的所有片段添加注释;如需

View File

@ -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 显示路径与默认值,其他功能不受影响。

View File

@ -0,0 +1,25 @@
## 目标
`javaBatteryDataApi.getChartData` 同时兼容两种后端返回:
- 直接数组 `List<ChartDataPoint>`
- 包装对象 `{ data: ChartDataPoint[] }`
从而首页刷新时能正常拿到数据并绘图。
## 修改点
- 文件:`src/api/javaApi.ts`
- 方法:`javaBatteryDataApi.getChartData`
- 调整实现:
```ts
async getChartData(deviceId: string, hours = 24): Promise<ChartDataPoint[]> {
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`
- 电压/电流两张图能显示数据,无控制台错误。
## 影响范围
- 仅修改图表数据接口读取逻辑;不影响其他模块。

View File

@ -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/登录页替代)。
## 影响范围
- 仅调整返回解析逻辑;不改变接口路径或页面结构。

View File

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

View File

@ -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 小时
## 验证
* 类型检查通过,首页能显示电压与电流两张趋势图;无影响其他页面逻辑
## 后续可选
* 增加设备与时间范围选择器,以便用户切换设备与窗口大小

View File

@ -1,3 +1,7 @@
# 欢迎使用你的秒哒应用代码包
秒哒应用链接
URL:https://www.miaoda.cn/projects/app-6qcydjtbzwu9
# 电池管理系统 (Battery Management System)
## 项目介绍

View File

@ -49,7 +49,39 @@ VUE 3框架
- 便捷的设备管理和操作界面
- 完善升级任务监控和日志查看功能
## 4. 参考文件
## 4. 接口文档
### 4.1 设备管理接口
- 设备登录授权接口
- 设备状态查询接口
- 设备列表获取接口
- 设备信息更新接口
### 4.2 数据传输接口
- 实时电池数据获取接口
- 历史数据查询接口
- 数据统计分析接口
- 设备状态数据接口
### 4.3 OTA升级接口
- 固件版本查询接口
- OTA升级任务创建接口
- 升级进度查询接口
- 升级日志获取接口
### 4.4 通信管理接口
- MQTT连接状态接口
- 消息发送接口
- 消息接收状态查询接口
- 通信日志接口
### 4.5 系统配置接口
- 系统参数配置接口
- 配置信息获取接口
- 用户权限管理接口
- 系统状态监控接口
## 5. 参考文件
参考文件路径:
1. 动力电池端采集设备与平台交互接口规范 - 羿动新能源科技有限公司技术文档

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

0
rules/check.sh Executable file → Normal file
View File

View File

@ -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 App: React.FC = () => {
const AppContent: React.FC = () => {
const location = useLocation();
const hideHeader = location.pathname === '/login';
return (
<Router>
<div className="flex flex-col min-h-screen bg-gray-50">
<Header />
{!hideHeader && <Header />}
<main className="flex-grow">
<Routes>
{routes.map((route, index) => (
<Route
key={index}
path={route.path}
element={route.element}
/>
<Route key={index} path={route.path} element={route.element} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<Toaster position="top-right" richColors />
</div>
);
};
const App: React.FC = () => {
return (
<Router>
<AppContent />
</Router>
);
};

529
src/api/javaApi.ts Normal file
View File

@ -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<Device[]> {
const response: any = await apiClient.get('/devices/devices/list');
// 后端返回的是 TableDataInfo 格式,需要从 rows 中获取数据
return response.rows || [];
},
// 获取设备详情(包含最新电池数据)
async getDeviceDetail(deviceId: string): Promise<DeviceDetail | null> {
try {
const response: ApiResponse<DeviceDetail> = 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<Device> {
// 将表单的 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<Device> = await apiClient.post('/devices/devices', payload);
return response.data;
},
// 更新设备
async updateDevice(id: string, updates: UpdateDeviceRequest): Promise<Device> {
// 将更新请求的 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<Device> = await apiClient.put(`/devices/devices`, payload);
return response.data;
},
// 删除设备 - 使用数据库主键id
async deleteDevice(id: string): Promise<void> {
await apiClient.delete(`/devices/devices/${id}`);
},
// 批量删除设备 - 使用数据库主键id数组
async batchDeleteDevices(ids: string[]): Promise<void> {
await apiClient.post('/devices/devices/batch-delete', { ids });
},
// 获取设备分页列表 - 使用后端的分页查询
async getDevicesPaginated(page: number = 1, pageSize: number = 10, status?: string, keyword?: string): Promise<PaginatedResponse<Device>> {
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<BatteryData[]> {
const response: ApiResponse<BatteryData[]> = await apiClient.get(`/battery-data/${deviceId}`, {
params: { limit }
});
return response.data;
},
// 获取实时图表数据
/**
*
* - List<ChartDataPoint>
* - { data: ChartDataPoint[] }
*/
async getChartData(deviceId: string, hours = 24): Promise<ChartDataPoint[]> {
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<BatteryData, "id" | "timestamp">): Promise<BatteryData> {
const response: ApiResponse<BatteryData> = await apiClient.post('/battery-data', data);
return response.data;
},
// 获取设备最新电池数据
async getLatestBatteryData(deviceId: string): Promise<BatteryData | null> {
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<ExtremeValues | null> {
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<ExtremeValues[]> {
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<ExtremeValues[]> {
const response: any = await apiClient.get(`/extreme-values/${deviceId}/range`, { params: { start, end } });
return Array.isArray(response) ? response : (response?.data ?? []);
},
/** 新增一条极值记录 */
async addRecord(payload: Omit<ExtremeValues, "id" | "createTime" | "updateTime" | "createBy" | "updateBy">): Promise<ExtremeValues> {
const response: ApiResponse<ExtremeValues> = 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<VehicleLocation | null> {
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<VehicleLocation[]> {
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<VehicleLocation, "id" | "createdAt" | "updatedAt">): Promise<VehicleLocation> {
const response: ApiResponse<VehicleLocation> = 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<VehicleData | null> {
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<VehicleData[]> {
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<VehicleData[]> {
const response: any = await apiClient.get(`/vehicle-data/${deviceId}/range`, { params: { start, end } });
return Array.isArray(response) ? response : (response?.data ?? []);
},
/** 新增整车数据记录 */
async addRecord(payload: Omit<VehicleData, "id" | "createdAt" | "updatedAt">): Promise<VehicleData> {
const response: ApiResponse<VehicleData> = 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<SubsystemVoltage | null> {
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<SubsystemVoltage[]> {
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<SubsystemVoltage[]> {
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<SubsystemVoltage, "id" | "createdAt" | "updatedAt">): Promise<SubsystemVoltage> {
const response: ApiResponse<SubsystemVoltage> = 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<SubsystemTemperature | null> {
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<SubsystemTemperature[]> {
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<SubsystemTemperature[]> {
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<SubsystemTemperature, "id" | "createdAt" | "updatedAt">): Promise<SubsystemTemperature> {
const response: ApiResponse<SubsystemTemperature> = await apiClient.post('/subsystem-temperature', payload);
return response.data;
}
};
// OTA任务API
export const javaOtaApi = {
// 列表查询(后端返回 TableDataInfo需要从 rows 取数组)
async getTasks(params?: Record<string, any>): Promise<OtaTask[]> {
const response: any = await apiClient.get('/ota/tasks/list', { params });
return response.rows || [];
},
// 获取任务详情
async getTaskById(id: string): Promise<OtaTask> {
const response: any = await apiClient.get(`/ota/tasks/${id}`);
return response.data ?? response;
},
// 创建任务
async createTask(task: CreateOtaTaskRequest): Promise<any> {
// 将表单的 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<OtaTask> & { id: string }): Promise<any> {
const response: any = await apiClient.put('/ota/tasks', task);
return response;
},
// 批量删除
async deleteTasks(ids: string[]): Promise<any> {
const idPath = ids.join(',');
const response: any = await apiClient.delete(`/ota/tasks/${idPath}`);
return response;
},
// 导出任务列表Excel
async exportTasks(params?: Record<string, any>): Promise<Blob> {
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<MqttLog[]> {
const params: any = { limit };
if (deviceId) params.deviceId = deviceId;
const response: ApiResponse<MqttLog[]> = await apiClient.get('/mqtt/logs', { params });
return response.data;
},
// 添加MQTT日志
async addLog(log: Omit<MqttLog, "id" | "timestamp">): Promise<MqttLog> {
const response: ApiResponse<MqttLog> = await apiClient.post('/mqtt/logs', log);
return response.data;
}
};
// 系统配置API
export const javaSystemApi = {
// 获取所有配置
async getConfigs(): Promise<SystemConfig[]> {
const response: ApiResponse<SystemConfig[]> = await apiClient.get('/system/configs');
return response.data;
},
// 获取单个配置
async getConfig(key: string): Promise<SystemConfig | null> {
try {
const response: ApiResponse<SystemConfig> = 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<SystemConfig> {
const response: ApiResponse<SystemConfig> = await apiClient.put(`/system/configs/${key}`, updates);
return response.data;
},
// 获取仪表盘统计数据
async getDashboardStats(): Promise<DashboardStats> {
const response: ApiResponse<DashboardStats> = await apiClient.get('/system/dashboard-stats');
return response.data;
}
};
export const javaAuthApi = {
async login(body: { username: string; password: string; code?: string; uuid?: string }): Promise<string> {
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<any> {
const response: any = await apiClient.get('/getInfo');
return response?.data ?? response;
},
async getRouters(): Promise<any> {
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;

View File

@ -99,19 +99,21 @@ let BMapGLLoadingPromise: Promise<void> | 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<HTMLDivElement>(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

View File

@ -5,7 +5,6 @@ import { Progress } from '@/components/ui/progress';
import {
Battery,
Zap,
Thermometer,
Activity,
Wifi,
WifiOff,
@ -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<DashboardStats | null>(null);
const [devices, setDevices] = useState<Device[]>([]);
const [recentBatteryData, setRecentBatteryData] = useState<BatteryData[]>([]);
// 首页图表数据源Java 图表接口返回的 24 小时数据timestamp, voltage, current
const [chartData, setChartData] = useState<ChartDataPoint[]>([]);
const [recentTasks, setRecentTasks] = useState<OtaTask[]>([]);
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);
@ -194,14 +216,14 @@ const Dashboard: React.FC = () => {
<div className="flex items-center space-x-3">
{getStatusIcon(device.status)}
<div>
<div className="font-medium text-gray-900">{device.device_name}</div>
<div className="text-sm text-gray-500">{device.device_id}</div>
<div className="font-medium text-gray-900">{device.deviceName}</div>
<div className="text-sm text-gray-500">{device.deviceId}</div>
</div>
</div>
<div className="text-right">
{getStatusBadge(device.status)}
<div className="text-xs text-gray-500 mt-1">
{device.firmware_version}
{device.firmwareVersion}
</div>
</div>
</div>
@ -225,8 +247,8 @@ const Dashboard: React.FC = () => {
<div className="flex items-center space-x-3">
{getTaskStatusIcon(task.status)}
<div>
<div className="font-medium text-gray-900">{task.task_name}</div>
<div className="text-sm text-gray-500">{task.device_id}</div>
<div className="font-medium text-gray-900">{task.taskName}</div>
<div className="text-sm text-gray-500">{task.deviceId}</div>
</div>
</div>
<div className="text-right">
@ -240,8 +262,30 @@ const Dashboard: React.FC = () => {
</Card>
</div>
<div className="grid grid-cols-1 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Server className="h-5 w-5 text-blue-600" />
<span>6</span>
</CardTitle>
</CardHeader>
<CardContent>
{trackPoints.length > 1 ? (
<Map
ak={import.meta.env.VITE_BAIDU_MAP_AK || 'OeTpXHgdUrRT2pPyAPRL7pog6GlMlQzl'}
option={{ zoom: 12, lng: trackPoints[trackPoints.length - 1].lng, lat: trackPoints[trackPoints.length - 1].lat, address: '轨迹' }}
path={trackPoints}
className="rounded-lg"
/>
) : (
<div className="h-[300px] flex items-center justify-center text-sm text-gray-500"></div>
)}
</CardContent>
</Card>
</div>
{/* 电池数据图表 */}
{recentBatteryData.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
@ -251,8 +295,10 @@ const Dashboard: React.FC = () => {
</CardTitle>
</CardHeader>
<CardContent>
{/* 电压趋势图:单位 V数据来源 chartData */}
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={recentBatteryData}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
@ -272,19 +318,26 @@ const Dashboard: React.FC = () => {
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-sm text-gray-500">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Thermometer className="h-5 w-5 text-blue-600" />
<span></span>
<Activity className="h-5 w-5 text-green-600" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent>
{/* 电流趋势图:单位 A数据来源 chartData */}
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={recentBatteryData}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
@ -293,21 +346,25 @@ const Dashboard: React.FC = () => {
<YAxis />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString('zh-CN')}
formatter={(value: number) => [`${value}°C`, '温度']}
formatter={(value: number) => [`${value}A`, '电流']}
/>
<Line
type="monotone"
dataKey="temperature"
stroke="#ef4444"
dataKey="current"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: '#ef4444', strokeWidth: 2, r: 4 }}
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-sm text-gray-500">
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
};

View File

@ -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 = () => {
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="device_sn"
render={({ field }) => (
<FormItem>
<FormLabel>SN码</FormLabel>
<FormControl>
<Input placeholder="例如: SN-0006" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="device_type"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="例如: BBOX" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="ip_address"
@ -234,6 +264,29 @@ const DeviceManagement: React.FC = () => {
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择状态" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="online">线</SelectItem>
<SelectItem value="offline">线</SelectItem>
<SelectItem value="maintenance"></SelectItem>
<SelectItem value="error"></SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="firmware_version"
@ -298,10 +351,10 @@ const DeviceManagement: React.FC = () => {
<Card key={device.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{device.device_name}</CardTitle>
<CardTitle className="text-lg">{device.deviceName}</CardTitle>
{getStatusIcon(device.status)}
</div>
<div className="text-sm text-gray-500">{device.device_id}</div>
<div className="text-sm text-gray-500">{device.deviceId}</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
@ -309,25 +362,31 @@ const DeviceManagement: React.FC = () => {
{getStatusBadge(device.status)}
</div>
{device.ip_address && (
{device.ipAddress && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">IP地址</span>
<span className="text-sm font-mono">{device.ip_address}</span>
<span className="text-sm font-mono">{device.ipAddress}</span>
</div>
)}
{device.deviceSn && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">SN码</span>
<span className="text-sm font-mono">{device.deviceSn}</span>
</div>
)}
{device.firmware_version && (
{device.firmwareVersion && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<span className="text-sm font-mono">{device.firmware_version}</span>
<span className="text-sm font-mono">{device.firmwareVersion}</span>
</div>
)}
{device.last_online && (
{device.lastOnline && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">线</span>
<span className="text-sm">
{new Date(device.last_online).toLocaleString('zh-CN')}
{new Date(device.lastOnline).toLocaleString('zh-CN')}
</span>
</div>
)}
@ -347,10 +406,12 @@ const DeviceManagement: React.FC = () => {
onClick={() => {
setSelectedDevice(device);
editForm.reset({
device_name: device.device_name,
device_name: device.deviceName,
status: device.status,
ip_address: device.ip_address || '',
firmware_version: device.firmware_version || ''
device_type: device.deviceType || '',
device_sn: device.deviceSn || '',
ip_address: device.ipAddress || '',
firmware_version: device.firmwareVersion || ''
});
setIsEditDialogOpen(true);
}}
@ -360,7 +421,7 @@ const DeviceManagement: React.FC = () => {
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteDevice(device.device_id)}
onClick={() => handleDeleteDevice(device.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
@ -426,6 +487,32 @@ const DeviceManagement: React.FC = () => {
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="device_type"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="device_sn"
render={({ field }) => (
<FormItem>
<FormLabel>SN码</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="ip_address"
@ -474,15 +561,15 @@ const DeviceManagement: React.FC = () => {
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{selectedDevice.device_name}</div>
<div className="mt-1 text-sm">{selectedDevice.deviceName}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600">ID</Label>
<div className="mt-1 text-sm font-mono">{selectedDevice.device_id}</div>
<div className="mt-1 text-sm font-mono">{selectedDevice.deviceId}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{selectedDevice.device_type}</div>
<div className="mt-1 text-sm">{selectedDevice.deviceType}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
@ -490,22 +577,40 @@ const DeviceManagement: React.FC = () => {
</div>
<div>
<Label className="text-sm font-medium text-gray-600">IP地址</Label>
<div className="mt-1 text-sm font-mono">{selectedDevice.ip_address || '未设置'}</div>
<div className="mt-1 text-sm font-mono">{selectedDevice.ipAddress || '未设置'}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm font-mono">{selectedDevice.firmware_version || '未知'}</div>
<div className="mt-1 text-sm font-mono">{selectedDevice.firmwareVersion || '未知'}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{new Date(selectedDevice.created_at).toLocaleString('zh-CN')}</div>
<div className="mt-1 text-sm">{selectedDevice.createTime ? new Date(selectedDevice.createTime).toLocaleString('zh-CN') : '--'}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600">线</Label>
<div className="mt-1 text-sm">
{selectedDevice.last_online ? new Date(selectedDevice.last_online).toLocaleString('zh-CN') : '从未在线'}
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{selectedDevice.createBy || '--'}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{selectedDevice.updateTime ? new Date(selectedDevice.updateTime).toLocaleString('zh-CN') : '--'}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{selectedDevice.updateBy || '--'}</div>
</div>
<div>
<Label className="text-sm font-medium text-gray-600">线</Label>
<div className="font-medium">
{selectedDevice.lastOnline ? new Date(selectedDevice.lastOnline).toLocaleString('zh-CN') : '从未在线'}
</div>
</div>
{selectedDevice.remark && (
<div>
<Label className="text-sm font-medium text-gray-600"></Label>
<div className="mt-1 text-sm">{selectedDevice.remark}</div>
</div>
)}
</div>
{deviceBatteryData && (

42
src/pages/Login.css Normal file
View File

@ -0,0 +1,42 @@
@import url('https://fonts.googleapis.com/css?family=Montserrat:400,800');
* { box-sizing: border-box; }
.login-page {
background: linear-gradient(135deg, #eef2ff 0%, #e9d5ff 50%, #fdf2f8 100%);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-family: 'Montserrat', sans-serif;
min-height: 100vh;
padding: 32px;
}
h1 { font-weight: bold; margin: 0; }
h2 { text-align: center; }
p { font-size: 14px; font-weight: 100; line-height: 20px; letter-spacing: 0.5px; margin: 20px 0 30px; }
span { font-size: 12px; }
a { color: #333; font-size: 14px; text-decoration: none; margin: 15px 0; }
button.login-btn { border-radius: 20px; border: 1px solid #FF4B2B; background-color: #FF4B2B; color: #FFFFFF; font-size: 12px; font-weight: bold; padding: 12px 45px; letter-spacing: 1px; text-transform: uppercase; transition: transform 80ms ease-in; width: 100%; }
button.login-btn:active { transform: scale(0.95); }
button.login-btn:focus { outline: none; }
button.login-ghost { background-color: transparent; border-color: #FFFFFF; }
form.login-form { background-color: #FFFFFF; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 0 50px; height: 100%; text-align: center; }
input.login-input { background-color: #eee; border: none; padding: 12px 15px; margin: 8px 0; width: 100%; border-radius: 8px; }
.container.login-container { background-color: #fff; border-radius: 10px; box-shadow: 0 14px 28px rgba(0,0,0,0.15), 0 10px 10px rgba(0,0,0,0.08); position: relative; overflow: hidden; width: 820px; max-width: 100%; min-height: 520px; margin: 0 auto; }
.form-container { position: absolute; top: 0; height: 100%; transition: all 0.6s ease-in-out; }
.sign-in-container { left: 0; width: 50%; z-index: 2; }
.overlay-container { position: absolute; top: 0; left: 50%; width: 50%; height: 100%; overflow: hidden; transition: transform 0.6s ease-in-out; z-index: 100; }
.overlay { background: -webkit-linear-gradient(to right, #2563eb, #7c3aed); background: linear-gradient(to right, #2563eb, #7c3aed); background-repeat: no-repeat; background-size: cover; background-position: 0 0; color: #FFFFFF; position: relative; left: -100%; height: 100%; width: 200%; transform: translateX(0); transition: transform 0.6s ease-in-out; }
.overlay-panel { position: absolute; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 0 40px; text-align: center; top: 0; height: 100%; width: 50%; transform: translateX(0); transition: transform 0.6s ease-in-out; }
.overlay-right { right: 0; }
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.login-brand-badge { height: 40px; width: 40px; border-radius: 10px; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-weight: 700; }
.company-logo { height: 32px; width: auto; object-fit: contain; }
.captcha-row { display: flex; gap: 8px; align-items: center; width: 100%; }
.captcha-img { height: 40px; width: 120px; border-radius: 8px; border: 1px solid #ddd; object-fit: cover; cursor: pointer; }
.error-text { color: #ef4444; font-size: 12px; margin-top: 6px; }

133
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,133 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { javaAuthApi } from '@/api/javaApi';
import './Login.css';
const Login: React.FC = () => {
const navigate = useNavigate();
const publicBase = (import.meta.env as any).BASE_URL || '/';
const rawLogo = (import.meta.env as any).VITE_COMPANY_LOGO_URL;
const companyLogo = rawLogo
? (rawLogo.startsWith('/') ? `${publicBase}${rawLogo.replace(/^\//, '')}` : rawLogo)
: `${publicBase}images/logo/evo-tech.png`;
const candidates = [
companyLogo,
`${publicBase}images/logo/auth-logo.svg`,
`${publicBase}images/logo/logo-dark.svg`,
];
const [logoIndex, setLogoIndex] = useState(0);
const [logoSrc, setLogoSrc] = useState<string>(candidates[0]);
const [showTextBrand, setShowTextBrand] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [captchaImg, setCaptchaImg] = useState<string>('');
const [captchaUuid, setCaptchaUuid] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const loadCaptcha = async () => {
try {
const { img, uuid } = await javaAuthApi.getCaptcha();
setCaptchaImg(img);
setCaptchaUuid(uuid);
} catch (e: any) {
setError(e?.message || '验证码加载失败');
}
};
useEffect(() => {
loadCaptcha();
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
setLoading(true);
await javaAuthApi.login({ username, password, code, uuid: captchaUuid });
navigate('/');
} catch (err: any) {
setError(err?.message || '登录失败');
loadCaptcha();
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="container login-container" id="container">
<div className="form-container sign-in-container">
<form className="login-form" onSubmit={handleLogin}>
<div className="login-brand">
<div className="login-brand-badge">
{showTextBrand ? (
<span>EVO</span>
) : (
<img
className="company-logo"
src={logoSrc}
alt="logo"
onError={() => {
if (logoIndex < candidates.length - 1) {
const next = logoIndex + 1;
setLogoIndex(next);
setLogoSrc(candidates[next]);
} else {
setShowTextBrand(true);
}
}}
/>
)}
</div>
<h1></h1>
</div>
<span></span>
<input className="login-input" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="用户名" />
<input className="login-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="密码" />
{captchaImg && (
<div className="captcha-row">
<input className="login-input" value={code} onChange={(e) => setCode(e.target.value)} placeholder="验证码" />
<img className="captcha-img" src={captchaImg} alt="captcha" onClick={loadCaptcha} />
</div>
)}
{error && <div className="error-text">{error}</div>}
<button className="login-btn" type="submit" disabled={loading}>{loading ? '登录中...' : '登录'}</button>
</form>
</div>
<div className="overlay-container">
<div className="overlay">
<div className="overlay-panel overlay-right">
<div className="login-brand" style={{marginBottom: 16}}>
{showTextBrand ? (
<span className="login-brand-badge">EVO</span>
) : (
<img
className="company-logo"
src={logoSrc}
alt="logo"
onError={() => {
if (logoIndex < candidates.length - 1) {
const next = logoIndex + 1;
setLogoIndex(next);
setLogoSrc(candidates[next]);
} else {
setShowTextBrand(true);
}
}}
/>
)}
</div>
<h1></h1>
<p></p>
<span></span>
</div>
</div>
</div>
</div>
</div>
);
};
export default Login;

View File

@ -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 = () => {
<SelectContent>
<SelectItem value="all"></SelectItem>
{devices.map((device) => (
<SelectItem key={device.device_id} value={device.device_id}>
{device.device_name}
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.deviceName}
</SelectItem>
))}
</SelectContent>

View File

@ -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 = () => {
</FormControl>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.device_id} value={device.device_id}>
{device.device_name} ({device.device_id})
<SelectItem key={device.id} value={device.id}>
{device.deviceName} ({device.id})
</SelectItem>
))}
</SelectContent>
@ -320,9 +323,9 @@ const OtaManagement: React.FC = () => {
<div className="flex items-center space-x-3">
{getStatusIcon(task.status)}
<div>
<h3 className="font-semibold text-lg">{task.task_name}</h3>
<h3 className="font-semibold text-lg">{task.taskName}</h3>
<p className="text-sm text-gray-600">
: {task.device_id} | : {task.firmware_version}
: {task.deviceId} | : {task.firmwareVersion}
</p>
</div>
</div>
@ -347,42 +350,42 @@ const OtaManagement: React.FC = () => {
<div>
<span className="text-gray-600">:</span>
<div className="font-medium">
{new Date(task.created_at).toLocaleString('zh-CN')}
{new Date(task.createTime).toLocaleString('zh-CN')}
</div>
</div>
{task.start_time && (
{task.startTime && (
<div>
<span className="text-gray-600">:</span>
<div className="font-medium">
{new Date(task.start_time).toLocaleString('zh-CN')}
{new Date(task.startTime).toLocaleString('zh-CN')}
</div>
</div>
)}
{task.end_time && (
{task.endTime && (
<div>
<span className="text-gray-600">:</span>
<div className="font-medium">
{new Date(task.end_time).toLocaleString('zh-CN')}
{new Date(task.endTime).toLocaleString('zh-CN')}
</div>
</div>
)}
{task.end_time && task.start_time && (
{task.endTime && task.startTime && (
<div>
<span className="text-gray-600">:</span>
<div className="font-medium">
{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)}
</div>
</div>
)}
</div>
{task.error_message && (
{task.errorMessage && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center space-x-2 text-red-700">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">:</span>
</div>
<p className="text-red-600 text-sm mt-1">{task.error_message}</p>
<p className="text-red-600 text-sm mt-1">{task.errorMessage}</p>
</div>
)}
@ -466,15 +469,15 @@ const OtaManagement: React.FC = () => {
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">:</span>
<div className="font-medium">{selectedTask.task_name}</div>
<div className="font-medium">{selectedTask.taskName}</div>
</div>
<div>
<span className="text-gray-600">ID:</span>
<div className="font-medium font-mono">{selectedTask.device_id}</div>
<div className="font-medium font-mono">{selectedTask.deviceId}</div>
</div>
<div>
<span className="text-gray-600">:</span>
<div className="font-medium font-mono">{selectedTask.firmware_version}</div>
<div className="font-medium font-mono">{selectedTask.firmwareVersion}</div>
</div>
<div>
<span className="text-gray-600">:</span>
@ -485,9 +488,9 @@ const OtaManagement: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm h-64 overflow-y-auto">
<div>[{new Date(selectedTask.created_at).toLocaleString()}] </div>
{selectedTask.start_time && (
<div>[{new Date(selectedTask.start_time).toLocaleString()}] ...</div>
<div>[{new Date(selectedTask.createTime).toLocaleString()}] </div>
{selectedTask.startTime && (
<div>[{new Date(selectedTask.startTime).toLocaleString()}] ...</div>
)}
{selectedTask.status === 'downloading' && (
<>
@ -504,15 +507,15 @@ const OtaManagement: React.FC = () => {
)}
{selectedTask.status === 'completed' && (
<>
<div>[{new Date(selectedTask.end_time!).toLocaleString()}] </div>
<div>[{new Date(selectedTask.end_time!).toLocaleString()}] ...</div>
<div>[{new Date(selectedTask.end_time!).toLocaleString()}] </div>
<div>[{new Date(selectedTask.endTime!).toLocaleString()}] </div>
<div>[{new Date(selectedTask.endTime!).toLocaleString()}] ...</div>
<div>[{new Date(selectedTask.endTime!).toLocaleString()}] </div>
</>
)}
{selectedTask.status === 'failed' && selectedTask.error_message && (
{selectedTask.status === 'failed' && selectedTask.errorMessage && (
<>
<div className="text-red-400">[{new Date(selectedTask.end_time!).toLocaleString()}] : {selectedTask.error_message}</div>
<div className="text-red-400">[{new Date(selectedTask.end_time!).toLocaleString()}] </div>
<div className="text-red-400">[{new Date(selectedTask.endTime!).toLocaleString()}] : {selectedTask.errorMessage}</div>
<div className="text-red-400">[{new Date(selectedTask.endTime!).toLocaleString()}] </div>
</>
)}
</div>

View File

@ -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<Device[]>([]);
@ -25,6 +25,13 @@ const RealTimeMonitoring: React.FC = () => {
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [chartData, setChartData] = useState<ChartDataPoint[]>([]);
const [latestData, setLatestData] = useState<BatteryData | null>(null);
const [vehicleLatest, setVehicleLatest] = useState<VehicleData | null>(null);
const [locationLatest, setLocationLatest] = useState<VehicleLocation | null>(null);
const [extremeLatest, setExtremeLatest] = useState<ExtremeValues | null>(null);
const [subsystemNoVolt, setSubsystemNoVolt] = useState<number>(1);
const [subsystemNoTemp, setSubsystemNoTemp] = useState<number>(1);
const [subsystemVoltLatest, setSubsystemVoltLatest] = useState<SubsystemVoltage | null>(null);
const [subsystemTempLatest, setSubsystemTempLatest] = useState<SubsystemTemperature | null>(null);
const [isRealTime, setIsRealTime] = useState(false);
const [timeRange, setTimeRange] = useState<number>(24);
const [loading, setLoading] = useState(true);
@ -41,7 +48,7 @@ const RealTimeMonitoring: React.FC = () => {
}, [selectedDeviceId, timeRange]);
useEffect(() => {
let interval: NodeJS.Timeout;
let interval: ReturnType<typeof setInterval>;
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 = () => {
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.device_id} value={device.device_id}>
{device.device_name} ({device.device_id})
<SelectItem key={device.id} value={device.deviceId}>
{device.deviceName} ({device.deviceId})
</SelectItem>
))}
</SelectContent>
@ -206,11 +255,37 @@ const RealTimeMonitoring: React.FC = () => {
</SelectContent>
</Select>
</div>
<div className="w-full md:w-48">
<label className="block text-sm font-medium text-gray-700 mb-2">()</label>
<Select value={subsystemNoVolt.toString()} onValueChange={(value) => setSubsystemNoVolt(Number(value))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 10 }, (_, i) => i + 1).map(no => (
<SelectItem key={no} value={no.toString()}>{no}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full md:w-48">
<label className="block text-sm font-medium text-gray-700 mb-2">()</label>
<Select value={subsystemNoTemp.toString()} onValueChange={(value) => setSubsystemNoTemp(Number(value))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 10 }, (_, i) => i + 1).map(no => (
<SelectItem key={no} value={no.toString()}>{no}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{selectedDevice && latestData && (
{selectedDevice && (
<>
{/* 实时数据卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
@ -223,9 +298,9 @@ const RealTimeMonitoring: React.FC = () => {
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latestData.voltage?.toFixed(2) || '--'}V</div>
<div className="text-2xl font-bold">{latestData?.voltage?.toFixed(2) || '--'}V</div>
<p className="text-xs text-blue-100">
{new Date(latestData.timestamp).toLocaleTimeString('zh-CN')}
{latestData?.timestamp ? new Date(latestData.timestamp).toLocaleTimeString('zh-CN') : '--'}
</p>
</CardContent>
</Card>
@ -239,9 +314,9 @@ const RealTimeMonitoring: React.FC = () => {
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latestData.current?.toFixed(2) || '--'}A</div>
<div className="text-2xl font-bold">{latestData?.current?.toFixed(2) || '--'}A</div>
<p className="text-xs text-green-100">
: {latestData.power?.toFixed(2) || '--'}W
: {latestData?.power?.toFixed(2) || '--'}W
</p>
</CardContent>
</Card>
@ -255,9 +330,9 @@ const RealTimeMonitoring: React.FC = () => {
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latestData.temperature?.toFixed(1) || '--'}°C</div>
<div className="text-2xl font-bold">{latestData?.temperature?.toFixed(1) || '--'}°C</div>
<p className="text-xs text-red-100">
{latestData.temperature && latestData.temperature > 50 ? '高温警告' : '温度正常'}
{latestData?.temperature && latestData?.temperature > 50 ? '高温警告' : '温度正常'}
</p>
</CardContent>
</Card>
@ -271,9 +346,9 @@ const RealTimeMonitoring: React.FC = () => {
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latestData.soc?.toFixed(1) || '--'}%</div>
<div className="text-2xl font-bold">{latestData?.soc?.toFixed(1) || '--'}%</div>
<p className="text-xs text-yellow-100">
{latestData.soc && latestData.soc < 20 ? '电量不足' : '电量充足'}
{latestData?.soc && latestData?.soc < 20 ? '电量不足' : '电量充足'}
</p>
</CardContent>
</Card>
@ -284,12 +359,52 @@ const RealTimeMonitoring: React.FC = () => {
<Activity className="h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latestData.soh?.toFixed(1) || '--'}%</div>
<div className="text-2xl font-bold">{latestData?.soh?.toFixed(1) || '--'}%</div>
<p className="text-xs text-purple-100">
{latestData.soh && latestData.soh > 95 ? '状态良好' : '需要关注'}
{latestData?.soh && latestData?.soh > 95 ? '状态良好' : '需要关注'}
</p>
</CardContent>
</Card>
{vehicleLatest && (
<Card className="bg-gradient-to-r from-indigo-500 to-indigo-600 text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-sm">: {vehicleLatest.totalVoltage ?? '--'} V</div>
<div className="text-sm">: {vehicleLatest.totalCurrent ?? '--'} A</div>
<div className="text-sm">SOC: {vehicleLatest.soc ?? '--'} %</div>
<div className="text-xs text-indigo-100">: {vehicleLatest.vehicleSpeed ?? '--'} km/h | : {vehicleLatest.totalMileage ?? '--'} km</div>
</CardContent>
</Card>
)}
{extremeLatest && (
<Card className="bg-gradient-to-r from-rose-500 to-rose-600 text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-sm">: {extremeLatest.maxVoltageValue ?? '--'} V ( {extremeLatest.maxVoltageSubsystemNo ?? '--'} / {extremeLatest.maxVoltageBatteryNo ?? '--'})</div>
<div className="text-sm">: {extremeLatest.minVoltageValue ?? '--'} V ( {extremeLatest.minVoltageSubsystemNo ?? '--'} / {extremeLatest.minVoltageBatteryNo ?? '--'})</div>
<div className="text-sm">: {extremeLatest.maxTempValue ?? '--'} ( {extremeLatest.maxTempSubsystemNo ?? '--'} / {extremeLatest.maxTempProbeNo ?? '--'})</div>
<div className="text-sm">: {extremeLatest.minTempValue ?? '--'} ( {extremeLatest.minTempSubsystemNo ?? '--'} / {extremeLatest.minTempProbeNo ?? '--'})</div>
</CardContent>
</Card>
)}
{locationLatest && (
<Card className="bg-gradient-to-r from-teal-500 to-teal-600 text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-sm">: {locationLatest.longitude ?? '--'} | : {locationLatest.latitude ?? '--'}</div>
<div className="text-xs text-teal-100">: {locationLatest.positioningStatus === 1 ? '有效' : '无效'}</div>
</CardContent>
</Card>
)}
</div>
{/* 图表区域 */}
@ -439,6 +554,49 @@ const RealTimeMonitoring: React.FC = () => {
</Card>
</div>
{/* 子系统电压/温度最新帧展示 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Zap className="h-5 w-5 text-blue-600" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={(subsystemVoltLatest?.batteryVoltages || []).map((v, i) => ({ index: i + 1, value: v }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="index" />
<YAxis />
<Tooltip formatter={(value: number) => [`${Number(value).toFixed(3)}V`, '电压']} />
<Bar dataKey="value" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Thermometer className="h-5 w-5 text-red-600" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={(subsystemTempLatest?.temperatureValues || []).map((v, i) => ({ index: i + 1, value: v }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="index" />
<YAxis />
<Tooltip formatter={(value: number) => [`${Number(value).toFixed(2)}`, '温度']} />
<Line type="monotone" dataKey="value" stroke="#ef4444" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* 设备信息 */}
<Card>
<CardHeader>
@ -448,19 +606,19 @@ const RealTimeMonitoring: React.FC = () => {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-sm text-gray-600"></div>
<div className="font-medium">{selectedDevice.device_name}</div>
<div className="font-medium">{selectedDevice.deviceName}</div>
</div>
<div>
<div className="text-sm text-gray-600">ID</div>
<div className="font-medium font-mono">{selectedDevice.device_id}</div>
<div className="font-medium font-mono">{selectedDevice.id}</div>
</div>
<div>
<div className="text-sm text-gray-600">IP地址</div>
<div className="font-medium font-mono">{selectedDevice.ip_address || '未设置'}</div>
<div className="font-medium font-mono">{selectedDevice.ipAddress || '未设置'}</div>
</div>
<div>
<div className="text-sm text-gray-600"></div>
<div className="font-medium font-mono">{selectedDevice.firmware_version || '未知'}</div>
<div className="font-medium font-mono">{selectedDevice.firmwareVersion || '未知'}</div>
</div>
</div>
</CardContent>

View File

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

View File

@ -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: <Dashboard />
},
{
name: '登录',
path: '/login',
element: <Login />,
visible: false
},
{
name: '设备管理',
path: '/devices',

View File

@ -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;
}
@ -133,3 +158,104 @@ export interface CreateOtaTaskRequest {
export interface UpdateConfigRequest {
config_value: string;
}
/**
* extreme_values
* VUTC 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 ASOC %
*/
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; // 绝缘电阻
reservedData?: string; // 预留字段
createdAt?: string;
updatedAt?: string;
}
/**
* subsystem_voltage
* VAbatteryVoltages 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;
}

View File

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

197
vite.config.dev.ts Normal file
View File

@ -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<any | null> {
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(),
],
},
}
});

View File

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