refactor: H5和WEB前端分离,H5独立项目,扫码路径改为/h5/

This commit is contained in:
12451 2026-07-02 18:23:13 +08:00
parent 60d04cd2bb
commit 9b5789fb75
27 changed files with 1917 additions and 10 deletions

View File

@ -11,7 +11,7 @@
- **平台抽象ID**`10-00000000`绑定柜控板二维码对应此ID
- **设备ID**4G SIM卡15位IMEI10进制
- **仓体全局ID**`平台层ID-仓板ID-仓体ID`
- **扫码链接**`https://app.anzhizhichong.com/pms/{抽象ID}`
- **扫码链接**`https://app.anzhizhichong.com/h5/{抽象ID}`
## 技术栈

View File

@ -44,4 +44,15 @@ server {
alias /data/pms/web/;
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
View 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
View 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
View 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>
);
}

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

View 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 ?? [];
}

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1 @@
@import "tailwindcss";

10
software/h5/src/main.tsx Normal file
View 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>,
)

View 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"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

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

View File

@ -1,6 +1,6 @@
/**
*
* - + + H5用户端/pms/*
* - +
* -
*/
@ -12,7 +12,6 @@ import {
Navigate,
} from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';
import { useH5AuthStore } from '@/h5/auth';
import AdminLayout from '@/layouts/AdminLayout';
import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard';
@ -24,7 +23,6 @@ import Energy from '@/pages/energy';
import Users from '@/pages/settings/users';
import Roles from '@/pages/settings/roles';
import OperationLogs from '@/pages/settings/operation-logs';
import { h5Routes } from '@/h5/routes';
/** 路由守卫:未认证时等待恢复,恢复失败则跳转到登录页 */
function RequireAuth({ children }: { children: React.ReactNode }) {
@ -49,13 +47,11 @@ function RedirectIfAuth({ children }: { children: React.ReactNode }) {
export default function App() {
const restore = useAuthStore((s) => s.restore);
const restoreH5 = useH5AuthStore((s) => s.restore);
/** 应用启动时从 localStorage 恢复认证状态 */
useEffect(() => {
restore();
restoreH5();
}, [restore, restoreH5]);
}, [restore]);
return (
<BrowserRouter>
@ -89,9 +85,6 @@ export default function App() {
<Route path="/settings/operation-logs" element={<OperationLogs />} />
</Route>
{/* H5 用户端路由 */}
{h5Routes}
{/* 默认重定向到 Dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>