refactor: H5和WEB前端分离,H5独立项目,扫码路径改为/h5/
This commit is contained in:
parent
60d04cd2bb
commit
9b5789fb75
@ -11,7 +11,7 @@
|
|||||||
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
|
- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
|
||||||
- **设备ID**:4G SIM卡15位IMEI(10进制)
|
- **设备ID**:4G SIM卡15位IMEI(10进制)
|
||||||
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
|
- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
|
||||||
- **扫码链接**:`https://app.anzhizhichong.com/pms/{抽象ID}`
|
- **扫码链接**:`https://app.anzhizhichong.com/h5/{抽象ID}`
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@ -44,4 +44,15 @@ server {
|
|||||||
alias /data/pms/web/;
|
alias /data/pms/web/;
|
||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# H5用户端 - 不带斜杠时重定向
|
||||||
|
location = /h5 {
|
||||||
|
return 301 /h5/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# H5用户端
|
||||||
|
location /h5/ {
|
||||||
|
alias /data/pms/h5/;
|
||||||
|
try_files $uri $uri/ /h5/index.html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
software/h5/index.html
Normal file
13
software/h5/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/h5/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>安知充</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
software/h5/package.json
Normal file
27
software/h5/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "pms-h5",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
software/h5/src/App.tsx
Normal file
71
software/h5/src/App.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* H5 用户端根组件
|
||||||
|
*/
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useH5AuthStore } from './auth';
|
||||||
|
import H5Layout from './layout';
|
||||||
|
import H5Login from './pages/login';
|
||||||
|
import H5Home from './pages/home';
|
||||||
|
import H5Devices from './pages/devices';
|
||||||
|
import H5DeviceDetail from './pages/device-detail';
|
||||||
|
import H5CompartmentDetail from './pages/compartment-detail';
|
||||||
|
import H5Me from './pages/me';
|
||||||
|
import { Loading } from './components';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { h5Api } from './api';
|
||||||
|
|
||||||
|
/** 扫码入口处理 */
|
||||||
|
function ScanRedirect() {
|
||||||
|
const { abstractId } = useParams<{ abstractId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!abstractId || ['login', 'devices', 'me', 'compartments'].includes(abstractId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h5Api.getCabinetByAbstractId(abstractId)
|
||||||
|
.then((res) => navigate(`/devices/${res.data.id}`, { replace: true }))
|
||||||
|
.catch(() => setError('未找到该设备'));
|
||||||
|
}, [abstractId, navigate]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-white">
|
||||||
|
<p className="text-sm text-gray-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-white">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const restore = useH5AuthStore((s) => s.restore);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore();
|
||||||
|
}, [restore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<H5Login />} />
|
||||||
|
<Route element={<H5Layout />}>
|
||||||
|
<Route index element={<H5Home />} />
|
||||||
|
<Route path="devices" element={<H5Devices />} />
|
||||||
|
<Route path="devices/:id" element={<H5DeviceDetail />} />
|
||||||
|
<Route path="me" element={<H5Me />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="compartments/:id" element={<H5CompartmentDetail />} />
|
||||||
|
<Route path=":abstractId" element={<ScanRedirect />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
software/h5/src/api/devices.ts
Normal file
90
software/h5/src/api/devices.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 设备管理 API
|
||||||
|
* 组织/项目/柜子 CRUD + 树状结构
|
||||||
|
*/
|
||||||
|
import { api } from './request';
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
organization_id?: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cabinet {
|
||||||
|
id: number;
|
||||||
|
project_id?: number;
|
||||||
|
abstract_id: string;
|
||||||
|
imei: string;
|
||||||
|
auth_str?: string;
|
||||||
|
name?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Compartment {
|
||||||
|
id: number;
|
||||||
|
cabin_board_id: number;
|
||||||
|
channel_id: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinBoard {
|
||||||
|
id: number;
|
||||||
|
board_id: number;
|
||||||
|
status: number;
|
||||||
|
compartments: Compartment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinetDetail extends Cabinet {
|
||||||
|
cabin_boards: CabinBoard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
children?: TreeNode[];
|
||||||
|
abstract_id?: string;
|
||||||
|
imei?: string;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组织 API
|
||||||
|
export const orgApi = {
|
||||||
|
list: () => api.get<Organization[]>('/organizations'),
|
||||||
|
create: (data: { name: string }) => api.post<Organization>('/organizations', data),
|
||||||
|
update: (id: number, data: { name: string }) => api.put(`/organizations/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/organizations/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 项目 API
|
||||||
|
export const projectApi = {
|
||||||
|
list: (orgId: number) => api.get<Project[]>(`/organizations/${orgId}/projects`),
|
||||||
|
create: (orgId: number, data: { name: string }) =>
|
||||||
|
api.post(`/organizations/${orgId}/projects`, data),
|
||||||
|
update: (id: number, data: { name: string }) => api.put(`/projects/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/projects/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 柜子 API
|
||||||
|
export const cabinetApi = {
|
||||||
|
list: (projectId: number) => api.get<Cabinet[]>(`/projects/${projectId}/cabinets`),
|
||||||
|
create: (data: { imeis: string[]; project_id: number }) =>
|
||||||
|
api.post<{ ids: number[] }>('/cabinets', data),
|
||||||
|
update: (id: number, data: { project_id?: number; name?: string }) =>
|
||||||
|
api.put(`/cabinets/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/cabinets/${id}`),
|
||||||
|
regenerateAuth: (id: number) => api.post<{ auth_str: string }>(`/cabinets/${id}/regenerate-auth`),
|
||||||
|
getDetail: (id: number) => api.get<CabinetDetail>(`/cabinets/${id}`),
|
||||||
|
// 接触器控制
|
||||||
|
powOn: (id: number) => api.post(`/cabinets/${id}/pow-on`),
|
||||||
|
powOff: (id: number) => api.post(`/cabinets/${id}/pow-off`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组织树 API
|
||||||
|
export const treeApi = {
|
||||||
|
get: () => api.get<TreeNode[]>('/organization-tree'),
|
||||||
|
};
|
||||||
105
software/h5/src/api/permission.ts
Normal file
105
software/h5/src/api/permission.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 权限管理 API
|
||||||
|
*
|
||||||
|
* 角色管理、权限码查询、用户权限配置等接口封装
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@/api/request';
|
||||||
|
|
||||||
|
/** 角色信息 */
|
||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permission_codes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限码信息 */
|
||||||
|
export interface PermissionItem {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户权限范围条目 */
|
||||||
|
export interface ScopeEntry {
|
||||||
|
id?: number;
|
||||||
|
/** 1组织 2项目 3设备 */
|
||||||
|
scope_type: number;
|
||||||
|
/** 对应的组织/项目/设备ID */
|
||||||
|
scope_id: number;
|
||||||
|
/** 1查看 2操作 */
|
||||||
|
permission_type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户权限详情 */
|
||||||
|
export interface UserPermissionDetail {
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
role_level: number;
|
||||||
|
organization_id: number | null;
|
||||||
|
roles: Role[];
|
||||||
|
permission_codes: string[];
|
||||||
|
scopes: ScopeEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 角色管理 ──
|
||||||
|
|
||||||
|
/** 获取所有角色列表 */
|
||||||
|
export async function fetchRoles(): Promise<Role[]> {
|
||||||
|
const res = await api.get<Role[]>('/roles');
|
||||||
|
return res.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建角色 */
|
||||||
|
export async function createRole(name: string, description?: string) {
|
||||||
|
return api.post('/roles', { name, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新角色 */
|
||||||
|
export async function updateRole(id: number, name: string, description?: string) {
|
||||||
|
return api.put(`/roles/${id}`, { name, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除角色 */
|
||||||
|
export async function deleteRole(id: number) {
|
||||||
|
return api.delete(`/roles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置角色权限(全量替换) */
|
||||||
|
export async function setRolePermissions(roleId: number, permissionCodes: string[]) {
|
||||||
|
return api.put(`/roles/${roleId}/permissions`, { permission_codes: permissionCodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 权限码 ──
|
||||||
|
|
||||||
|
/** 获取所有权限码列表 */
|
||||||
|
export async function fetchPermissions(): Promise<PermissionItem[]> {
|
||||||
|
const res = await api.get<PermissionItem[]>('/permissions');
|
||||||
|
return res.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 用户权限 ──
|
||||||
|
|
||||||
|
/** 获取用户权限详情 */
|
||||||
|
export async function fetchUserPermissions(userId: number): Promise<UserPermissionDetail> {
|
||||||
|
const res = await api.get<UserPermissionDetail>(`/users/${userId}/permissions`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置用户角色(全量替换) */
|
||||||
|
export async function setUserRoles(userId: number, roleIds: number[]) {
|
||||||
|
return api.put(`/users/${userId}/roles`, { role_ids: roleIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置用户权限范围(全量替换) */
|
||||||
|
export async function setUserScopes(userId: number, scopes: ScopeEntry[]) {
|
||||||
|
return api.put(`/users/${userId}/scopes`, { scopes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户权限范围 */
|
||||||
|
export async function fetchUserScopes(userId: number): Promise<ScopeEntry[]> {
|
||||||
|
const res = await api.get<ScopeEntry[]>(`/users/${userId}/scopes`);
|
||||||
|
return res.data ?? [];
|
||||||
|
}
|
||||||
72
software/h5/src/api/request.ts
Normal file
72
software/h5/src/api/request.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* HTTP 请求封装
|
||||||
|
* - 自动注入 Authorization token
|
||||||
|
* - 统一异常处理
|
||||||
|
* - 类型友好的响应
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || '/pms/api';
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public code: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new ApiError(res.status, body.message || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
throw new ApiError(0, (err as Error).message || '网络错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string) => request<T>(url),
|
||||||
|
post: <T>(url: string, data?: unknown) =>
|
||||||
|
request<T>(url, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
put: <T>(url: string, data?: unknown) =>
|
||||||
|
request<T>(url, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
121
software/h5/src/api/user.ts
Normal file
121
software/h5/src/api/user.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 用户管理和操作日志 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@/api/request';
|
||||||
|
|
||||||
|
// ── 用户管理 ──
|
||||||
|
|
||||||
|
/** 用户信息 */
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
role: number;
|
||||||
|
role_text: string;
|
||||||
|
organization_id: number | null;
|
||||||
|
organization_name: string | null;
|
||||||
|
status: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户列表响应 */
|
||||||
|
export interface UserListData {
|
||||||
|
list: User[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建用户参数 */
|
||||||
|
export interface CreateUserParams {
|
||||||
|
phone: string;
|
||||||
|
name?: string;
|
||||||
|
role?: number;
|
||||||
|
organization_id?: number | null;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑用户参数 */
|
||||||
|
export interface UpdateUserParams {
|
||||||
|
name?: string;
|
||||||
|
role?: number;
|
||||||
|
organization_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户列表(分页) */
|
||||||
|
export async function fetchUsers(params: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}): Promise<UserListData> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.page) searchParams.set('page', String(params.page));
|
||||||
|
if (params.page_size) searchParams.set('page_size', String(params.page_size));
|
||||||
|
if (params.keyword) searchParams.set('keyword', params.keyword);
|
||||||
|
const res = await api.get<UserListData>(`/users?${searchParams.toString()}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建用户 */
|
||||||
|
export async function createUser(data: CreateUserParams) {
|
||||||
|
return api.post('/users', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑用户 */
|
||||||
|
export async function updateUser(id: number, data: UpdateUserParams) {
|
||||||
|
return api.put(`/users/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置密码 */
|
||||||
|
export async function resetPassword(id: number, password?: string) {
|
||||||
|
return api.put(`/users/${id}/reset-password`, { password });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用/禁用用户 */
|
||||||
|
export async function setUserStatus(id: number, status: number) {
|
||||||
|
return api.put(`/users/${id}/status`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除用户 */
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
return api.delete(`/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 操作日志 ──
|
||||||
|
|
||||||
|
/** 操作日志项 */
|
||||||
|
export interface OperationLog {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
action: string;
|
||||||
|
target_type: string | null;
|
||||||
|
target_id: number | null;
|
||||||
|
detail: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志列表响应 */
|
||||||
|
export interface OperationLogListData {
|
||||||
|
list: OperationLog[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志列表(分页+过滤) */
|
||||||
|
export async function fetchOperationLogs(params: {
|
||||||
|
user_id?: number;
|
||||||
|
action?: string;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<OperationLogListData> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.user_id) searchParams.set('user_id', String(params.user_id));
|
||||||
|
if (params.action) searchParams.set('action', params.action);
|
||||||
|
if (params.start_time) searchParams.set('start_time', params.start_time);
|
||||||
|
if (params.end_time) searchParams.set('end_time', params.end_time);
|
||||||
|
if (params.page) searchParams.set('page', String(params.page));
|
||||||
|
if (params.page_size) searchParams.set('page_size', String(params.page_size));
|
||||||
|
const res = await api.get<OperationLogListData>(`/operation-logs?${searchParams.toString()}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
154
software/h5/src/h5/api.ts
Normal file
154
software/h5/src/h5/api.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* H5 用户端 API 服务
|
||||||
|
* 封装所有 H5 端接口调用
|
||||||
|
*/
|
||||||
|
import { api } from '@/api/request';
|
||||||
|
|
||||||
|
// ─── 数据类型 ───
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
online_cabinets: number;
|
||||||
|
charging_count: number;
|
||||||
|
idle_channels: number;
|
||||||
|
fault_channels: number;
|
||||||
|
today_charge_count: number;
|
||||||
|
today_charge_hours: number;
|
||||||
|
today_energy_kwh: number;
|
||||||
|
alerts: AlertItem[];
|
||||||
|
projects: ProjectSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
project: string;
|
||||||
|
cabinet: string;
|
||||||
|
channel: number;
|
||||||
|
alert_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cabinet_count: number;
|
||||||
|
charging: number;
|
||||||
|
idle: number;
|
||||||
|
fault: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5Project {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5Cabinet {
|
||||||
|
id: number;
|
||||||
|
abstract_id: string;
|
||||||
|
name?: string;
|
||||||
|
status: number; // 0=离线 1=在线
|
||||||
|
project_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompartmentStatus {
|
||||||
|
id: number;
|
||||||
|
channel_id: number;
|
||||||
|
status: number; // 0=空闲 1=充电中 2=故障 3=离线
|
||||||
|
voltage?: number;
|
||||||
|
current?: number;
|
||||||
|
power?: number;
|
||||||
|
energy?: number;
|
||||||
|
soc?: number; // 荷电状态 %
|
||||||
|
soh?: number; // 健康度 %
|
||||||
|
cell_voltages?: number[];
|
||||||
|
cell_temps?: number[];
|
||||||
|
bms_alerts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinBoardStatus {
|
||||||
|
id: number;
|
||||||
|
board_id: number;
|
||||||
|
status: number;
|
||||||
|
compartments: CompartmentStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabinetDetail {
|
||||||
|
id: number;
|
||||||
|
abstract_id: string;
|
||||||
|
imei: string;
|
||||||
|
name?: string;
|
||||||
|
status: number;
|
||||||
|
cabin_boards: CabinBoardStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompartmentDetail {
|
||||||
|
id: number;
|
||||||
|
channel_id: number;
|
||||||
|
status: number;
|
||||||
|
voltage: number;
|
||||||
|
current: number;
|
||||||
|
power: number;
|
||||||
|
energy: number;
|
||||||
|
soc: number;
|
||||||
|
soh: number;
|
||||||
|
cell_voltages: number[];
|
||||||
|
cell_temps: number[];
|
||||||
|
bms_alerts: string[];
|
||||||
|
charge_records: ChargeRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeRecord {
|
||||||
|
id: number;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
energy_kwh: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5User {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5LoginResponse {
|
||||||
|
token: string;
|
||||||
|
user: H5User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API 函数 ───
|
||||||
|
|
||||||
|
export const h5Api = {
|
||||||
|
// 认证
|
||||||
|
login: (data: { phone: string; password: string }) =>
|
||||||
|
api.post<H5LoginResponse>('/h5/auth/login', data),
|
||||||
|
|
||||||
|
// 首页仪表盘
|
||||||
|
getDashboard: () => api.get<DashboardData>('/h5/dashboard'),
|
||||||
|
|
||||||
|
// 项目列表
|
||||||
|
getProjects: () => api.get<H5Project[]>('/h5/projects'),
|
||||||
|
|
||||||
|
// 设备列表(按项目)
|
||||||
|
getCabinets: (projectId?: number) =>
|
||||||
|
api.get<H5Cabinet[]>(`/h5/cabinets${projectId ? `?project_id=${projectId}` : ''}`),
|
||||||
|
|
||||||
|
// 设备详情(含仓板+仓体状态)
|
||||||
|
getCabinetDetail: (id: number) =>
|
||||||
|
api.get<CabinetDetail>(`/h5/cabinets/${id}`),
|
||||||
|
|
||||||
|
// 通过 abstract_id 获取设备详情(扫码)
|
||||||
|
getCabinetByAbstractId: (abstractId: string) =>
|
||||||
|
api.get<CabinetDetail>(`/h5/cabinets/abstract/${abstractId}`),
|
||||||
|
|
||||||
|
// 仓体/通道详情
|
||||||
|
getCompartmentDetail: (id: number) =>
|
||||||
|
api.get<CompartmentDetail>(`/h5/compartments/${id}`),
|
||||||
|
|
||||||
|
// 充电控制
|
||||||
|
startCharge: (compartmentId: number) =>
|
||||||
|
api.post<{ success: boolean }>('/h5/charge/start', { compartment_id: compartmentId }),
|
||||||
|
stopCharge: (compartmentId: number) =>
|
||||||
|
api.post<{ success: boolean }>('/h5/charge/stop', { compartment_id: compartmentId }),
|
||||||
|
|
||||||
|
// 开门
|
||||||
|
openDoor: (compartmentId: number) =>
|
||||||
|
api.post<{ success: boolean }>('/h5/door/open', { compartment_id: compartmentId }),
|
||||||
|
};
|
||||||
56
software/h5/src/h5/auth.ts
Normal file
56
software/h5/src/h5/auth.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* H5 用户端认证状态管理
|
||||||
|
* - 与后台管理独立,使用不同 token
|
||||||
|
* - localStorage key 使用 h5_ 前缀避免冲突
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { h5Api, type H5User } from './api';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'h5_token';
|
||||||
|
const USER_KEY = 'h5_user';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
user: H5User | null;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (phone: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
restore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useH5AuthStore = create<AuthState>((set) => ({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
login: async (phone: string, password: string) => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await h5Api.login({ phone, password });
|
||||||
|
const { token, user } = res.data;
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
set({ token, user, isAuthenticated: true, loading: false });
|
||||||
|
} catch {
|
||||||
|
set({ loading: false });
|
||||||
|
throw new Error('登录失败,请检查手机号和密码');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
set({ token: null, user: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
restore: () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const raw = localStorage.getItem(USER_KEY);
|
||||||
|
if (token) {
|
||||||
|
const user = raw ? JSON.parse(raw) as H5User : null;
|
||||||
|
set({ token, user, isAuthenticated: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
249
software/h5/src/h5/components.tsx
Normal file
249
software/h5/src/h5/components.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* H5 共享组件:底部导航、统计卡片、通道卡片、加载/错误状态
|
||||||
|
*/
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
// ─── 底部导航 ───
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: '/pms', label: '首页', icon: HomeIcon },
|
||||||
|
{ key: '/pms/devices', label: '设备', icon: DeviceIcon },
|
||||||
|
{ key: '/pms/me', label: '我的', icon: MeIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const activeKey = '/' + location.pathname.split('/').slice(0, 3).join('/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-50 flex h-14 items-center justify-around border-t border-gray-200 bg-white pb-safe">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeKey === tab.key ||
|
||||||
|
(tab.key === '/pms' && location.pathname === '/pms');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-0.5 py-1"
|
||||||
|
onClick={() => navigate(tab.key)}
|
||||||
|
>
|
||||||
|
<tab.icon active={isActive} />
|
||||||
|
<span className={`text-xs ${isActive ? 'font-semibold text-blue-600' : 'text-gray-500'}`}>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeIcon({ active }: { active: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill={active ? '#2563eb' : '#9ca3af'}>
|
||||||
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceIcon({ active }: { active: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill={active ? '#2563eb' : '#9ca3af'}>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeIcon({ active }: { active: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill={active ? '#2563eb' : '#9ca3af'}>
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 统计卡片 ───
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
sub?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ label, value, sub, color = 'blue' }: StatCardProps) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
blue: 'bg-blue-50 text-blue-700',
|
||||||
|
green: 'bg-green-50 text-green-700',
|
||||||
|
orange: 'bg-orange-50 text-orange-700',
|
||||||
|
red: 'bg-red-50 text-red-700',
|
||||||
|
gray: 'bg-gray-50 text-gray-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center justify-center rounded-xl p-3 ${colors[color] || colors.blue}`}>
|
||||||
|
<span className="text-2xl font-bold">{value}</span>
|
||||||
|
<span className="mt-0.5 text-xs">{label}</span>
|
||||||
|
{sub && <span className="mt-0.5 text-[10px] opacity-70">{sub}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 告警项 ───
|
||||||
|
|
||||||
|
interface AlertCardProps {
|
||||||
|
project: string;
|
||||||
|
cabinet: string;
|
||||||
|
channel: number;
|
||||||
|
alert_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertCard({ project, cabinet, channel, alert_type }: AlertCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm">
|
||||||
|
<span className="mt-0.5 text-base">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-red-700">{project}</span>
|
||||||
|
<span className="text-red-600"> - {cabinet} 通道{channel}</span>
|
||||||
|
<p className="mt-0.5 text-red-500">{alert_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 项目卡片 ───
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
name: string;
|
||||||
|
cabinet_count: number;
|
||||||
|
charging: number;
|
||||||
|
idle: number;
|
||||||
|
fault: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCard({ name, cabinet_count, charging, idle, fault, onClick }: ProjectCardProps) {
|
||||||
|
return (
|
||||||
|
<button className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left active:bg-gray-50" onClick={onClick}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-base font-semibold text-gray-900">{name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{cabinet_count}柜</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-3 text-xs">
|
||||||
|
<span className="text-green-600">充电{charging}</span>
|
||||||
|
<span className="text-gray-500">空闲{idle}</span>
|
||||||
|
{fault > 0 && <span className="text-red-600">故障{fault}</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 通道卡片 ───
|
||||||
|
|
||||||
|
interface ChannelCardProps {
|
||||||
|
channelId: number;
|
||||||
|
status: number; // 0=空闲 1=充电中 2=故障 3=离线
|
||||||
|
voltage?: number;
|
||||||
|
current?: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<number, { label: string; bg: string; dot: string }> = {
|
||||||
|
0: { label: '空闲', bg: 'bg-gray-50', dot: 'bg-gray-400' },
|
||||||
|
1: { label: '充电中', bg: 'bg-green-50', dot: 'bg-green-500' },
|
||||||
|
2: { label: '故障', bg: 'bg-red-50', dot: 'bg-red-500' },
|
||||||
|
3: { label: '离线', bg: 'bg-gray-100', dot: 'bg-gray-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelCard({ channelId, status, voltage, current, onClick }: ChannelCardProps) {
|
||||||
|
const cfg = statusConfig[status] || statusConfig[3];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`flex flex-col items-center rounded-lg p-2.5 ${cfg.bg} active:opacity-70`} onClick={onClick}>
|
||||||
|
<span className="text-xs font-medium text-gray-700">通道{channelId}</span>
|
||||||
|
<span className={`mt-1 inline-block h-2 w-2 rounded-full ${cfg.dot}`} />
|
||||||
|
<span className="mt-0.5 text-[10px] text-gray-500">{cfg.label}</span>
|
||||||
|
{voltage !== undefined && (
|
||||||
|
<span className="mt-0.5 text-[10px] text-gray-400">{voltage.toFixed(1)}V</span>
|
||||||
|
)}
|
||||||
|
{current !== undefined && (
|
||||||
|
<span className="text-[10px] text-gray-400">{current.toFixed(1)}A</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 加载状态 ───
|
||||||
|
|
||||||
|
export function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||||
|
<span className="ml-2 text-sm text-gray-500">加载中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 错误状态 ───
|
||||||
|
|
||||||
|
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<span className="text-4xl">😵</span>
|
||||||
|
<p className="text-sm text-gray-500">{message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 页面容器 ───
|
||||||
|
|
||||||
|
export function PageContainer({ children, title, onBack }: { children: ReactNode; title?: string; onBack?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-gray-50 pb-16">
|
||||||
|
{title && (
|
||||||
|
<header className="flex items-center gap-2 border-b border-gray-200 bg-white px-4 py-3">
|
||||||
|
{onBack && (
|
||||||
|
<button className="text-lg text-gray-600" onClick={onBack}>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h1 className="text-base font-semibold text-gray-900">{title}</h1>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 px-4 py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 确认弹窗 ───
|
||||||
|
|
||||||
|
export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
|
||||||
|
<div className="mx-4 w-full max-w-xs rounded-xl bg-white p-5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-center text-base font-semibold text-gray-900">{title}</h3>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-500">{message}</p>
|
||||||
|
<div className="mt-5 flex gap-3">
|
||||||
|
<button className="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700" onClick={onCancel}>取消</button>
|
||||||
|
<button className="flex-1 rounded-lg bg-blue-600 py-2 text-sm text-white" onClick={onConfirm}>确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
software/h5/src/h5/layout.tsx
Normal file
36
software/h5/src/h5/layout.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* H5 移动端布局
|
||||||
|
* - 底部 Tab 导航(首页/设备/我的)
|
||||||
|
* - 使用 Outlet 渲染子页面
|
||||||
|
*/
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useH5AuthStore } from './auth';
|
||||||
|
import { BottomNav } from './components';
|
||||||
|
|
||||||
|
export default function H5Layout() {
|
||||||
|
const { isAuthenticated } = useH5AuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 未认证时重定向到登录页,保留目标地址
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to={`/pms/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h5-layout mx-auto max-w-md">
|
||||||
|
<div className="min-h-screen pb-14">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不带底部导航的 H5 布局(登录页、通道详情等全屏页面) */
|
||||||
|
export function H5PlainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="h5-layout mx-auto max-w-md">
|
||||||
|
<div className="min-h-screen">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
software/h5/src/h5/pages/compartment-detail.tsx
Normal file
253
software/h5/src/h5/pages/compartment-detail.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* H5 通道详情
|
||||||
|
* - 实时状态(SOC/SOH)
|
||||||
|
* - 电气参数(电压/电流/功率/能耗)
|
||||||
|
* - BMS数据(单体电压/温度/告警)
|
||||||
|
* - 充电曲线(示意)
|
||||||
|
* - 操作按钮(停止充电、开门)
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { h5Api, type CompartmentDetail } from '../api';
|
||||||
|
import { Loading, ErrorState, PageContainer, ConfirmDialog } from '../components';
|
||||||
|
|
||||||
|
export default function H5CompartmentDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<CompartmentDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [actionLoading, setActionLoading] = useState('');
|
||||||
|
const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null);
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await h5Api.getCompartmentDetail(Number(id));
|
||||||
|
setData(res.data);
|
||||||
|
} catch {
|
||||||
|
setError('加载通道详情失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDetail();
|
||||||
|
// ponytail: 15s auto-refresh during charging, add WebSocket for real-time
|
||||||
|
const timer = setInterval(fetchDetail, 15000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleAction = async (action: 'start' | 'stop' | 'door') => {
|
||||||
|
if (!id || actionLoading) return;
|
||||||
|
setActionLoading(action);
|
||||||
|
try {
|
||||||
|
if (action === 'start') {
|
||||||
|
await h5Api.startCharge(Number(id));
|
||||||
|
} else if (action === 'stop') {
|
||||||
|
await h5Api.stopCharge(Number(id));
|
||||||
|
} else if (action === 'door') {
|
||||||
|
await h5Api.openDoor(Number(id));
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
fetchDetail();
|
||||||
|
} catch {
|
||||||
|
setError('操作失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setActionLoading('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <PageContainer title="通道详情"><Loading /></PageContainer>;
|
||||||
|
if (error && !data) return <PageContainer title="通道详情"><ErrorState message={error} onRetry={fetchDetail} /></PageContainer>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const statusLabel: Record<number, string> = { 0: '空闲', 1: '充电中', 2: '故障', 3: '离线' };
|
||||||
|
const isCharging = data.status === 1;
|
||||||
|
const isIdle = data.status === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer title={`通道${data.channel_id}`} onBack={() => navigate(-1)}>
|
||||||
|
{/* 实时状态 */}
|
||||||
|
<section className="mb-4 rounded-xl bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">实时状态</h3>
|
||||||
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
data.status === 1 ? 'bg-green-100 text-green-700' :
|
||||||
|
data.status === 0 ? 'bg-gray-100 text-gray-500' :
|
||||||
|
'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{statusLabel[data.status] || '未知'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-blue-600">{data.soc ?? '-'}%</p>
|
||||||
|
<p className="text-xs text-gray-500">SOC</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-blue-600">{data.soh ?? '-'}%</p>
|
||||||
|
<p className="text-xs text-gray-500">SOH</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-blue-600">{data.energy?.toFixed(1) ?? '-'}</p>
|
||||||
|
<p className="text-xs text-gray-500">能耗(kWh)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 电气参数 */}
|
||||||
|
<section className="mb-4 rounded-xl bg-white p-4">
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-900">电气参数</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-y-3 text-sm">
|
||||||
|
<div className="flex justify-between pr-4">
|
||||||
|
<span className="text-gray-500">电压</span>
|
||||||
|
<span className="font-medium text-gray-900">{data.voltage?.toFixed(1) ?? '-'} V</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pl-4">
|
||||||
|
<span className="text-gray-500">电流</span>
|
||||||
|
<span className="font-medium text-gray-900">{data.current?.toFixed(1) ?? '-'} A</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pr-4">
|
||||||
|
<span className="text-gray-500">功率</span>
|
||||||
|
<span className="font-medium text-gray-900">{data.power?.toFixed(1) ?? '-'} W</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pl-4">
|
||||||
|
<span className="text-gray-500">能耗</span>
|
||||||
|
<span className="font-medium text-gray-900">{data.energy?.toFixed(3) ?? '-'} kWh</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* BMS 数据 */}
|
||||||
|
{data.cell_voltages && data.cell_voltages.length > 0 && (
|
||||||
|
<section className="mb-4 rounded-xl bg-white p-4">
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-900">BMS 数据</h3>
|
||||||
|
{/* 单体电压 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="mb-1 text-xs text-gray-500">单体电压 (V)</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{data.cell_voltages.map((v, i) => (
|
||||||
|
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
|
||||||
|
#{i + 1}: {v.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 单体温度 */}
|
||||||
|
{data.cell_temps && data.cell_temps.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="mb-1 text-xs text-gray-500">温度 (°C)</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{data.cell_temps.map((t, i) => (
|
||||||
|
<span key={i} className={`rounded px-2 py-0.5 text-xs ${t > 50 ? 'bg-red-50 text-red-700' : 'bg-orange-50 text-orange-700'}`}>
|
||||||
|
#{i + 1}: {t.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* BMS 告警 */}
|
||||||
|
{data.bms_alerts && data.bms_alerts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs text-red-500">告警</p>
|
||||||
|
<ul className="list-inside list-disc text-xs text-red-600">
|
||||||
|
{data.bms_alerts.map((alert, i) => (
|
||||||
|
<li key={i}>{alert}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 充电曲线示意 */}
|
||||||
|
{isCharging && (
|
||||||
|
<section className="mb-4 rounded-xl bg-white p-4">
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-900">充电曲线</h3>
|
||||||
|
{/* TODO: 待后端提供充电曲线API后替换为真实数据 */}
|
||||||
|
<div className="flex h-24 items-end gap-1">
|
||||||
|
{[30, 45, 55, 50, 65, 70, 75, 72, 80, 85, 82, 88].map((h, i) => (
|
||||||
|
<div key={i} className="flex-1 rounded-t bg-blue-500" style={{ height: `${h}%` }} title={`${h}%`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between text-[10px] text-gray-400">
|
||||||
|
<span>12:00</span>
|
||||||
|
<span>当前</span>
|
||||||
|
</div>
|
||||||
|
{/* ponytail: static mock chart, replace with real chart lib when backend provides data */}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 充电记录 */}
|
||||||
|
{data.charge_records && data.charge_records.length > 0 && (
|
||||||
|
<section className="mb-4 rounded-xl bg-white p-4">
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-900">充电记录</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{data.charge_records.slice(0, 5).map((record) => (
|
||||||
|
<div key={record.id} className="flex items-center justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-900">{record.start_time.slice(0, 16)}</p>
|
||||||
|
{record.end_time && <p className="text-xs text-gray-400">{record.end_time.slice(0, 16)}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-gray-900">{record.energy_kwh.toFixed(2)} kWh</p>
|
||||||
|
<p className="text-xs text-gray-400">¥{record.cost.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex flex-col gap-3 pb-4">
|
||||||
|
{isIdle && (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-lg bg-green-600 py-3 text-base font-medium text-white disabled:opacity-50"
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
onClick={() => handleAction('start')}
|
||||||
|
>
|
||||||
|
{actionLoading === 'start' ? '操作中...' : '开始充电'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isCharging && (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-lg bg-orange-500 py-3 text-base font-medium text-white disabled:opacity-50"
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
onClick={() => setConfirmAction('stop')}
|
||||||
|
>
|
||||||
|
{actionLoading === 'stop' ? '操作中...' : '停止充电'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white py-3 text-base font-medium text-gray-700 disabled:opacity-50"
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
onClick={() => setConfirmAction('door')}
|
||||||
|
>
|
||||||
|
{actionLoading === 'door' ? '操作中...' : '开门'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认弹窗 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmAction === 'stop'}
|
||||||
|
title="停止充电"
|
||||||
|
message="确定要停止当前充电?"
|
||||||
|
onConfirm={() => handleAction('stop')}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmAction === 'door'}
|
||||||
|
title="开门"
|
||||||
|
message="确定要打开仓门?"
|
||||||
|
onConfirm={() => handleAction('door')}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
software/h5/src/h5/pages/device-detail.tsx
Normal file
84
software/h5/src/h5/pages/device-detail.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* H5 设备详情(柜子视图)
|
||||||
|
* - 柜子基本信息
|
||||||
|
* - 仓板列表,每板6个通道
|
||||||
|
* - 点击通道进入通道详情
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { h5Api, type CabinetDetail } from '../api';
|
||||||
|
import { ChannelCard, Loading, ErrorState, PageContainer } from '../components';
|
||||||
|
|
||||||
|
export default function H5DeviceDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<CabinetDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await h5Api.getCabinetDetail(Number(id));
|
||||||
|
setData(res.data);
|
||||||
|
} catch {
|
||||||
|
setError('加载设备详情失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDetail();
|
||||||
|
// ponytail: no periodic refresh, add if real-time needed
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <PageContainer title="设备详情"><Loading /></PageContainer>;
|
||||||
|
if (error) return <PageContainer title="设备详情"><ErrorState message={error} onRetry={fetchDetail} /></PageContainer>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer title={data.name || data.abstract_id} onBack={() => navigate('/pms/devices')}>
|
||||||
|
{/* 设备基本信息 */}
|
||||||
|
<div className="mb-4 rounded-xl bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold text-gray-900">{data.name || data.abstract_id}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">ID: {data.abstract_id}</p>
|
||||||
|
{data.imei && <p className="text-xs text-gray-400">IMEI: {data.imei}</p>}
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${data.status === 1 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{data.status === 1 ? '在线' : '离线'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仓板列表 */}
|
||||||
|
{data.cabin_boards.map((board) => (
|
||||||
|
<section key={board.id} className="mb-4">
|
||||||
|
<h3 className="mb-2 text-sm font-medium text-gray-700">
|
||||||
|
仓板 {board.board_id}
|
||||||
|
<span className={`ml-2 text-xs ${board.status === 1 ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
({board.status === 1 ? '在线' : '离线'})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{board.compartments.map((comp) => (
|
||||||
|
<ChannelCard
|
||||||
|
key={comp.id}
|
||||||
|
channelId={comp.channel_id}
|
||||||
|
status={comp.status}
|
||||||
|
voltage={comp.voltage}
|
||||||
|
current={comp.current}
|
||||||
|
onClick={() => navigate(`/pms/compartments/${comp.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
software/h5/src/h5/pages/devices.tsx
Normal file
129
software/h5/src/h5/pages/devices.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* H5 设备页
|
||||||
|
* - 项目列表 → 选择项目后显示设备列表
|
||||||
|
* - 点击设备进入设备详情
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { h5Api, type H5Project, type H5Cabinet } from '../api';
|
||||||
|
import { Loading, ErrorState, PageContainer } from '../components';
|
||||||
|
|
||||||
|
type ViewMode = 'projects' | 'cabinets';
|
||||||
|
|
||||||
|
export default function H5Devices() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const projectId = searchParams.get('project_id');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<ViewMode>(projectId ? 'cabinets' : 'projects');
|
||||||
|
const [projects, setProjects] = useState<H5Project[]>([]);
|
||||||
|
const [cabinets, setCabinets] = useState<H5Cabinet[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<H5Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await h5Api.getProjects();
|
||||||
|
setProjects(res.data);
|
||||||
|
} catch {
|
||||||
|
setError('加载项目列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCabinets = async (pid: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await h5Api.getCabinets(pid);
|
||||||
|
setCabinets(res.data);
|
||||||
|
} catch {
|
||||||
|
setError('加载设备列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) {
|
||||||
|
const pid = Number(projectId);
|
||||||
|
setMode('cabinets');
|
||||||
|
setSelectedProject(projects.find((p) => p.id === pid) || null);
|
||||||
|
fetchCabinets(pid);
|
||||||
|
} else {
|
||||||
|
setMode('projects');
|
||||||
|
fetchProjects();
|
||||||
|
}
|
||||||
|
// ponytail: projects refetched on mount, ok for current scope
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleProjectClick = (project: H5Project) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setMode('cabinets');
|
||||||
|
navigate(`/pms/devices?project_id=${project.id}`, { replace: true });
|
||||||
|
fetchCabinets(project.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setMode('projects');
|
||||||
|
setSelectedProject(null);
|
||||||
|
setCabinets([]);
|
||||||
|
navigate('/pms/devices', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <PageContainer title={mode === 'projects' ? '设备' : selectedProject?.name || '设备'}><Loading /></PageContainer>;
|
||||||
|
if (error) return <PageContainer title="设备"><ErrorState message={error} onRetry={mode === 'projects' ? fetchProjects : () => selectedProject && fetchCabinets(selectedProject.id)} /></PageContainer>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title={mode === 'projects' ? '设备' : selectedProject?.name || '设备'}
|
||||||
|
onBack={mode === 'cabinets' ? handleBack : undefined}
|
||||||
|
>
|
||||||
|
{mode === 'projects' ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{projects.length === 0 && <p className="text-center text-sm text-gray-400">暂无项目</p>}
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left active:bg-gray-50"
|
||||||
|
onClick={() => handleProjectClick(project)}
|
||||||
|
>
|
||||||
|
<span className="text-base font-medium text-gray-900">{project.name}</span>
|
||||||
|
<svg className="float-right mt-1 h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{cabinets.length === 0 && <p className="text-center text-sm text-gray-400">暂无设备</p>}
|
||||||
|
{cabinets.map((cabinet) => (
|
||||||
|
<button
|
||||||
|
key={cabinet.id}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-gray-200 bg-white p-4 active:bg-gray-50"
|
||||||
|
onClick={() => navigate(`/pms/devices/${cabinet.id}`)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-gray-900">{cabinet.name || cabinet.abstract_id}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">ID: {cabinet.abstract_id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${cabinet.status === 1 ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||||
|
<span className="text-xs text-gray-500">{cabinet.status === 1 ? '在线' : '离线'}</span>
|
||||||
|
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
software/h5/src/h5/pages/home.tsx
Normal file
102
software/h5/src/h5/pages/home.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* H5 首页 Dashboard
|
||||||
|
* - 统计卡片(在线/充电中/空闲/故障)
|
||||||
|
* - 今日统计
|
||||||
|
* - 告警通知
|
||||||
|
* - 项目列表
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { h5Api, type DashboardData } from '../api';
|
||||||
|
import { StatCard, AlertCard, ProjectCard, Loading, ErrorState } from '../components';
|
||||||
|
|
||||||
|
export default function H5Home() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await h5Api.getDashboard();
|
||||||
|
setData(res.data);
|
||||||
|
} catch {
|
||||||
|
setError('加载失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
// ponytail: 30s auto-refresh, add WebSocket if real-time needed
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <Loading />;
|
||||||
|
if (error) return <ErrorState message={error} onRetry={fetchData} />;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-gray-900">设备概览</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<StatCard label="在线" value={data.online_cabinets} sub="柜子" color="blue" />
|
||||||
|
<StatCard label="充电中" value={data.charging_count} sub="柜子" color="green" />
|
||||||
|
<StatCard label="空闲" value={data.idle_channels} sub="通道" color="gray" />
|
||||||
|
<StatCard label="故障" value={data.fault_channels} sub="通道" color="red" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 今日统计 */}
|
||||||
|
<section className="rounded-xl bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-gray-900">今日统计</h2>
|
||||||
|
<div className="flex justify-around text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-blue-600">{data.today_charge_count}</p>
|
||||||
|
<p className="text-xs text-gray-500">充电次数</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-px bg-gray-200" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-blue-600">{data.today_charge_hours}</p>
|
||||||
|
<p className="text-xs text-gray-500">充电时长(h)</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-px bg-gray-200" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-blue-600">{data.today_energy_kwh.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-gray-500">能耗(kWh)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 告警通知 */}
|
||||||
|
{data.alerts.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-gray-900">告警通知</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{data.alerts.map((alert, i) => (
|
||||||
|
<AlertCard key={i} {...alert} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 项目列表 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-gray-900">项目列表</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{data.projects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
{...project}
|
||||||
|
onClick={() => navigate(`/pms/devices?project_id=${project.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
software/h5/src/h5/pages/login.tsx
Normal file
101
software/h5/src/h5/pages/login.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 登录页
|
||||||
|
* 深色科技风格,与产品介绍页统一
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useH5AuthStore } from '../auth';
|
||||||
|
|
||||||
|
export default function H5Login() {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { login, loading } = useH5AuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const redirect = searchParams.get('redirect') || '/pms';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!phone || !password) {
|
||||||
|
setError('请输入手机号和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await login(phone, password);
|
||||||
|
navigate(redirect, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-[#09090b] px-6">
|
||||||
|
{/* 背景网格 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'linear-gradient(rgba(34,211,238,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(34,211,238,0.5) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '60px 60px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="relative z-10 flex flex-1 flex-col items-center justify-center">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#22d3ee]">
|
||||||
|
<svg className="h-8 w-8 text-[#09090b]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#fafafa]">安知充</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#71717a]">充电柜管理系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录表单 */}
|
||||||
|
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-[#3f3f46] bg-[#18181b] px-4 py-3 text-base text-[#fafafa] outline-none transition-colors focus:border-[#22d3ee]"
|
||||||
|
type="tel"
|
||||||
|
placeholder="手机号"
|
||||||
|
maxLength={11}
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-[#3f3f46] bg-[#18181b] px-4 py-3 text-base text-[#fafafa] outline-none transition-colors focus:border-[#22d3ee]"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-center text-sm text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-4 w-full rounded-lg bg-[#22d3ee] py-3 text-base font-medium text-[#09090b] transition-all hover:bg-[#06b6d4] active:translate-y-[1px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 底部信息 */}
|
||||||
|
<div className="mt-8 text-center text-xs text-[#52525b]">
|
||||||
|
<p>无锡艾动电子有限公司</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
software/h5/src/h5/pages/me.tsx
Normal file
63
software/h5/src/h5/pages/me.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* H5 我的页面
|
||||||
|
* - 个人信息展示
|
||||||
|
* - 退出登录
|
||||||
|
*/
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useH5AuthStore } from '../auth';
|
||||||
|
import { PageContainer, ConfirmDialog } from '../components';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function H5Me() {
|
||||||
|
const { user, logout } = useH5AuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showLogout, setShowLogout] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/pms/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer title="我的">
|
||||||
|
{/* 用户信息 */}
|
||||||
|
<div className="mb-6 flex items-center gap-4 rounded-xl bg-white p-4">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
||||||
|
{(user?.name || user?.phone || '?').charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold text-gray-900">{user?.name || '用户'}</p>
|
||||||
|
<p className="text-sm text-gray-500">{user?.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 菜单项 */}
|
||||||
|
<div className="rounded-xl bg-white">
|
||||||
|
<button className="flex w-full items-center justify-between px-4 py-4 active:bg-gray-50" onClick={() => navigate('/pms/me')}>
|
||||||
|
<span className="text-sm text-gray-900">修改密码</span>
|
||||||
|
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* ponytail: password change page placeholder, add when backend endpoint ready */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
className="w-full rounded-lg border border-red-200 bg-white py-3 text-base font-medium text-red-500"
|
||||||
|
onClick={() => setShowLogout(true)}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showLogout}
|
||||||
|
title="退出登录"
|
||||||
|
message="确定要退出当前账号?"
|
||||||
|
onConfirm={handleLogout}
|
||||||
|
onCancel={() => setShowLogout(false)}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
software/h5/src/h5/routes.tsx
Normal file
82
software/h5/src/h5/routes.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* H5 路由配置
|
||||||
|
* 所有路由相对于 /h5/ 基路径
|
||||||
|
*/
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import H5Layout from './layout';
|
||||||
|
import H5Login from './pages/login';
|
||||||
|
import H5Home from './pages/home';
|
||||||
|
import H5Devices from './pages/devices';
|
||||||
|
import H5DeviceDetail from './pages/device-detail';
|
||||||
|
import H5CompartmentDetail from './pages/compartment-detail';
|
||||||
|
import H5Me from './pages/me';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 路由片段:
|
||||||
|
*
|
||||||
|
* /login - 登录
|
||||||
|
* / - 首页(需认证,底部导航)
|
||||||
|
* /devices - 设备列表(需认证,底部导航)
|
||||||
|
* /devices/:id - 设备详情(需认证,底部导航)
|
||||||
|
* /compartments/:id - 通道详情(需认证,全屏)
|
||||||
|
* /me - 我的(需认证,底部导航)
|
||||||
|
* /:abstractId - 扫码入口
|
||||||
|
*/
|
||||||
|
export const h5Routes = (
|
||||||
|
<>
|
||||||
|
{/* 登录页 - 无底部导航 */}
|
||||||
|
<Route path="/login" element={<H5Login />} />
|
||||||
|
|
||||||
|
{/* H5 主布局(含底部导航 + 认证守卫) */}
|
||||||
|
<Route path="/" element={<H5Layout />}>
|
||||||
|
<Route index element={<H5Home />} />
|
||||||
|
<Route path="devices" element={<H5Devices />} />
|
||||||
|
<Route path="devices/:id" element={<H5DeviceDetail />} />
|
||||||
|
<Route path="me" element={<H5Me />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 通道详情 - 全屏页面,直接挂载 */}
|
||||||
|
<Route path="/compartments/:id" element={<H5CompartmentDetail />} />
|
||||||
|
|
||||||
|
{/* 扫码入口: /{abstract_id} → 设备详情 */}
|
||||||
|
<Route path="/:abstractId" element={<ScanRedirect />} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 扫码入口处理:通过 abstract_id 查设备详情 */
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { h5Api } from './api';
|
||||||
|
import { Loading } from './components';
|
||||||
|
|
||||||
|
function ScanRedirect() {
|
||||||
|
const { abstractId } = useParams<{ abstractId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 排除已知路径
|
||||||
|
if (!abstractId ||
|
||||||
|
['login', 'devices', 'me', 'compartments'].includes(abstractId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5Api.getCabinetByAbstractId(abstractId)
|
||||||
|
.then((res) => navigate(`/devices/${res.data.id}`, { replace: true }))
|
||||||
|
.catch(() => setError('未找到该设备'));
|
||||||
|
}, [abstractId, navigate]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-white">
|
||||||
|
<p className="text-sm text-gray-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-white">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
software/h5/src/index.css
Normal file
1
software/h5/src/index.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
10
software/h5/src/main.tsx
Normal file
10
software/h5/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
31
software/h5/tsconfig.app.json
Normal file
31
software/h5/tsconfig.app.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
software/h5/tsconfig.json
Normal file
7
software/h5/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
software/h5/tsconfig.node.json
Normal file
23
software/h5/tsconfig.node.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
software/h5/vite.config.ts
Normal file
23
software/h5/vite.config.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/h5/',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 应用根组件
|
* 应用根组件
|
||||||
* - 路由配置:登录页 + 后台管理布局(嵌套子路由)+ H5用户端(/pms/*)
|
* - 路由配置:登录页 + 后台管理布局(嵌套子路由)
|
||||||
* - 未认证时自动跳转到登录页
|
* - 未认证时自动跳转到登录页
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -12,7 +12,6 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useH5AuthStore } from '@/h5/auth';
|
|
||||||
import AdminLayout from '@/layouts/AdminLayout';
|
import AdminLayout from '@/layouts/AdminLayout';
|
||||||
import Login from '@/pages/Login';
|
import Login from '@/pages/Login';
|
||||||
import Dashboard from '@/pages/Dashboard';
|
import Dashboard from '@/pages/Dashboard';
|
||||||
@ -24,7 +23,6 @@ import Energy from '@/pages/energy';
|
|||||||
import Users from '@/pages/settings/users';
|
import Users from '@/pages/settings/users';
|
||||||
import Roles from '@/pages/settings/roles';
|
import Roles from '@/pages/settings/roles';
|
||||||
import OperationLogs from '@/pages/settings/operation-logs';
|
import OperationLogs from '@/pages/settings/operation-logs';
|
||||||
import { h5Routes } from '@/h5/routes';
|
|
||||||
|
|
||||||
/** 路由守卫:未认证时等待恢复,恢复失败则跳转到登录页 */
|
/** 路由守卫:未认证时等待恢复,恢复失败则跳转到登录页 */
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
@ -49,13 +47,11 @@ function RedirectIfAuth({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const restore = useAuthStore((s) => s.restore);
|
const restore = useAuthStore((s) => s.restore);
|
||||||
const restoreH5 = useH5AuthStore((s) => s.restore);
|
|
||||||
|
|
||||||
/** 应用启动时从 localStorage 恢复认证状态 */
|
/** 应用启动时从 localStorage 恢复认证状态 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restore();
|
restore();
|
||||||
restoreH5();
|
}, [restore]);
|
||||||
}, [restore, restoreH5]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@ -89,9 +85,6 @@ export default function App() {
|
|||||||
<Route path="/settings/operation-logs" element={<OperationLogs />} />
|
<Route path="/settings/operation-logs" element={<OperationLogs />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* H5 用户端路由 */}
|
|
||||||
{h5Routes}
|
|
||||||
|
|
||||||
{/* 默认重定向到 Dashboard */}
|
{/* 默认重定向到 Dashboard */}
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user