第三阶段核心业务开发完成
This commit is contained in:
12
charging_web_app/package-lock.json
generated
12
charging_web_app/package-lock.json
generated
@@ -12,7 +12,8 @@
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -5783,6 +5784,15 @@
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -1,69 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings } from 'react-icons/fi';
|
||||
import { api } from '@/utils/axios';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
|
||||
const AdminDashboardPage: React.FC = () => {
|
||||
const { user, logout, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
// 匹配后端 AdminDashboardStatsVO
|
||||
interface AdminStats {
|
||||
totalUsers: number;
|
||||
totalRobots: number; // 后端有这个字段
|
||||
onlineRobots: number;
|
||||
chargingRobots: number;
|
||||
idleRobots: number;
|
||||
activeSessions: number; // 后端叫 activeSessions
|
||||
totalRevenue: number; // 后端叫 totalRevenue
|
||||
totalParkingSpots?: number; // 新增,设为可选以处理数据可能暂时未返回的情况
|
||||
availableParkingSpots?: number; // 新增,设为可选
|
||||
// 下面两个字段在当前的后端 AdminDashboardStatsVO 中不存在,但在前端 AdminStats 接口中存在
|
||||
// totalChargingSessionsToday: number;
|
||||
// totalRevenueToday: number;
|
||||
// 下面这两个字段也需要确认后端VO中是否有对应,暂时先用后端有的字段
|
||||
// totalParkingSpots: number;
|
||||
// availableParkingSpots: number;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 建议定义一个通用的 BaseResponse 接口
|
||||
interface BackendBaseResponse<T> {
|
||||
data: T;
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
// AuthenticatedLayout 应该已经处理了重定向
|
||||
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
|
||||
}
|
||||
const AdminDashboardPage = () => {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const [adminStats, setAdminStats] = useState<AdminStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const [statsError, setStatsError] = useState<string | null>(null);
|
||||
|
||||
// 如果用户不是管理员,则重定向到普通用户dashboard
|
||||
if (user.role !== 'admin') {
|
||||
router.replace('/dashboard');
|
||||
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (isAuthenticated && user?.role !== 'admin') {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, user, router]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 pt-20">
|
||||
<div className="bg-white shadow-md rounded-lg p-8 max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
|
||||
管理员控制台
|
||||
</h1>
|
||||
<p className="text-xl mb-4 text-gray-700">
|
||||
欢迎, 管理员 <span className="font-semibold">{user.username}</span>!
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<Link href="/admin/user-management" legacyBehavior>
|
||||
<a className="block p-6 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-md transition duration-150 ease-in-out text-center">
|
||||
<h2 className="text-xl font-semibold">用户管理</h2>
|
||||
<p>查看和管理用户列表</p>
|
||||
</a>
|
||||
</Link>
|
||||
{/* 可以添加更多管理员功能模块链接 */}
|
||||
<div className="block p-6 bg-gray-200 text-gray-500 rounded-lg shadow-md text-center">
|
||||
<h2 className="text-xl font-semibold">系统设置 (待开发)</h2>
|
||||
<p>配置系统参数</p>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
setStatsLoading(true);
|
||||
setStatsError(null);
|
||||
api.get<BackendBaseResponse<AdminStats>>('/admin/stats/summary') // 使用 BackendBaseResponse
|
||||
.then((response: AxiosResponse<BackendBaseResponse<AdminStats>>) => {
|
||||
if (response.data && response.data.data) {
|
||||
// 后端返回的 totalRevenue 对应前端接口的 totalRevenueToday
|
||||
// 后端返回的 activeSessions 对应前端接口的 totalChargingSessionsToday
|
||||
// 但为了保持一致性,我们最好让前端AdminStats接口的字段名与后端VO的字段名一致
|
||||
// 这里暂时直接使用后端返回的字段名,前端 JSX 部分也需要对应调整
|
||||
setAdminStats(response.data.data);
|
||||
} else {
|
||||
console.error("Failed to fetch admin stats or data is null/malformed:", response);
|
||||
setStatsError("加载统计数据失败: 数据为空或格式错误。");
|
||||
setAdminStats(null);
|
||||
}
|
||||
})
|
||||
.catch((error: AxiosError) => {
|
||||
console.error("Failed to fetch admin stats:", error);
|
||||
if (error.response) {
|
||||
setStatsError(`加载统计数据失败: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
setStatsError("加载统计数据失败: 未收到服务器响应。");
|
||||
} else {
|
||||
setStatsError("加载统计数据失败: 请求设置时出错。");
|
||||
}
|
||||
setAdminStats(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setStatsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, user]);
|
||||
|
||||
if (isLoading || (!isLoading && !isAuthenticated)) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isAuthenticated && user?.role !== 'admin') {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const StatCard = ({ title, value, icon, subValue, unit }: { title: string; value: string | number; icon: React.ReactNode; subValue?: string, unit?: string }) => (
|
||||
<div className="bg-white p-5 rounded-xl shadow-lg flex items-start space-x-3 transform transition-all hover:shadow-xl hover:-translate-y-1 min-h-[100px]">
|
||||
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-full">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 font-medium">{title}</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{value}
|
||||
{unit && <span className="text-lg ml-1">{unit}</span>}
|
||||
</p>
|
||||
{subValue && <p className="text-xs text-gray-400 mt-1">{subValue}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
}}
|
||||
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
|
||||
const NavCard = ({ title, description, href, icon, disabled }: { title: string; description: string; href: string; icon: React.ReactNode; disabled?: boolean }) => (
|
||||
<Link
|
||||
href={disabled ? '#' : href}
|
||||
legacyBehavior={false}
|
||||
className={`block p-6 rounded-lg shadow-lg transform transition-all hover:shadow-xl hover:scale-105
|
||||
${disabled ? 'bg-gray-200 cursor-not-allowed' : 'bg-gradient-to-br from-indigo-600 to-blue-500 hover:from-indigo-700 hover:to-blue-600 text-white'}
|
||||
`}
|
||||
onClick={(e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex items-center mb-2">
|
||||
<span className={disabled ? 'text-gray-500' : 'text-indigo-100'}>{icon}</span>
|
||||
<h3 className={`ml-3 text-xl font-semibold ${disabled ? 'text-gray-700' : 'text-white'}`}>{title}</h3>
|
||||
</div>
|
||||
<p className={`text-sm ${disabled ? 'text-gray-600' : 'opacity-90 text-indigo-50'}`}>{description}</p>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8 px-4 md:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-8">管理员控制台</h1>
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">系统概览</h2>
|
||||
{statsLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-white p-5 rounded-xl shadow-lg h-28 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-2"></div>
|
||||
<div className="h-8 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : statsError ? (
|
||||
<p className="text-red-600 bg-red-100 p-4 rounded-md">{statsError}</p>
|
||||
) : adminStats ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
<StatCard title="总用户数" value={adminStats.totalUsers ?? '--'} icon={<FiUsers size={24} />} />
|
||||
<StatCard
|
||||
title="机器人状态 (在线/充电/空闲)"
|
||||
value={`${adminStats.onlineRobots ?? '--'} / ${adminStats.chargingRobots ?? '--'} / ${adminStats.idleRobots ?? '--'}`}
|
||||
icon={<FiCpu size={24} />}
|
||||
/>
|
||||
<StatCard
|
||||
title="车位统计 (总数/可用)"
|
||||
value={`${adminStats.totalParkingSpots ?? '--'} / ${adminStats.availableParkingSpots ?? '--'}`}
|
||||
icon={<FiGrid size={24} />}
|
||||
/>
|
||||
<StatCard
|
||||
title="概览 (活动会话/总收入)"
|
||||
value={`${adminStats.activeSessions ?? '--'} / ¥${(adminStats.totalRevenue ?? 0).toFixed(2)}`}
|
||||
icon={<FiBarChart2 size={24} />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600">当前无系统统计数据。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">管理模块</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<NavCard
|
||||
title="车位管理"
|
||||
description="管理充电车位信息、状态等"
|
||||
href="/admin/parking-spots"
|
||||
icon={<FiGrid size={28}/>}
|
||||
/>
|
||||
<NavCard
|
||||
title="机器人管理"
|
||||
description="管理充电机器人、状态、任务分配"
|
||||
href="/admin/robots"
|
||||
icon={<FiTerminal size={28}/>}
|
||||
/>
|
||||
<NavCard
|
||||
title="会话管理"
|
||||
description="查看所有充电会话记录、详情"
|
||||
href="/admin/sessions"
|
||||
icon={<FiList size={28}/>}
|
||||
disabled={true}
|
||||
/>
|
||||
<NavCard
|
||||
title="用户管理"
|
||||
description="查看和管理平台用户列表"
|
||||
href="/admin/user-management"
|
||||
icon={<FiUserCheck size={28}/>}
|
||||
/>
|
||||
<NavCard
|
||||
title="系统设置"
|
||||
description="配置系统参数、费率等"
|
||||
href="/admin/settings"
|
||||
icon={<FiSettings size={28}/>}
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
export default AdminDashboardPage;
|
||||
@@ -0,0 +1,803 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/services/api';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import Link from 'next/link';
|
||||
// AuthenticatedLayout is likely applied via the directory structure (app/(authenticated)/layout.tsx)
|
||||
// So, no explicit import should be needed here for wrapping the page content.
|
||||
|
||||
// Define interfaces for ParkingSpot and query params based on backend DTOs
|
||||
interface ParkingSpot {
|
||||
id: number;
|
||||
spotUid: string;
|
||||
locationDesc: string | null;
|
||||
status: string; // e.g., AVAILABLE, OCCUPIED, MAINTENANCE, RESERVED
|
||||
robotAssignable: boolean;
|
||||
currentSessionId: number | null;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
interface ParkingSpotQueryRequest {
|
||||
spotUid?: string;
|
||||
status?: string;
|
||||
robotAssignable?: boolean | string; // Allow string for 'ALL' option in select
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
sortField?: string;
|
||||
sortOrder?: string; // asc or desc
|
||||
}
|
||||
|
||||
// Define interface for Add Parking Spot Request based on backend DTO
|
||||
interface ParkingSpotAddRequest {
|
||||
spotUid: string;
|
||||
locationDesc?: string;
|
||||
status: string; // Default to AVAILABLE
|
||||
robotAssignable: boolean; // Default to true
|
||||
}
|
||||
|
||||
// Define interface for Update Parking Spot Request based on backend DTO
|
||||
interface ParkingSpotUpdateRequest {
|
||||
id: number; // Crucial for identifying the spot to update
|
||||
spotUid?: string; // Usually not updatable, but depends on backend logic
|
||||
locationDesc?: string;
|
||||
status?: string;
|
||||
robotAssignable?: boolean;
|
||||
}
|
||||
|
||||
const AdminParkingSpotsPage = () => {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const [parkingSpots, setParkingSpots] = useState<ParkingSpot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalSpots, setTotalSpots] = useState(0);
|
||||
|
||||
// State for query params and pagination
|
||||
const [queryParams, setQueryParams] = useState<ParkingSpotQueryRequest>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
sortField: 'create_time',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
|
||||
// --- Notification State ---
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
|
||||
const showNotification = (message: string, type: 'success' | 'error') => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => {
|
||||
setNotification(null);
|
||||
}, 3000); // Notification disappears after 3 seconds
|
||||
};
|
||||
|
||||
// CRUD Modal States (Add, Edit, Delete)
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [newSpotData, setNewSpotData] = useState<ParkingSpotAddRequest>({
|
||||
spotUid: '',
|
||||
locationDesc: '',
|
||||
status: 'AVAILABLE',
|
||||
robotAssignable: true,
|
||||
});
|
||||
const [addSpotLoading, setAddSpotLoading] = useState(false);
|
||||
const [addSpotError, setAddSpotError] = useState<string | null>(null);
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingSpot, setEditingSpot] = useState<ParkingSpotUpdateRequest | null>(null);
|
||||
const [editSpotLoading, setEditSpotLoading] = useState(false);
|
||||
const [editSpotError, setEditSpotError] = useState<string | null>(null);
|
||||
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||
const [deletingSpotInfo, setDeletingSpotInfo] = useState<{ id: number; spotUid: string } | null>(null);
|
||||
const [deleteSpotLoading, setDeleteSpotLoading] = useState(false);
|
||||
const [deleteSpotError, setDeleteSpotError] = useState<string | null>(null);
|
||||
|
||||
// Search/Filter states
|
||||
const [searchSpotUid, setSearchSpotUid] = useState('');
|
||||
const [searchStatus, setSearchStatus] = useState('');
|
||||
const [searchRobotAssignable, setSearchRobotAssignable] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
} else {
|
||||
// Updated admin check based on user.role
|
||||
const isAdminUser = user?.role === 'admin';
|
||||
setIsAdmin(isAdminUser);
|
||||
if (!isAdminUser) {
|
||||
router.push('/dashboard'); // Redirect non-admins
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [user, authLoading, router]); // Removed checkAdmin from dependencies
|
||||
|
||||
const fetchParkingSpots = useCallback(async (overrideParams?: Partial<ParkingSpotQueryRequest>) => {
|
||||
if (!isAdmin) return; // Don't fetch if not admin
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const currentSearchParams: ParkingSpotQueryRequest = {
|
||||
...queryParams,
|
||||
spotUid: searchSpotUid.trim() || undefined,
|
||||
status: searchStatus || undefined,
|
||||
robotAssignable: searchRobotAssignable === '' ? undefined : searchRobotAssignable === 'true',
|
||||
...overrideParams, // Apply overrides, e.g., for page changes or new searches
|
||||
};
|
||||
|
||||
// Remove undefined keys to keep the request clean
|
||||
Object.keys(currentSearchParams).forEach(key =>
|
||||
(currentSearchParams as any)[key] === undefined && delete (currentSearchParams as any)[key]
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await api.post('/admin/spot/list/page', currentSearchParams);
|
||||
if (response.data && response.data.code === 0) {
|
||||
setParkingSpots(response.data.data.records || []);
|
||||
setTotalSpots(response.data.data.total || 0);
|
||||
// Update queryParams state if overrideParams were passed (e.g. page change)
|
||||
// but not for initial search filter application as that's handled by search button
|
||||
if (overrideParams && Object.keys(overrideParams).length > 0 && !overrideParams.spotUid && !overrideParams.status && !overrideParams.robotAssignable ){
|
||||
setQueryParams(prev => ({...prev, ...overrideParams}));
|
||||
}
|
||||
} else {
|
||||
setError(response.data.message || '获取车位列表失败');
|
||||
setParkingSpots([]);
|
||||
setTotalSpots(0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('获取车位列表错误:', err);
|
||||
setError(err.message || '发生未知错误。');
|
||||
setParkingSpots([]);
|
||||
setTotalSpots(0);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [isAdmin, queryParams, searchSpotUid, searchStatus, searchRobotAssignable]); // Added search states to dependencies
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) { // Only fetch if confirmed admin
|
||||
fetchParkingSpots();
|
||||
}
|
||||
}, [fetchParkingSpots, isAdmin]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchParkingSpots({ current: newPage });
|
||||
setQueryParams(prev => ({ ...prev, current: newPage })); // Keep queryParams state in sync for display/logic
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
fetchParkingSpots({ pageSize: newSize, current: 1 });
|
||||
setQueryParams(prev => ({ ...prev, pageSize: newSize, current: 1 })); // Reset to first page
|
||||
};
|
||||
|
||||
// --- Table Sorting ---
|
||||
const handleSort = (fieldKey: keyof ParkingSpot) => {
|
||||
// Map frontend display key (camelCase) to backend sortField (snake_case)
|
||||
let sortFieldForBackend = '';
|
||||
if (fieldKey === 'createTime') {
|
||||
sortFieldForBackend = 'create_time';
|
||||
} else if (fieldKey === 'updateTime') {
|
||||
sortFieldForBackend = 'update_time';
|
||||
} else if (fieldKey === 'spotUid') {
|
||||
sortFieldForBackend = 'spot_uid'; // example if spot_uid in DB
|
||||
} else {
|
||||
sortFieldForBackend = fieldKey; // For other fields like status, robotAssignable if they are directly mapped
|
||||
}
|
||||
|
||||
const newSortOrder = queryParams.sortField === sortFieldForBackend && queryParams.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
const newQueryParams = { ...queryParams, sortField: sortFieldForBackend, sortOrder: newSortOrder, current: 1 };
|
||||
setQueryParams(newQueryParams);
|
||||
fetchParkingSpots(newQueryParams);
|
||||
};
|
||||
|
||||
// --- Search/Filter Logic ---
|
||||
const handleSearch = () => {
|
||||
const newQueryParams = {
|
||||
...queryParams, // Keep current sort and page size
|
||||
current: 1,
|
||||
spotUid: searchSpotUid.trim() || undefined,
|
||||
status: searchStatus || undefined,
|
||||
robotAssignable: searchRobotAssignable === '' ? undefined : searchRobotAssignable === 'true',
|
||||
};
|
||||
setQueryParams(newQueryParams);
|
||||
fetchParkingSpots(newQueryParams);
|
||||
};
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchSpotUid('');
|
||||
setSearchStatus('');
|
||||
setSearchRobotAssignable('');
|
||||
const baseParams = {
|
||||
current: 1,
|
||||
pageSize: queryParams.pageSize, // Retain current page size
|
||||
sortField: 'create_time', // Default sort
|
||||
sortOrder: 'desc', // Default sort order
|
||||
spotUid: undefined,
|
||||
status: undefined,
|
||||
robotAssignable: undefined,
|
||||
}
|
||||
setQueryParams(baseParams);
|
||||
fetchParkingSpots(baseParams);
|
||||
};
|
||||
|
||||
// --- Add New Spot Modal Logic ---
|
||||
const openAddModal = () => {
|
||||
setNewSpotData({ // Reset form data
|
||||
spotUid: '',
|
||||
locationDesc: '',
|
||||
status: 'AVAILABLE',
|
||||
robotAssignable: true,
|
||||
});
|
||||
setAddSpotError(null);
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const closeAddModal = () => {
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const handleNewSpotDataChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
const checked = (e.target as HTMLInputElement).checked; // For checkbox
|
||||
|
||||
setNewSpotData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddNewSpot = async () => {
|
||||
if (!newSpotData.spotUid.trim()) {
|
||||
setAddSpotError('车位UID是必填项。');
|
||||
return;
|
||||
}
|
||||
const spotUidRegex = /^[a-zA-Z0-9]{3,20}$/;
|
||||
if (!spotUidRegex.test(newSpotData.spotUid.trim())) {
|
||||
setAddSpotError('车位UID必须是3-20位字母或数字。');
|
||||
return;
|
||||
}
|
||||
if (newSpotData.locationDesc && newSpotData.locationDesc.length > 200) {
|
||||
setAddSpotError('位置描述不能超过200个字符。');
|
||||
return;
|
||||
}
|
||||
|
||||
setAddSpotLoading(true);
|
||||
setAddSpotError(null);
|
||||
try {
|
||||
const response = await api.post('/admin/spot/add', newSpotData);
|
||||
if (response.data && response.data.code === 0) {
|
||||
closeAddModal();
|
||||
fetchParkingSpots({current: queryParams.current});
|
||||
showNotification('车位添加成功!', 'success');
|
||||
} else {
|
||||
setAddSpotError(response.data.message || '添加车位失败。');
|
||||
showNotification(response.data.message || '添加车位失败。', 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('添加车位错误:', err);
|
||||
setAddSpotError(err.message || '添加车位时发生未知错误。');
|
||||
showNotification(err.message || '添加车位时发生未知错误。', 'error');
|
||||
}
|
||||
setAddSpotLoading(false);
|
||||
};
|
||||
|
||||
// --- Edit Spot Modal Logic ---
|
||||
const openEditModal = (spot: ParkingSpot) => {
|
||||
setEditingSpot({
|
||||
id: spot.id,
|
||||
spotUid: spot.spotUid,
|
||||
locationDesc: spot.locationDesc || '',
|
||||
status: spot.status,
|
||||
robotAssignable: spot.robotAssignable,
|
||||
});
|
||||
setEditSpotError(null);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingSpot(null);
|
||||
};
|
||||
|
||||
const handleEditingSpotDataChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setEditingSpot(prev => prev ? { ...prev, [name]: type === 'checkbox' ? checked : value } : null);
|
||||
};
|
||||
|
||||
const handleUpdateSpot = async () => {
|
||||
if (!editingSpot) return;
|
||||
|
||||
if (editingSpot.locationDesc && editingSpot.locationDesc.length > 200) {
|
||||
setEditSpotError('位置描述不能超过200个字符。');
|
||||
return;
|
||||
}
|
||||
|
||||
setEditSpotLoading(true);
|
||||
setEditSpotError(null);
|
||||
try {
|
||||
const response = await api.post('/admin/spot/update', editingSpot);
|
||||
if (response.data && response.data.code === 0) {
|
||||
closeEditModal();
|
||||
fetchParkingSpots({current: queryParams.current});
|
||||
showNotification('车位更新成功!', 'success');
|
||||
} else {
|
||||
setEditSpotError(response.data.message || '更新车位失败。');
|
||||
showNotification(response.data.message || '更新车位失败。', 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('更新车位错误:', err);
|
||||
setEditSpotError(err.message || '更新车位时发生未知错误。');
|
||||
showNotification(err.message || '更新车位时发生未知错误。', 'error');
|
||||
}
|
||||
setEditSpotLoading(false);
|
||||
};
|
||||
|
||||
// --- Delete Spot Confirmation Logic ---
|
||||
const openDeleteConfirm = (spot: ParkingSpot) => {
|
||||
setDeletingSpotInfo({ id: spot.id, spotUid: spot.spotUid });
|
||||
setDeleteSpotError(null);
|
||||
setIsDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeDeleteConfirm = () => {
|
||||
setIsDeleteConfirmOpen(false);
|
||||
setDeletingSpotInfo(null);
|
||||
};
|
||||
|
||||
const handleDeleteSpot = async () => {
|
||||
if (!deletingSpotInfo) return;
|
||||
setDeleteSpotLoading(true);
|
||||
setDeleteSpotError(null);
|
||||
try {
|
||||
const response = await api.post('/admin/spot/delete', { id: deletingSpotInfo.id });
|
||||
if (response.data && response.data.code === 0) {
|
||||
closeDeleteConfirm();
|
||||
// After deletion, decide which page to fetch. If current page becomes empty, fetch previous page or page 1.
|
||||
// For simplicity, refetching all or current page based on remaining items.
|
||||
const newTotalSpots = totalSpots - 1;
|
||||
const newCurrentPage = Math.max(1, Math.ceil(newTotalSpots / (queryParams.pageSize || 10)));
|
||||
if (queryParams.current && queryParams.current > newCurrentPage && newCurrentPage > 0) {
|
||||
fetchParkingSpots({ current: newCurrentPage });
|
||||
} else {
|
||||
fetchParkingSpots({current: queryParams.current}); // Or just fetch the current page
|
||||
}
|
||||
showNotification(`车位 ${deletingSpotInfo.spotUid} 删除成功!`, 'success');
|
||||
} else {
|
||||
setDeleteSpotError(response.data.message || '删除车位失败。');
|
||||
showNotification(response.data.message || '删除车位失败。', 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('删除车位错误:', err);
|
||||
setDeleteSpotError(err.message || '删除车位时发生未知错误。');
|
||||
showNotification(err.message || '删除车位时发生未知错误。', 'error');
|
||||
}
|
||||
setDeleteSpotLoading(false);
|
||||
};
|
||||
|
||||
const SortIndicator: React.FC<{ field: keyof ParkingSpot }> = ({ field }) => {
|
||||
// Compare with the backend's expected snake_case field name for active sort column
|
||||
let backendSortField = queryParams.sortField;
|
||||
if (field === 'createTime' && queryParams.sortField === 'create_time') {
|
||||
// Correctly show indicator if frontend 'createTime' maps to backend 'create_time'
|
||||
} else if (field === 'updateTime' && queryParams.sortField === 'update_time') {
|
||||
// Correctly show indicator if frontend 'updateTime' maps to backend 'update_time'
|
||||
} else if (field === 'spotUid' && queryParams.sortField === 'spot_uid') {
|
||||
// Correctly show indicator
|
||||
} else if (queryParams.sortField !== field) {
|
||||
return null; // Not the active sort column (after mapping)
|
||||
}
|
||||
|
||||
if (queryParams.sortField === (field === 'createTime' ? 'create_time' : field === 'updateTime' ? 'update_time' : field === 'spotUid' ? 'spot_uid' : field)) {
|
||||
return queryParams.sortOrder === 'asc' ? <span className="ml-1">▲</span> : <span className="ml-1">▼</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (authLoading || (!isAdmin && user)) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <p>正在重定向到登录页面...</p>;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <p>访问被拒绝。正在重定向...</p>;
|
||||
}
|
||||
|
||||
// Main content for Admin
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 relative">
|
||||
{/* Notification Area */}
|
||||
{notification && (
|
||||
<div className={`fixed top-5 right-5 p-4 rounded-md shadow-lg text-white z-[100]
|
||||
${notification.type === 'success' ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-3xl font-semibold text-gray-800">管理员:车位管理</h1>
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white text-sm font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-75"
|
||||
>
|
||||
返回工作台
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal} // Open the modal
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out"
|
||||
>
|
||||
+ 新建车位
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg shadow">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
|
||||
<div>
|
||||
<label htmlFor="searchSpotUid" className="block text-sm font-medium text-gray-700">车位UID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="searchSpotUid"
|
||||
id="searchSpotUid"
|
||||
value={searchSpotUid}
|
||||
onChange={(e) => setSearchSpotUid(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="searchStatus" className="block text-sm font-medium text-gray-700">状态</label>
|
||||
<select
|
||||
name="searchStatus"
|
||||
id="searchStatus"
|
||||
value={searchStatus}
|
||||
onChange={(e) => setSearchStatus(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">所有状态</option>
|
||||
<option value="AVAILABLE">可用</option>
|
||||
<option value="OCCUPIED">占用</option>
|
||||
<option value="MAINTENANCE">维护中</option>
|
||||
<option value="RESERVED">预留</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="searchRobotAssignable" className="block text-sm font-medium text-gray-700">机器人可分配</label>
|
||||
<select
|
||||
name="searchRobotAssignable"
|
||||
id="searchRobotAssignable"
|
||||
value={searchRobotAssignable}
|
||||
onChange={(e) => setSearchRobotAssignable(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetSearch}
|
||||
className="w-full bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-2 px-4 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Spot Modal */}
|
||||
<Modal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={closeAddModal}
|
||||
title="添加新车位"
|
||||
footer={
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={closeAddModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddNewSpot}
|
||||
disabled={addSpotLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{addSpotLoading ? '添加中...' : '添加车位'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddNewSpot(); }}>
|
||||
{addSpotError && <p className="text-red-500 text-sm mb-3 bg-red-100 p-2 rounded">{addSpotError}</p>}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="spotUid" className="block text-sm font-medium text-gray-700 mb-1">车位UID <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
name="spotUid"
|
||||
id="spotUid"
|
||||
value={newSpotData.spotUid}
|
||||
onChange={handleNewSpotDataChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="locationDesc" className="block text-sm font-medium text-gray-700 mb-1">位置描述 (最多200字符)</label>
|
||||
<textarea
|
||||
name="locationDesc"
|
||||
id="locationDesc"
|
||||
value={newSpotData.locationDesc || ''}
|
||||
onChange={handleNewSpotDataChange}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<select
|
||||
name="status"
|
||||
id="status"
|
||||
value={newSpotData.status}
|
||||
onChange={handleNewSpotDataChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="AVAILABLE">可用</option>
|
||||
<option value="OCCUPIED">占用</option>
|
||||
<option value="MAINTENANCE">维护中</option>
|
||||
<option value="RESERVED">预留</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="robotAssignable"
|
||||
id="robotAssignable"
|
||||
checked={newSpotData.robotAssignable}
|
||||
onChange={handleNewSpotDataChange}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="robotAssignable" className="ml-2 block text-sm text-gray-900">机器人可分配</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Spot Modal */}
|
||||
{editingSpot && (
|
||||
<Modal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={closeEditModal}
|
||||
title={`编辑车位: ${editingSpot.spotUid}`}
|
||||
footer={
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={closeEditModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateSpot}
|
||||
disabled={editSpotLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
||||
>
|
||||
{editSpotLoading ? '保存中...' : '保存更改'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleUpdateSpot(); }}>
|
||||
{editSpotError && <p className="text-red-500 text-sm mb-3 bg-red-100 p-2 rounded">{editSpotError}</p>}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="editSpotUid" className="block text-sm font-medium text-gray-700 mb-1">车位UID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="spotUid"
|
||||
id="editSpotUid"
|
||||
value={editingSpot.spotUid || ''}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 sm:text-sm"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="editLocationDesc" className="block text-sm font-medium text-gray-700 mb-1">位置描述 (最多200字符)</label>
|
||||
<textarea
|
||||
name="locationDesc"
|
||||
id="editLocationDesc"
|
||||
value={editingSpot.locationDesc || ''}
|
||||
onChange={handleEditingSpotDataChange}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="editStatus" className="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<select
|
||||
name="status"
|
||||
id="editStatus"
|
||||
value={editingSpot.status || 'AVAILABLE'}
|
||||
onChange={handleEditingSpotDataChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="AVAILABLE">可用</option>
|
||||
<option value="OCCUPIED">占用</option>
|
||||
<option value="MAINTENANCE">维护中</option>
|
||||
<option value="RESERVED">预留</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="robotAssignable"
|
||||
id="editRobotAssignable"
|
||||
checked={editingSpot.robotAssignable || false}
|
||||
onChange={handleEditingSpotDataChange}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="editRobotAssignable" className="ml-2 block text-sm text-gray-900">机器人可分配</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deletingSpotInfo && (
|
||||
<Modal
|
||||
isOpen={isDeleteConfirmOpen}
|
||||
onClose={closeDeleteConfirm}
|
||||
title="确认删除车位"
|
||||
footer={
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={closeDeleteConfirm}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteSpot}
|
||||
disabled={deleteSpotLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||
>
|
||||
{deleteSpotLoading ? '删除中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-gray-700">
|
||||
您确定要删除车位 <span className="font-semibold">{deletingSpotInfo.spotUid}</span> 吗?
|
||||
此操作无法撤销。
|
||||
</p>
|
||||
{deleteSpotError && <p className="text-red-500 text-sm mt-3 bg-red-100 p-2 rounded">{deleteSpotError}</p>}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{loading && <LoadingSpinner />}
|
||||
{error && <p className="text-red-500 bg-red-100 p-3 rounded-md">错误: {error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Parking Spots Table */}
|
||||
<div className="bg-white shadow-xl rounded-lg overflow-hidden">
|
||||
<table className="min-w-full leading-normal">
|
||||
<thead className="bg-gray-800 text-white">
|
||||
<tr>
|
||||
<th onClick={() => handleSort('spotUid')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer">车位UID <SortIndicator field="spotUid" /></th>
|
||||
<th className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider">位置</th>
|
||||
<th onClick={() => handleSort('status')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer">状态 <SortIndicator field="status" /></th>
|
||||
<th onClick={() => handleSort('robotAssignable')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer">机器人可分配 <SortIndicator field="robotAssignable" /></th>
|
||||
<th onClick={() => handleSort('updateTime')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer">最后更新 <SortIndicator field="updateTime" /></th>
|
||||
<th className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-700">
|
||||
{parkingSpots.length > 0 ? (
|
||||
parkingSpots.map((spot) => (
|
||||
<tr key={spot.id} className="hover:bg-gray-100 border-b border-gray-200">
|
||||
<td className="px-5 py-4 text-sm">{spot.spotUid}</td>
|
||||
<td className="px-5 py-4 text-sm">{spot.locationDesc || 'N/A'}</td>
|
||||
<td className="px-5 py-4 text-sm">
|
||||
<span
|
||||
className={`px-2 py-1 font-semibold leading-tight rounded-full text-xs
|
||||
${spot.status === 'AVAILABLE' ? 'bg-green-100 text-green-700' :
|
||||
spot.status === 'OCCUPIED' ? 'bg-yellow-100 text-yellow-700' :
|
||||
spot.status === 'MAINTENANCE' ? 'bg-red-100 text-red-700' :
|
||||
spot.status === 'RESERVED' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'}
|
||||
`}
|
||||
>
|
||||
{spot.status === 'AVAILABLE' ? '可用' :
|
||||
spot.status === 'OCCUPIED' ? '占用' :
|
||||
spot.status === 'MAINTENANCE' ? '维护中' :
|
||||
spot.status === 'RESERVED' ? '预留' : spot.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm">{spot.robotAssignable ? '是' : '否'}</td>
|
||||
<td className="px-5 py-4 text-sm">{new Date(spot.updateTime).toLocaleString()}</td>
|
||||
<td className="px-5 py-4 text-sm">
|
||||
<button
|
||||
onClick={() => openEditModal(spot)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteConfirm(spot)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-500">
|
||||
未找到车位信息。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalSpots > 0 && (
|
||||
<div className="py-5 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs xs:text-sm text-gray-700 mr-4">
|
||||
显示第 {((queryParams.current || 1) - 1) * (queryParams.pageSize || 10) + 1} 到 {Math.min((queryParams.current || 1) * (queryParams.pageSize || 10), totalSpots)} 条,共 {totalSpots} 条车位记录
|
||||
</span>
|
||||
<select
|
||||
value={queryParams.pageSize}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
className="text-xs xs:text-sm border border-gray-300 rounded-md p-1 shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value={10}>10 条/页</option>
|
||||
<option value={20}>20 条/页</option>
|
||||
<option value={50}>50 条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="inline-flex mt-2 xs:mt-0">
|
||||
<button
|
||||
onClick={() => handlePageChange((queryParams.current || 1) - 1)}
|
||||
disabled={(queryParams.current || 1) <= 1}
|
||||
className="text-sm bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-2 px-4 rounded-l disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange((queryParams.current || 1) + 1)}
|
||||
disabled={((queryParams.current || 1) * (queryParams.pageSize || 10)) >= totalSpots}
|
||||
className="text-sm bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-2 px-4 rounded-r disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminParkingSpotsPage;
|
||||
781
charging_web_app/src/app/(authenticated)/admin/robots/page.tsx
Normal file
781
charging_web_app/src/app/(authenticated)/admin/robots/page.tsx
Normal file
@@ -0,0 +1,781 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import {
|
||||
FiChevronLeft, FiPlus, FiEdit2, FiTrash2, FiSearch,
|
||||
FiFilter, FiTerminal, FiBatteryCharging, FiTool,
|
||||
FiAlertCircle, FiPower, FiMapPin, FiX
|
||||
} from 'react-icons/fi';
|
||||
import { api } from '@/utils/axios'; // 确保 api 实例已配置
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
// 与后端实体 ChargingRobot.java 对齐的接口
|
||||
interface ChargingRobot {
|
||||
id: number; // 后端是 Long,TS 中用 number
|
||||
robotUid: string;
|
||||
status: string; // 例如 "IDLE", "CHARGING"
|
||||
batteryLevel?: number | null;
|
||||
currentLocation?: string | null;
|
||||
lastHeartbeatTime?: string | null; // Date 类型在 JSON 中通常是 string
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
currentTaskId?: number | null;
|
||||
}
|
||||
|
||||
// 后端分页响应结构 (简化版,关注核心字段)
|
||||
interface Page<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
size: number;
|
||||
current: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// 机器人查询请求参数接口 (对应 ChargingRobotQueryRequest.java)
|
||||
interface ChargingRobotQueryRequest {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
robotUid?: string;
|
||||
status?: string; // 'ALL' 表示不按状态筛选,实际发送时空字符串或不传此字段
|
||||
sortField?: string;
|
||||
sortOrder?: 'ascend' | 'descend';
|
||||
}
|
||||
|
||||
// 在 ChargingRobotQueryRequest 定义之后,添加 BaseResponse 接口定义 (如果它还没有在全局定义的话)
|
||||
// 这个定义应该与你的后端 common.BaseResponse 一致
|
||||
interface BaseResponse<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Backend request for adding a robot (assuming structure)
|
||||
interface ChargingRobotAddRequest {
|
||||
robotUid: string;
|
||||
status: string;
|
||||
batteryLevel?: number | null;
|
||||
currentLocation?: string | null;
|
||||
}
|
||||
|
||||
// For Editing Robot - includes ID
|
||||
interface ChargingRobotUpdateRequest {
|
||||
id: number;
|
||||
robotUid?: string; // UID might not be editable, but included based on backend DTO
|
||||
status?: string;
|
||||
batteryLevel?: number | null;
|
||||
currentLocation?: string | null;
|
||||
}
|
||||
|
||||
// Interface for Delete Request (matches backend's DeleteRequest)
|
||||
interface DeleteRobotRequest {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const getRobotStatusDisplay = (status: string | undefined | null) => {
|
||||
if (!status) return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">未知</span>;
|
||||
switch (status.toUpperCase()) {
|
||||
case 'IDLE':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"><FiPower className="mr-1" /> 空闲</span>;
|
||||
case 'CHARGING':
|
||||
case 'CHARGING_IN_PROGRESS': // 兼容可能的枚举值
|
||||
case 'CHARGING_STARTED':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"><FiBatteryCharging className="mr-1" /> 充电中</span>;
|
||||
case 'MAINTENANCE':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"><FiTool className="mr-1" /> 维护中</span>;
|
||||
case 'ERROR':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"><FiAlertCircle className="mr-1" /> 故障</span>;
|
||||
case 'OFFLINE':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700"><FiAlertCircle className="mr-1" /> 离线</span>; // Adjusted colors for offline
|
||||
case 'MOVING':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"><FiTerminal className="mr-1" /> 移动中</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AdminRobotsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [robots, setRobots] = useState<ChargingRobot[]>([]);
|
||||
const [robotStatusTypes, setRobotStatusTypes] = useState<string[]>([]);
|
||||
const [pageInfo, setPageInfo] = useState<Omit<Page<ChargingRobot>, 'records'>>({
|
||||
current: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
});
|
||||
const [filters, setFilters] = useState<{
|
||||
robotUid: string;
|
||||
status: string; // 'ALL' or actual status value
|
||||
}>({ robotUid: '', status: 'ALL' });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// State for Add Robot Modal
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const defaultAddRobotData: ChargingRobotAddRequest = {
|
||||
robotUid: '',
|
||||
status: 'IDLE',
|
||||
batteryLevel: null, // Default to null or a sensible number like 100
|
||||
currentLocation: ''
|
||||
};
|
||||
const [newRobotData, setNewRobotData] = useState<ChargingRobotAddRequest>(defaultAddRobotData);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
// State for Delete Robot Modal
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [robotToDelete, setRobotToDelete] = useState<ChargingRobot | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [isDeletingRobot, setIsDeletingRobot] = useState(false);
|
||||
|
||||
// State for Edit Robot Modal
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [robotToEditData, setRobotToEditData] = useState<ChargingRobotUpdateRequest | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [isEditingRobot, setIsEditingRobot] = useState(false);
|
||||
|
||||
// 认证和权限检查
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (isAuthenticated && user?.role !== 'admin') {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [authLoading, isAuthenticated, user, router]);
|
||||
|
||||
// 获取机器人状态类型
|
||||
const fetchRobotStatusTypes = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<BaseResponse<string[]>>('/admin/robot/status/types');
|
||||
if (response.data && response.data.code === 0 && Array.isArray(response.data.data)) {
|
||||
setRobotStatusTypes(response.data.data);
|
||||
// If newRobotData.status is default and status types are loaded, set to the first available status
|
||||
if (newRobotData.status === 'IDLE' && response.data.data.length > 0) {
|
||||
setNewRobotData(prev => ({ ...prev, status: response.data.data[0] }));
|
||||
} else if (response.data.data.length === 0 && newRobotData.status !== 'IDLE') {
|
||||
setNewRobotData(prev => ({ ...prev, status: 'IDLE' })); // Fallback if no types
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch robot status types or data format is incorrect:", response.data);
|
||||
setRobotStatusTypes([]);
|
||||
setNewRobotData(prev => ({ ...prev, status: 'IDLE' })); // Fallback
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("API error fetching robot status types:", err);
|
||||
setRobotStatusTypes([]);
|
||||
setNewRobotData(prev => ({ ...prev, status: 'IDLE' })); // Fallback
|
||||
}
|
||||
}, [newRobotData.status]); // Added newRobotData.status to dependencies to re-evaluate default
|
||||
|
||||
// 获取机器人列表
|
||||
const fetchRobots = useCallback(async (currentPage: number, currentPageSize: number, currentFilters: typeof filters) => {
|
||||
if (!isAuthenticated || user?.role !== 'admin') return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const queryParams: ChargingRobotQueryRequest = {
|
||||
current: currentPage,
|
||||
pageSize: currentPageSize,
|
||||
robotUid: currentFilters.robotUid || undefined,
|
||||
status: currentFilters.status === 'ALL' ? undefined : currentFilters.status,
|
||||
sortField: 'create_time',
|
||||
sortOrder: 'descend',
|
||||
};
|
||||
|
||||
try {
|
||||
// Correctly expect BaseResponse wrapping Page<ChargingRobot>
|
||||
const response = await api.post<BaseResponse<Page<ChargingRobot>>>('/admin/robot/list/page', queryParams);
|
||||
|
||||
if (response.data && response.data.code === 0 && response.data.data) {
|
||||
const pageData = response.data.data; // This is the Page<ChargingRobot> object
|
||||
setRobots(pageData.records || []);
|
||||
setPageInfo({
|
||||
current: pageData.current,
|
||||
size: pageData.size,
|
||||
total: pageData.total,
|
||||
pages: pageData.pages,
|
||||
});
|
||||
} else {
|
||||
console.error("Failed to fetch robots or data format is incorrect:", response.data);
|
||||
setError(response.data?.message || "加载机器人列表失败,数据格式不正确。");
|
||||
setRobots([]);
|
||||
setPageInfo(prev => ({ ...prev, total: 0, pages: 0, current: 1 }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch robots:", err);
|
||||
const axiosError = err as AxiosError;
|
||||
if (axiosError.response) {
|
||||
setError(`加载机器人列表失败: ${axiosError.response.status} ${(axiosError.response.data as any)?.message || axiosError.message}`);
|
||||
} else if (axiosError.request) {
|
||||
setError("加载机器人列表失败: 未收到服务器响应。");
|
||||
} else {
|
||||
setError(`加载机器人列表失败: ${axiosError.message}`);
|
||||
}
|
||||
setRobots([]);
|
||||
setPageInfo(prev => ({ ...prev, total:0, pages:0, current: 1}));
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [isAuthenticated, user?.role]);
|
||||
|
||||
// 初始加载和当认证通过时加载
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
fetchRobotStatusTypes();
|
||||
fetchRobots(pageInfo.current, pageInfo.size, filters);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, user?.role]); // 依赖于认证状态,确保只在管理员登录后执行
|
||||
// fetchRobots 和 fetchRobotStatusTypes 使用 useCallback 固化,不需要加入依赖
|
||||
|
||||
// 处理筛选条件或分页变化
|
||||
const handleFilterChange = () => {
|
||||
// 重置到第一页进行新的筛选
|
||||
fetchRobots(1, pageInfo.size, filters);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchRobots(newPage, pageInfo.size, filters);
|
||||
};
|
||||
|
||||
const handleOpenAddModal = () => {
|
||||
setNewRobotData({ ...defaultAddRobotData, status: robotStatusTypes.length > 0 ? robotStatusTypes[0] : 'IDLE' });
|
||||
setAddError(null);
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAddRobotSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAddError(null);
|
||||
if (!newRobotData.robotUid) {
|
||||
setAddError("机器人UID不能为空。");
|
||||
return;
|
||||
}
|
||||
if (!newRobotData.status) {
|
||||
setAddError("机器人状态不能为空。");
|
||||
return;
|
||||
}
|
||||
|
||||
// Battery level validation (example)
|
||||
if (newRobotData.batteryLevel !== null && newRobotData.batteryLevel !== undefined) {
|
||||
const battery = Number(newRobotData.batteryLevel);
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
setAddError("电池电量必须是0到100之间的数字。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true); // Or a specific loading state like setIsAddingRobot(true)
|
||||
try {
|
||||
const response = await api.post<BaseResponse<ChargingRobot>>('/admin/robot/add', newRobotData);
|
||||
if (response.data && response.data.code === 0) {
|
||||
setIsAddModalOpen(false);
|
||||
fetchRobots(pageInfo.current, pageInfo.size, filters); // Refresh list
|
||||
} else {
|
||||
setAddError(response.data.message || "添加机器人失败,请重试。");
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as AxiosError;
|
||||
console.error("Error adding robot:", axiosError);
|
||||
if (axiosError.response && axiosError.response.data && typeof (axiosError.response.data as any).message === 'string') {
|
||||
setAddError((axiosError.response.data as any).message);
|
||||
} else {
|
||||
setAddError("添加机器人失败,发生未知错误。");
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Handlers for Delete Robot Modal
|
||||
const handleOpenDeleteModal = (robot: ChargingRobot) => {
|
||||
setRobotToDelete(robot);
|
||||
setDeleteError(null);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteRobotSubmit = async () => {
|
||||
if (!robotToDelete) return;
|
||||
setIsDeletingRobot(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const deletePayload: DeleteRobotRequest = { id: robotToDelete.id };
|
||||
const response = await api.post<BaseResponse<boolean>>('/admin/robot/delete', deletePayload);
|
||||
if (response.data && response.data.code === 0 && response.data.data === true) {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRobotToDelete(null);
|
||||
// Refresh list. If current page becomes empty after deletion, try to go to previous page or first page.
|
||||
const newTotal = pageInfo.total - 1;
|
||||
const newPages = Math.ceil(newTotal / pageInfo.size);
|
||||
let newCurrentPage = pageInfo.current;
|
||||
if (robots.length === 1 && pageInfo.current > 1) { // If it was the last item on a page > 1
|
||||
newCurrentPage = pageInfo.current - 1;
|
||||
}
|
||||
if (newTotal === 0) { // if list becomes empty
|
||||
newCurrentPage = 1;
|
||||
}
|
||||
fetchRobots(newCurrentPage, pageInfo.size, filters);
|
||||
// Optionally: Show success toast
|
||||
} else {
|
||||
setDeleteError(response.data?.message || "删除机器人失败。");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete robot:", err);
|
||||
const axiosError = err as AxiosError;
|
||||
if (axiosError.response) {
|
||||
setDeleteError(`删除失败: ${(axiosError.response.data as any)?.message || axiosError.message}`);
|
||||
} else {
|
||||
setDeleteError("删除机器人失败,请稍后再试。");
|
||||
}
|
||||
}
|
||||
setIsDeletingRobot(false);
|
||||
};
|
||||
|
||||
// Handlers for Edit Robot Modal
|
||||
const handleOpenEditModal = (robot: ChargingRobot) => {
|
||||
setRobotToEditData({
|
||||
id: robot.id,
|
||||
robotUid: robot.robotUid,
|
||||
status: robot.status,
|
||||
batteryLevel: robot.batteryLevel,
|
||||
currentLocation: robot.currentLocation || '',
|
||||
});
|
||||
setEditError(null);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditRobotSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!robotToEditData) return;
|
||||
setEditError(null);
|
||||
|
||||
// Validate battery level
|
||||
if (robotToEditData.batteryLevel !== null && robotToEditData.batteryLevel !== undefined) {
|
||||
const battery = Number(robotToEditData.batteryLevel);
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
setEditError("电池电量必须是0到100之间的数字。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsEditingRobot(true);
|
||||
try {
|
||||
// Ensure robotToEditData has the ID
|
||||
const payload: ChargingRobotUpdateRequest = {
|
||||
id: robotToEditData.id, // ID is crucial
|
||||
robotUid: robotToEditData.robotUid, // UID might be part of the form or fixed
|
||||
status: robotToEditData.status,
|
||||
batteryLevel: robotToEditData.batteryLevel,
|
||||
currentLocation: robotToEditData.currentLocation,
|
||||
};
|
||||
const response = await api.post<BaseResponse<null>>('/admin/robot/update', payload);
|
||||
if (response.data && response.data.code === 0) {
|
||||
setIsEditModalOpen(false);
|
||||
fetchRobots(pageInfo.current, pageInfo.size, filters); // Refresh list
|
||||
} else {
|
||||
setEditError(response.data.message || "更新机器人失败,请重试。");
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as AxiosError;
|
||||
console.error("Error updating robot:", axiosError);
|
||||
if (axiosError.response && axiosError.response.data && typeof (axiosError.response.data as any).message === 'string') {
|
||||
setEditError((axiosError.response.data as any).message);
|
||||
} else {
|
||||
setEditError("更新机器人失败,发生未知错误。");
|
||||
}
|
||||
}
|
||||
setIsEditingRobot(false);
|
||||
};
|
||||
|
||||
// 权限未加载或用户非管理员时显示加载
|
||||
if (authLoading || !isAuthenticated || (isAuthenticated && user?.role !== 'admin')) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 md:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<header className="mb-6 md:mb-8">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 flex items-center mb-4 md:mb-0">
|
||||
<FiTerminal className="mr-3 text-indigo-600" /> 机器人管理
|
||||
</h1>
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
legacyBehavior={false}
|
||||
className="flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
>
|
||||
<FiChevronLeft className="mr-1" /> 返回管理员控制台
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Filters and Actions */}
|
||||
<div className="mb-6 p-4 bg-white rounded-xl shadow-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div>
|
||||
<label htmlFor="searchRobotUid" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<FiSearch className="inline mr-1" /> 搜索UID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="searchRobotUid"
|
||||
placeholder="RBT-xxxx"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={filters.robotUid}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, robotUid: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilterChange()} // 支持回车搜索
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="statusFilter" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<FiFilter className="inline mr-1" /> 按状态筛选
|
||||
</label>
|
||||
<select
|
||||
id="statusFilter"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
|
||||
>
|
||||
<option value="ALL">所有状态</option>
|
||||
{robotStatusTypes.map(statusType => (
|
||||
<option key={statusType} value={statusType}>{getRobotStatusDisplay(statusType)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex space-x-2 items-end">
|
||||
<button
|
||||
onClick={handleFilterChange}
|
||||
className="w-full md:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenAddModal} // Changed to open modal
|
||||
className="w-full md:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<FiPlus className="mr-2 -ml-1 h-5 w-5" /> 添加机器人
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && !robots.length ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="text-center py-12 bg-white rounded-xl shadow-lg">
|
||||
<FiAlertCircle size={48} className="mx-auto text-red-500 mb-4" />
|
||||
<h3 className="text-xl font-medium text-red-700">加载失败</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
) : robots.length > 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">UID</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">电量</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">位置</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">最近心跳</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">任务ID</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{robots.map((robot) => (
|
||||
<tr key={robot.id}>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">{robot.robotUid}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">{getRobotStatusDisplay(robot.status)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
{robot.batteryLevel != null ? (
|
||||
<div className="flex items-center">
|
||||
<div className="w-20 bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 mr-2">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${robot.batteryLevel < 20 ? 'bg-red-500' : robot.batteryLevel < 50 ? 'bg-yellow-400' : 'bg-green-500'}`}
|
||||
style={{ width: `${robot.batteryLevel}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs">{robot.batteryLevel}%</span>
|
||||
</div>
|
||||
) : 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiMapPin className="inline mr-1"/>{robot.currentLocation || 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{robot.lastHeartbeatTime ? new Date(robot.lastHeartbeatTime).toLocaleString() : 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{robot.currentTaskId || 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onClick={() => handleOpenEditModal(robot)} className="text-indigo-600 hover:text-indigo-900"><FiEdit2 size={16}/></button>
|
||||
<button onClick={() => handleOpenDeleteModal(robot)} className="text-red-600 hover:text-red-900"><FiTrash2 size={16}/></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-xl shadow-lg">
|
||||
<FiTerminal size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-700">未找到机器人</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{filters.robotUid || filters.status !== 'ALL' ? '没有机器人符合当前筛选条件。' : '系统中暂无机器人记录,请先添加。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls - Server-side */}
|
||||
{pageInfo.total > 0 && (
|
||||
<div className="mt-6 flex justify-center items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pageInfo.current - 1)}
|
||||
disabled={pageInfo.current <= 1 || isLoading}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
{[...Array(pageInfo.pages).keys()].map(number => (
|
||||
<button
|
||||
key={number + 1}
|
||||
onClick={() => handlePageChange(number + 1)}
|
||||
disabled={isLoading}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${pageInfo.current === number + 1 ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed'}`}
|
||||
>
|
||||
{number + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handlePageChange(pageInfo.current + 1)}
|
||||
disabled={pageInfo.current >= pageInfo.pages || isLoading}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">共 {pageInfo.total} 条记录</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Robot Modal */}
|
||||
{isAddModalOpen && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg shadow-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">添加新机器人</h2>
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<FiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddRobotSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="addRobotUid" className="block text-sm font-medium text-gray-700 mb-1">机器人UID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addRobotUid"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={newRobotData.robotUid}
|
||||
onChange={(e) => setNewRobotData({ ...newRobotData, robotUid: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="addRobotStatus" className="block text-sm font-medium text-gray-700 mb-1">初始状态</label>
|
||||
<select
|
||||
id="addRobotStatus"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={newRobotData.status}
|
||||
onChange={(e) => setNewRobotData({ ...newRobotData, status: e.target.value })}
|
||||
>
|
||||
{robotStatusTypes.length > 0 ? robotStatusTypes.map(status => (
|
||||
<option key={`add-${status}`} value={status}>{getRobotStatusDisplay(status)}</option>
|
||||
)) : (
|
||||
<option value="IDLE">{getRobotStatusDisplay('IDLE')}</option>
|
||||
)}
|
||||
{!robotStatusTypes.includes('IDLE') && <option value="IDLE">{getRobotStatusDisplay('IDLE')}</option>}
|
||||
{!robotStatusTypes.includes('MAINTENANCE') && <option value="MAINTENANCE">{getRobotStatusDisplay('MAINTENANCE')}</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="addBatteryLevel" className="block text-sm font-medium text-gray-700 mb-1">电池电量 (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="addBatteryLevel"
|
||||
min="0" max="100"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={newRobotData.batteryLevel == null ? '' : newRobotData.batteryLevel}
|
||||
onChange={(e) => setNewRobotData({ ...newRobotData, batteryLevel: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="addCurrentLocation" className="block text-sm font-medium text-gray-700 mb-1">当前位置</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addCurrentLocation"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={newRobotData.currentLocation || ''}
|
||||
onChange={(e) => setNewRobotData({ ...newRobotData, currentLocation: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{addError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md text-sm">
|
||||
{addError}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAddModalOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
确认添加
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Robot Modal */}
|
||||
{isEditModalOpen && robotToEditData && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg shadow-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">编辑机器人信息</h2>
|
||||
<button onClick={() => { setIsEditModalOpen(false); setRobotToEditData(null); }} className="text-gray-400 hover:text-gray-600">
|
||||
<FiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEditRobotSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="editRobotUid" className="block text-sm font-medium text-gray-700 mb-1">机器人UID (不可修改)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editRobotUid"
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 cursor-not-allowed"
|
||||
value={robotToEditData.robotUid || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="editRobotStatus" className="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<select
|
||||
id="editRobotStatus"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={robotToEditData.status}
|
||||
onChange={(e) => setRobotToEditData({ ...robotToEditData, status: e.target.value })}
|
||||
>
|
||||
{robotStatusTypes.map(status => (
|
||||
<option key={`edit-${status}`} value={status}>{getRobotStatusDisplay(status)}</option>
|
||||
))}
|
||||
{!robotStatusTypes.includes('IDLE') && <option value="IDLE">{getRobotStatusDisplay('IDLE')}</option>}
|
||||
{!robotStatusTypes.includes('MAINTENANCE') && <option value="MAINTENANCE">{getRobotStatusDisplay('MAINTENANCE')}</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="editBatteryLevel" className="block text-sm font-medium text-gray-700 mb-1">电池电量 (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="editBatteryLevel"
|
||||
min="0" max="100"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={robotToEditData.batteryLevel == null ? '' : robotToEditData.batteryLevel}
|
||||
onChange={(e) => setRobotToEditData({ ...robotToEditData, batteryLevel: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="editCurrentLocation" className="block text-sm font-medium text-gray-700 mb-1">当前位置</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editCurrentLocation"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={robotToEditData.currentLocation || ''}
|
||||
onChange={(e) => setRobotToEditData({ ...robotToEditData, currentLocation: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md text-sm">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsEditModalOpen(false); setRobotToEditData(null); }}
|
||||
disabled={isEditingRobot}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEditingRobot}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isEditingRobot ? '保存中...' : '保存更改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Robot Modal */}
|
||||
{isDeleteModalOpen && robotToDelete && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg shadow-xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">确认删除</h2>
|
||||
<button onClick={() => { setIsDeleteModalOpen(false); setRobotToDelete(null); }} className="text-gray-400 hover:text-gray-600">
|
||||
<FiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
您确定要删除机器人 <span className="font-semibold">{robotToDelete.robotUid}</span> 吗?此操作无法撤销。
|
||||
</p>
|
||||
{deleteError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md text-sm">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsDeleteModalOpen(false); setRobotToDelete(null); }}
|
||||
disabled={isDeletingRobot}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button" // Changed to button, onSubmit is handled by the modal's primary action button
|
||||
onClick={handleDeleteRobotSubmit}
|
||||
disabled={isDeletingRobot}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||
>
|
||||
{isDeletingRobot ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { api, BaseResponse } from '@/utils/axios';
|
||||
import { FiZap, FiClock, FiAlertCircle, FiCheckCircle, FiPower, FiArrowLeft, FiInfo } from 'react-icons/fi';
|
||||
|
||||
// 与 dashboard/page.tsx 类似的活动会话接口,可能需要根据后端VO调整
|
||||
interface ActiveChargingSession {
|
||||
id: number;
|
||||
spotUidSnapshot: string | null;
|
||||
robotUidSnapshot?: string | null;
|
||||
chargeStartTime: string | null; // ISO String Date
|
||||
status: string;
|
||||
// 根据需要可以从后端 ChargingSessionVO 添加更多字段
|
||||
// e.g., energyConsumedKwh, cost (though these might be final values)
|
||||
}
|
||||
|
||||
const POLLING_INTERVAL = 5000; // 5 seconds for polling
|
||||
|
||||
// 定义终止状态常量,用于组件内多处使用
|
||||
const TERMINAL_STATUSES = ['COMPLETED', 'PAID', 'CANCELLED_BY_USER', 'CANCELLED_BY_SYSTEM', 'ERROR', 'ROBOT_TASK_TIMEOUT'];
|
||||
|
||||
export default function ChargingStatusPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [session, setSession] = useState<ActiveChargingSession | null | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [elapsedTime, setElapsedTime] = useState("00:00:00");
|
||||
|
||||
const fetchActiveSession = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const response = await api.get<BaseResponse<ActiveChargingSession | null>>('/session/my/active');
|
||||
console.log('Active charging session response:', response.data);
|
||||
if (response.data && response.data.code === 0) {
|
||||
const activeSessionData = response.data.data;
|
||||
setSession(activeSessionData);
|
||||
setError(null);
|
||||
if (activeSessionData) {
|
||||
console.log('Current session status:', activeSessionData.status);
|
||||
if (TERMINAL_STATUSES.includes(activeSessionData.status)) {
|
||||
setTimeout(() => router.push('/dashboard'), 3000);
|
||||
}
|
||||
} else {
|
||||
console.log('No active charging session found');
|
||||
}
|
||||
} else {
|
||||
setError(response.data?.message || '获取充电状态失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching active session:", err);
|
||||
setError(err.response?.data?.message || err.message || '网络错误,无法获取充电状态');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (isAuthenticated) {
|
||||
setIsLoading(true);
|
||||
fetchActiveSession();
|
||||
}
|
||||
}, [authLoading, isAuthenticated, router, fetchActiveSession]);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: NodeJS.Timeout;
|
||||
if (isAuthenticated && session && !isLoading) {
|
||||
if (session && !TERMINAL_STATUSES.includes(session.status)) {
|
||||
intervalId = setInterval(fetchActiveSession, POLLING_INTERVAL);
|
||||
}
|
||||
}
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isAuthenticated, session, isLoading, fetchActiveSession]);
|
||||
|
||||
const calculateDuration = useCallback((startTime: string | null): string => {
|
||||
if (!startTime) return '00:00:00';
|
||||
const start = new Date(startTime).getTime();
|
||||
const now = Date.now();
|
||||
if (now < start) return '00:00:00';
|
||||
let diff = Math.floor((now - start) / 1000);
|
||||
const hours = String(Math.floor(diff / 3600)).padStart(2, '0');
|
||||
diff %= 3600;
|
||||
const minutes = String(Math.floor(diff / 60)).padStart(2, '0');
|
||||
const seconds = String(diff % 60).padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: NodeJS.Timeout;
|
||||
if (session && session.chargeStartTime && (session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS')) {
|
||||
setElapsedTime(calculateDuration(session.chargeStartTime)); // Initial calculation
|
||||
timerId = setInterval(() => {
|
||||
setElapsedTime(calculateDuration(session.chargeStartTime));
|
||||
}, 1000);
|
||||
} else if (session && session.chargeStartTime) {
|
||||
setElapsedTime(calculateDuration(session.chargeStartTime)); // Calculate final duration once if session ended
|
||||
} else {
|
||||
setElapsedTime('00:00:00');
|
||||
}
|
||||
return () => clearInterval(timerId);
|
||||
}, [session, calculateDuration]);
|
||||
|
||||
const handleStopCharging = async () => {
|
||||
if (!session || !session.id) {
|
||||
setError("无法停止充电:无效的会话信息。");
|
||||
return;
|
||||
}
|
||||
setIsStopping(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post<BaseResponse<any>>(`/session/stop`, { sessionId: session.id });
|
||||
if (response.data && response.data.code === 0) {
|
||||
// Rely on polling to update the session state
|
||||
// Optionally, trigger an immediate refresh:
|
||||
fetchActiveSession();
|
||||
} else {
|
||||
setError(response.data?.message || "停止充电请求失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error stopping charging session:", err);
|
||||
setError(err.response?.data?.message || err.message || "停止充电时发生错误");
|
||||
}
|
||||
setIsStopping(false);
|
||||
};
|
||||
|
||||
if (authLoading || (isLoading && session === undefined)) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div className="p-4 text-center">请先登录。</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 md:p-8 flex flex-col items-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<header className="mb-8 flex items-center">
|
||||
<Link href="/dashboard" className="text-indigo-600 hover:text-indigo-700 mr-4">
|
||||
<FiArrowLeft size={24} />
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-800">充电状态</h1>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-100 text-red-700 border border-red-300 rounded-md shadow-sm" role="alert">
|
||||
<div className="flex items-center">
|
||||
<FiAlertCircle className="mr-2" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session === null && !error && (
|
||||
<div className="bg-white p-8 rounded-xl shadow-xl text-center">
|
||||
<FiInfo size={48} className="mx-auto text-blue-500 mb-4" />
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-2">无活动充电会话</h2>
|
||||
<p className="text-gray-600 mb-6">您当前没有正在进行的充电。是否要开始新的充电?</p>
|
||||
<Link href="/request-charging" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg shadow-md transition duration-150 ease-in-out">
|
||||
前往充电
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session && (
|
||||
<div className="bg-white p-6 md:p-8 rounded-xl shadow-xl">
|
||||
<div className="mb-6 border-b pb-4 border-gray-200">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-1">会话ID: {session.id}</h2>
|
||||
<p className={`text-lg font-medium ${session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS' ? 'text-green-500' : 'text-gray-600'}`}>
|
||||
状态: {session.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">车位编号</p>
|
||||
<p className="text-lg font-medium text-gray-800">{session.spotUidSnapshot || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">机器人编号</p>
|
||||
<p className="text-lg font-medium text-gray-800">{session.robotUidSnapshot || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">开始时间</p>
|
||||
<p className="text-lg font-medium text-gray-800">
|
||||
{session.chargeStartTime ? new Date(session.chargeStartTime).toLocaleString() : '等待开始'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">已充电时长</p>
|
||||
<p className="text-2xl font-bold text-indigo-600 tabular-nums">
|
||||
<FiClock className="inline mr-2 mb-1" />{elapsedTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<p className="text-sm text-gray-500 mb-1">充电进度 (基于时间)</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||
<div className={`bg-green-500 h-4 ${session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS' ? 'animate-pulse-fast' : ''}`} style={{ width: '100%' }}></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">充电电量将根据总充电时间计算。</p>
|
||||
</div>
|
||||
|
||||
{/* 根据会话状态显示不同的内容 */}
|
||||
{(session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS' || session.status === 'ROBOT_ARRIVED' || session.status === 'ROBOT_ASSIGNED') ? (
|
||||
// 显示停止充电按钮
|
||||
<button
|
||||
onClick={handleStopCharging}
|
||||
disabled={isStopping}
|
||||
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg shadow-md transition duration-150 ease-in-out disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
正在停止...
|
||||
</>
|
||||
) : (
|
||||
<><FiPower className="mr-2" /> 停止充电</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
// 会话状态不适合显示停止按钮,显示原因
|
||||
<div className="mb-2 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-sm text-yellow-700">
|
||||
当前会话状态为 <strong>{session.status}</strong>,无法执行停止充电操作。
|
||||
{TERMINAL_STATUSES.includes(session.status) && " 会话已结束或处于终止状态。"}
|
||||
</div>
|
||||
)}
|
||||
{(session.status === 'COMPLETED' || session.status === 'PAID' || session.status === 'PAYMENT_PENDING') && (
|
||||
<div className="mt-6 text-center p-4 bg-green-50 border-green-200 border rounded-md">
|
||||
<FiCheckCircle size={28} className="text-green-500 mx-auto mb-2" />
|
||||
<p className="text-green-700 font-semibold">充电已完成或等待支付。</p>
|
||||
<p className="text-sm text-gray-600">感谢您的使用!</p>
|
||||
<Link href="/my-sessions" className="mt-3 inline-block text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
查看充电记录
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { api, BaseResponse } from '@/utils/axios'; // Import the api instance
|
||||
import { FiDollarSign, FiZap, FiClock, FiList, FiChevronRight, FiInfo, FiPower, FiAlertCircle } from 'react-icons/fi';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { user, logout, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
// Interface for active charging session (matches ChargingSessionVO fields used)
|
||||
interface ActiveChargingSession {
|
||||
id: number;
|
||||
spotUidSnapshot: string | null;
|
||||
chargeStartTime: string | null; // 后端是 Date 类型,前端收到的是 string
|
||||
status: string; // 例如 "CHARGING_STARTED"
|
||||
// 可以按需添加其他来自后端 ChargingSession 实体的字段
|
||||
robotUidSnapshot?: string | null;
|
||||
requestTime?: string | null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Interface for user dashboard statistics from backend
|
||||
interface UserDashboardStats {
|
||||
monthlySessions: number; // 后端返回的可能是 monthlyCharges 字段
|
||||
monthlySpending: number;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
// AuthenticatedLayout 应该已经处理了重定向
|
||||
// 但作为备用,可以显示加载或 null
|
||||
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
|
||||
}
|
||||
|
||||
// 如果用户是管理员,但意外访问了普通用户dashboard,则重定向到管理员dashboard
|
||||
// 这一步是可选的,因为AuthContext中的login已经做了角色判断和重定向
|
||||
// 但作为额外的保护层,防止用户通过直接输入URL访问不匹配的dashboard
|
||||
if (user.role === 'admin') {
|
||||
router.replace('/admin/dashboard');
|
||||
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
|
||||
}
|
||||
export default function DashboardPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 pt-20">
|
||||
<div className="bg-white shadow-md rounded-lg p-8 max-w-md mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
|
||||
欢迎, {user.username}!
|
||||
</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-lg text-gray-700">
|
||||
<span className="font-semibold">用户ID:</span> {user.id}
|
||||
</p>
|
||||
<p className="text-lg text-gray-700">
|
||||
<span className="font-semibold">角色:</span> {user.role}
|
||||
</p>
|
||||
<p className="text-lg text-gray-700">
|
||||
<span className="font-semibold">余额:</span> ¥{user.balance.toFixed(2)}
|
||||
</p>
|
||||
const [activeSession, setActiveSession] = useState<ActiveChargingSession | null | undefined>(undefined);
|
||||
const [userStats, setUserStats] = useState<UserDashboardStats | null | undefined>(undefined);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
router.replace('/admin/dashboard');
|
||||
}
|
||||
}, [authLoading, isAuthenticated, user, router]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!isAuthenticated || user?.role !== 'user') return;
|
||||
|
||||
setDataLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 使用 Promise.allSettled 来处理部分请求失败的情况,如果需要
|
||||
const [sessionResult, statsResult] = await Promise.all([
|
||||
api.get<BaseResponse<ActiveChargingSession | null>>('/session/my/active'),
|
||||
api.get<BaseResponse<UserDashboardStats>>('/user/stats/mine')
|
||||
]);
|
||||
|
||||
if (sessionResult.data && sessionResult.data.code === 0) { // 假设 code 0 代表成功
|
||||
console.log("Active session response:", sessionResult.data);
|
||||
setActiveSession(sessionResult.data.data); // data 可以是 null
|
||||
} else {
|
||||
// setActiveSession(null); // 或者根据后端错误信息设置
|
||||
console.warn("Failed to fetch active session or no active session:", sessionResult.data?.message);
|
||||
setActiveSession(null); // 如果没有活动会话,后端data.data可能为null,是正常情况
|
||||
}
|
||||
|
||||
if (statsResult.data && statsResult.data.code === 0 && statsResult.data.data) {
|
||||
console.log("User stats response:", statsResult.data.data);
|
||||
// 检查后端返回的是monthlyCharges还是monthlySessions,并进行适当转换
|
||||
const statsData = statsResult.data.data;
|
||||
if ('monthlyCharges' in statsData && statsData.monthlyCharges !== undefined) {
|
||||
// 如果后端返回的是monthlyCharges字段,映射到monthlySessions
|
||||
setUserStats({
|
||||
monthlySessions: statsData.monthlyCharges as number,
|
||||
monthlySpending: statsData.monthlySpending
|
||||
});
|
||||
} else {
|
||||
// 否则假定数据结构已匹配接口
|
||||
setUserStats(statsData);
|
||||
}
|
||||
} else {
|
||||
setUserStats(null);
|
||||
console.error("Failed to fetch user stats:", statsResult.data?.message);
|
||||
// setError(prev => prev ? prev + '\n' + (statsResult.data?.message || '获取用户统计失败') : (statsResult.data?.message || '获取用户统计失败'));
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching dashboard data:", err);
|
||||
const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败,请稍后再试。';
|
||||
setError(prevError => prevError ? `${prevError}\n${errorMessage}` : errorMessage);
|
||||
setActiveSession(null);
|
||||
setUserStats(null);
|
||||
}
|
||||
setDataLoading(false);
|
||||
}, [isAuthenticated, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.role === 'user') {
|
||||
fetchData();
|
||||
} else if (!authLoading && !isAuthenticated) {
|
||||
// Handle cases where user is not authenticated and not loading
|
||||
setDataLoading(false); // Stop loading if not fetching
|
||||
}
|
||||
}, [fetchData, isAuthenticated, user, authLoading]);
|
||||
|
||||
// 直接从仪表盘停止充电
|
||||
const handleStopCharging = async () => {
|
||||
if (!activeSession || !activeSession.id) {
|
||||
setError("无法停止充电:无效的会话信息。");
|
||||
return;
|
||||
}
|
||||
setIsStopping(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post<BaseResponse<any>>(`/session/stop`, { sessionId: activeSession.id });
|
||||
if (response.data && response.data.code === 0) {
|
||||
// 停止成功后重新获取数据
|
||||
fetchData();
|
||||
} else {
|
||||
setError(response.data?.message || "停止充电请求失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error stopping charging session:", err);
|
||||
setError(err.response?.data?.message || err.message || "停止充电时发生错误");
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || (dataLoading && user?.role === 'user')) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return <LoadingSpinner />; // Or a specific message, though redirect should handle
|
||||
}
|
||||
|
||||
if (user.role !== 'user') {
|
||||
// This case should ideally be handled by the redirect in the first useEffect
|
||||
// but as a fallback:
|
||||
return <div className="p-4 text-center">非用户角色,正在尝试重定向...</div>;
|
||||
}
|
||||
|
||||
const StatCard = ({ title, value, icon, unit, isLoading }: { title: string; value: string | number; icon: React.ReactNode; unit?: string; isLoading?: boolean }) => (
|
||||
<div className={`bg-white p-6 rounded-xl shadow-lg flex items-center space-x-4 ${isLoading ? 'animate-pulse' : ''}`}>
|
||||
<div className={`p-3 rounded-full ${isLoading ? 'bg-gray-200' : 'bg-indigo-100 text-indigo-600'}`}>
|
||||
{!isLoading && icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{title}</p>
|
||||
{isLoading ? (
|
||||
<div className="h-6 bg-gray-200 rounded w-20 mt-1"></div>
|
||||
) : (
|
||||
<p className="text-2xl font-semibold text-gray-800">
|
||||
{value} {unit && <span className="text-sm text-gray-500">{unit}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
// logout 函数内部会处理路由跳转到 /login
|
||||
}}
|
||||
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const formatMonthlySpending = (spending: number | undefined | null): string => {
|
||||
if (typeof spending === 'number') {
|
||||
return `¥${spending.toFixed(2)}`;
|
||||
}
|
||||
return '--';
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<header className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800">用户中心</h1>
|
||||
{/* Logout button is in global layout, no need here if using that strategy */}
|
||||
</header>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-2">欢迎, {user.username}!</h2>
|
||||
<p className="text-gray-600 mb-1"><span className="font-medium">用户ID:</span> {user.id}</p>
|
||||
<p className="text-gray-600 mb-1"><span className="font-medium">角色:</span> {user.role === 'user' ? '普通用户' : user.role}</p>
|
||||
<p className="text-gray-600"><span className="font-medium">账户余额:</span> ¥{user.balance?.toFixed(2) ?? 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-300 rounded-md whitespace-pre-line">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="本月充电次数"
|
||||
value={userStats?.monthlySessions ?? '--'}
|
||||
icon={<FiZap size={24} />}
|
||||
unit="次"
|
||||
isLoading={dataLoading || userStats === undefined}
|
||||
/>
|
||||
<StatCard
|
||||
title="本月总消费"
|
||||
value={formatMonthlySpending(userStats?.monthlySpending)}
|
||||
icon={<FiDollarSign size={24} />}
|
||||
isLoading={dataLoading || userStats === undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current Charging Session Status */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-3 flex items-center">
|
||||
<span className="mr-2 text-indigo-600"><FiClock size={22} /></span> 当前充电状态
|
||||
</h3>
|
||||
{dataLoading || activeSession === undefined ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
) : activeSession ? (
|
||||
<div>
|
||||
<p className="text-gray-700"><span className="font-medium">状态:</span> <span className="text-green-500 font-semibold">{activeSession.status || '进行中'}</span></p>
|
||||
<p className="text-gray-600"><span className="font-medium">车位:</span> {activeSession.spotUidSnapshot || 'N/A'}</p>
|
||||
<p className="text-gray-600"><span className="font-medium">开始时间:</span> {activeSession.chargeStartTime ? new Date(activeSession.chargeStartTime).toLocaleString() : 'N/A'}</p>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="mt-6 flex flex-col md:flex-row gap-3">
|
||||
{/* 前往充电状态详情页的按钮 */}
|
||||
<Link href="/charging-status" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-md shadow-sm transition duration-150 ease-in-out text-center">
|
||||
查看详细状态
|
||||
</Link>
|
||||
|
||||
{/* 仅在特定状态下显示停止充电按钮 */}
|
||||
{(activeSession.status === 'CHARGING_STARTED' ||
|
||||
activeSession.status === 'CHARGING_IN_PROGRESS' ||
|
||||
activeSession.status === 'ROBOT_ARRIVED' ||
|
||||
activeSession.status === 'ROBOT_ASSIGNED') && (
|
||||
<button
|
||||
onClick={handleStopCharging}
|
||||
disabled={isStopping}
|
||||
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-2 px-4 rounded-md shadow-sm transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<><FiPower className="mr-2" />停止充电</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700 flex items-center">
|
||||
<FiAlertCircle className="mr-2" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3">您可以查看充电详情或直接停止充电</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600 flex items-center"><FiInfo className="mr-2 text-blue-500" />暂无正在进行的充电。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Card/Button to Start New Charging Session */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-indigo-600 p-6 md:p-8 rounded-xl shadow-xl text-white text-center">
|
||||
<h3 className="text-2xl font-bold mb-4">开始新的充电之旅</h3>
|
||||
<p className="mb-6 text-blue-100">
|
||||
选择一个可用的车位,让我们的智能机器人为您服务。
|
||||
</p>
|
||||
<Link href="/request-charging" className="inline-block bg-white hover:bg-gray-100 text-indigo-600 font-bold py-3 px-8 rounded-lg shadow-md transform hover:scale-105 transition duration-300 ease-in-out">
|
||||
查找车位并充电
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Link to view charging history */}
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/my-sessions" className="text-indigo-600 hover:text-indigo-800 font-medium flex items-center justify-center">
|
||||
<span className="mr-2"><FiList size={20}/></span> 查看我的充电记录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
// import Link from 'next/link'; // Temporarily remove Link to test a plain anchor
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
@@ -10,7 +11,7 @@ export default function AuthenticatedLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isAuthenticated, isLoading, user } = useAuth(); // user is available if needed
|
||||
const { isAuthenticated, isLoading, user, logout } = useAuth(); // 获取 user 和 logout
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +25,7 @@ export default function AuthenticatedLayout({
|
||||
// If still loading, show spinner
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
@@ -32,15 +33,54 @@ export default function AuthenticatedLayout({
|
||||
|
||||
// If loading is complete but user is not authenticated,
|
||||
// useEffect will handle redirection. Render null or spinner to avoid flashing content.
|
||||
if (!isAuthenticated) {
|
||||
// This state should ideally be brief as useEffect redirects.
|
||||
if (!isAuthenticated || !user) { // Also check if user object is available
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner /> {/* Or return null; */}
|
||||
</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If authenticated, render children
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||
<header className="bg-white shadow-md w-full sticky top-0 z-50">
|
||||
<nav className="container mx-auto px-6 py-3 flex justify-between items-center">
|
||||
<div>
|
||||
{/* Temporarily replaced Link with a plain anchor tag for testing */}
|
||||
<a href={user.role === 'admin' ? '/admin/dashboard' : '/dashboard'} className="text-xl font-bold text-blue-700 hover:text-blue-900 transition-colors">
|
||||
充电机器人管理系统
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{user && (
|
||||
<span className="text-gray-700 text-sm">
|
||||
欢迎, <span className="font-medium">{user.username}</span> ({user.role === 'admin' ? '管理员' : '用户'})
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('注销失败:', error);
|
||||
// 可以选择性地通知用户注销失败
|
||||
}
|
||||
}}
|
||||
className="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded-md text-sm font-semibold transition duration-150 ease-in-out shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
|
||||
>
|
||||
注销
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-grow container mx-auto p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="bg-gray-800 text-white text-center p-4 mt-auto w-full">
|
||||
<p className="text-sm">© {new Date().getFullYear()} 充电机器人管理系统. 保留所有权利.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
charging_web_app/src/app/(authenticated)/my-sessions/page.tsx
Normal file
229
charging_web_app/src/app/(authenticated)/my-sessions/page.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { api, BaseResponse } from '@/utils/axios';
|
||||
import { FiChevronLeft, FiClock, FiZap, FiDollarSign, FiList, FiHash, FiCalendar, FiCheckCircle, FiXCircle, FiAlertTriangle, FiAlertOctagon } from 'react-icons/fi';
|
||||
|
||||
// Interface matching backend ChargingSessionVO
|
||||
interface ChargingSession {
|
||||
id: number;
|
||||
spotUidSnapshot: string | null;
|
||||
chargeStartTime: string | null;
|
||||
chargeEndTime: string | null;
|
||||
totalDurationSeconds: number | null;
|
||||
cost: number | null;
|
||||
status: string;
|
||||
statusText?: string;
|
||||
energyConsumedKwh?: number | null;
|
||||
}
|
||||
|
||||
// Interface for the Page object from backend
|
||||
interface Page<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
size: number;
|
||||
current: number;
|
||||
pages?: number;
|
||||
}
|
||||
|
||||
// Helper function to format duration
|
||||
const formatDuration = (seconds: number | null | undefined): string => {
|
||||
if (seconds === null || seconds === undefined || seconds < 0) return 'N/A';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h > 0 ? `${h}小时 ` : ''}${m}分钟`;
|
||||
};
|
||||
|
||||
// Helper function to get status display
|
||||
const getStatusDisplay = (status: string | null | undefined) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"><FiCheckCircle className="mr-1" /> 已完成</span>;
|
||||
case 'CANCELLED_BY_USER':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"><FiXCircle className="mr-1" /> 用户取消</span>;
|
||||
case 'CANCELLED_BY_SYSTEM':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"><FiAlertTriangle className="mr-1" /> 系统取消</span>;
|
||||
case 'PAYMENT_PENDING':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"><FiClock className="mr-1" /> 待支付</span>;
|
||||
case 'CHARGING_IN_PROGRESS':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-teal-100 text-teal-800"><FiZap className="mr-1" /> 充电中</span>;
|
||||
case 'ENDED_AWAITING_PAYMENT':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"><FiDollarSign className="mr-1" /> 待结账</span>;
|
||||
case 'ERROR':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-200 text-red-900"><FiAlertOctagon className="mr-1" /> 错误</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">{status || '未知'}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
export default function MySessionsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [sessions, setSessions] = useState<ChargingSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [authLoading, isAuthenticated, router]);
|
||||
|
||||
const fetchSessions = useCallback(async (page: number) => {
|
||||
if (!isAuthenticated || user?.role !== 'user') return;
|
||||
|
||||
setSessionsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post<BaseResponse<Page<ChargingSession>>>('/session/my/list/page', {
|
||||
current: page,
|
||||
pageSize: itemsPerPage,
|
||||
});
|
||||
|
||||
console.log("充电记录响应:", response.data);
|
||||
|
||||
if (response.data && response.data.code === 0 && response.data.data) {
|
||||
const pageData = response.data.data;
|
||||
setSessions(pageData.records || []);
|
||||
setTotalPages(Math.ceil(pageData.total / itemsPerPage));
|
||||
setCurrentPage(pageData.current);
|
||||
} else {
|
||||
console.warn("获取充电记录失败或数据为空:", response.data?.message);
|
||||
setSessions([]);
|
||||
setTotalPages(0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching charging sessions:", err);
|
||||
setError(err.response?.data?.message || err.message || '获取充电记录失败,请稍后再试。');
|
||||
setSessions([]);
|
||||
setTotalPages(0);
|
||||
}
|
||||
setSessionsLoading(false);
|
||||
}, [isAuthenticated, user, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(currentPage);
|
||||
}, [fetchSessions, currentPage]);
|
||||
|
||||
if (authLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!user || user.role === 'admin') {
|
||||
return <div className="p-4">此页面仅供普通用户查看自己的充电记录。</div>;
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
fetchSessions(newPage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 md:p-6 lg:p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<header className="mb-6 md:mb-8">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 flex items-center mb-4 md:mb-0">
|
||||
<FiList className="mr-3 text-indigo-600" /> 我的充电记录
|
||||
</h1>
|
||||
<Link href="/dashboard" className="flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
<FiChevronLeft className="mr-1" /> 返回用户中心
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-300 rounded-md">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionsLoading && !sessions.length ? (
|
||||
<LoadingSpinner />
|
||||
) : !sessionsLoading && sessions.length === 0 && !error ? (
|
||||
<div className="text-center py-12 bg-white rounded-xl shadow-lg">
|
||||
<FiList size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-700">暂无充电记录</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">您还没有任何充电会话,开始新的充电吧!</p>
|
||||
<Link href="/request-charging" className="mt-4 inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out">
|
||||
前往充电
|
||||
</Link>
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-x-auto">
|
||||
{sessionsLoading && <div className="absolute inset-0 bg-white bg-opacity-50 flex items-center justify-center"><LoadingSpinner /></div>}
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">车位UID</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">开始时间</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">结束时间</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时长</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">费用</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">电量 (kWh)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sessions.map((session) => (
|
||||
<tr key={session.id}>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900"><FiHash className="inline mr-1 text-gray-400"/>{session.spotUidSnapshot || 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiCalendar className="inline mr-1 text-gray-400"/>{session.chargeStartTime ? new Date(session.chargeStartTime).toLocaleString() : 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiCalendar className="inline mr-1 text-gray-400"/>{session.chargeEndTime ? new Date(session.chargeEndTime).toLocaleString() : 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiClock className="inline mr-1 text-gray-400"/>{formatDuration(session.totalDurationSeconds)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiDollarSign className="inline mr-1 text-green-500"/>{session.cost !== null ? session.cost.toFixed(2) : 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">{getStatusDisplay(session.status)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiZap className="inline mr-1 text-yellow-500"/>{session.energyConsumedKwh !== null && session.energyConsumedKwh !== undefined ? `${session.energyConsumedKwh.toFixed(2)} kWh` : 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex justify-center items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || sessionsLoading}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
{[...Array(totalPages).keys()].map(number => (
|
||||
<button
|
||||
key={number + 1}
|
||||
onClick={() => handlePageChange(number + 1)}
|
||||
disabled={sessionsLoading}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${currentPage === number + 1 ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 hover:bg-gray-50'} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{number + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || sessionsLoading}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
// import api from '@/utils/api'; // Previous attempt
|
||||
import { api } from '@/services/api'; // New attempt, assuming api.ts is in src/services/
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
interface ParkingSpot {
|
||||
id: number;
|
||||
spotUid: string;
|
||||
locationDescription?: string;
|
||||
status: string; // Expecting "AVAILABLE"
|
||||
}
|
||||
|
||||
export default function RequestChargingPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [parkingSpots, setParkingSpots] = useState<ParkingSpot[]>([]);
|
||||
const [selectedSpotId, setSelectedSpotId] = useState<number | null>(null);
|
||||
const [isLoadingSpots, setIsLoadingSpots] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [authLoading, isAuthenticated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const fetchAvailableSpots = async () => {
|
||||
setIsLoadingSpots(true);
|
||||
try {
|
||||
// Corrected path
|
||||
const response = await api.get('/parking-spot/list/available-assignable');
|
||||
if (response.data && response.data.code === 0) {
|
||||
setParkingSpots(response.data.data || []);
|
||||
} else {
|
||||
setError(response.data.message || '获取可用车位失败');
|
||||
setParkingSpots([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载车位列表时发生网络错误');
|
||||
setParkingSpots([]);
|
||||
}
|
||||
setIsLoadingSpots(false);
|
||||
};
|
||||
fetchAvailableSpots();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSelectSpot = (spotId: number) => {
|
||||
setSelectedSpotId(spotId);
|
||||
};
|
||||
|
||||
const handleSubmitRequest = async () => {
|
||||
if (!selectedSpotId) {
|
||||
setError('请先选择一个车位。');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Corrected path
|
||||
const response = await api.post('/session/request', { spotId: selectedSpotId });
|
||||
if (response.data && response.data.code === 0) {
|
||||
const newSession = response.data.data;
|
||||
// TODO: Navigate to a session status page or show success message
|
||||
// For now, navigate to dashboard and show an alert
|
||||
alert(`充电请求成功!会话ID: ${newSession.id}. 机器人正在前往...`);
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
setError(response.data.message || '发起充电请求失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.message || '发起充电请求时发生错误');
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
if (authLoading || isLoadingSpots) {
|
||||
// Layout will be applied by Next.js, just return the specific loading state for this page
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-150px)]"> {/* Adjust height if needed based on header/footer size*/}
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// This case should ideally be handled by redirect in useEffect,
|
||||
// but as a fallback or if redirect hasn't happened yet.
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<p>请先登录后访问此页面。</p>
|
||||
<LoadingSpinner /> {/* Or a link to login */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 md:p-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800">选择充电车位</h1>
|
||||
<p className="text-gray-600 mt-1">请从下方选择一个可用的车位开始充电。</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<strong className="font-bold">错误! </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parkingSpots.length === 0 && !isLoadingSpots && !error && (
|
||||
<div className="text-center text-gray-500 py-10">
|
||||
<p className="text-xl mb-2">当前没有可用的充电车位。</p>
|
||||
<p>请稍后再试。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-8">
|
||||
{parkingSpots.map((spot) => (
|
||||
<div
|
||||
key={spot.id}
|
||||
onClick={() => handleSelectSpot(spot.id)}
|
||||
className={`p-6 rounded-xl shadow-lg cursor-pointer transition-all duration-200 ease-in-out
|
||||
${selectedSpotId === spot.id
|
||||
? 'bg-indigo-500 text-white ring-4 ring-indigo-300 scale-105'
|
||||
: 'bg-white hover:shadow-xl hover:scale-102'}`}
|
||||
>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${selectedSpotId === spot.id ? 'text-white' : 'text-gray-700'}`}>
|
||||
车位: {spot.spotUid}
|
||||
</h3>
|
||||
{spot.locationDescription && (
|
||||
<p className={`text-sm ${selectedSpotId === spot.id ? 'text-indigo-100' : 'text-gray-500'}`}>
|
||||
位置: {spot.locationDescription}
|
||||
</p>
|
||||
)}
|
||||
<p className={`text-sm font-medium mt-3 ${selectedSpotId === spot.id ? 'text-green-300' : 'text-green-500'}`}>
|
||||
{spot.status === 'AVAILABLE' ? '可用' : spot.status}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{parkingSpots.length > 0 && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={handleSubmitRequest}
|
||||
disabled={!selectedSpotId || isSubmitting}
|
||||
className="bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-8 rounded-lg shadow-md transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center mx-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
请求中...
|
||||
</>
|
||||
) : (
|
||||
'确认选择并请求充电'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,3 +24,17 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
@keyframes pulse-fast {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-fast {
|
||||
animation: pulse-fast 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@@ -1,103 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (isAuthenticated) {
|
||||
if (user?.role === 'admin') {
|
||||
router.replace('/admin/dashboard');
|
||||
} else {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
} else {
|
||||
router.replace('/login');
|
||||
}
|
||||
}
|
||||
}, [isLoading, isAuthenticated, user, router]);
|
||||
|
||||
// Display a loading spinner while determining auth state and redirecting
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
47
charging_web_app/src/components/Modal.tsx
Normal file
47
charging_web_app/src/components/Modal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode; // Content of the modal
|
||||
footer?: ReactNode; // Optional footer for buttons like Save, Cancel
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex justify-center items-center">
|
||||
<div className="relative mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
|
||||
{/* Modal Header */}
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="mt-3">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer (Optional) */}
|
||||
{footer && (
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
62
charging_web_app/src/utils/axios.ts
Normal file
62
charging_web_app/src/utils/axios.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:7529/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // <--- 取消注释
|
||||
});
|
||||
|
||||
// 请求拦截器:用于在每个请求发送前执行某些操作(例如,添加认证token)
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
// 假设token存储在localStorage中。实际应用中,你可能会从AuthContext或类似地方获取。
|
||||
// if (typeof window !== 'undefined') { // <--- 暂时注释掉这部分逻辑
|
||||
// const token = localStorage.getItem('authToken'); // 确保你的登录逻辑中存储了名为 'authToken' 的token
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`;
|
||||
// }
|
||||
// } // <--- 暂时注释掉这部分逻辑
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器:用于在接收到响应后执行某些操作
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// 如果后端返回的数据结构在 data 字段中,有些项目会直接返回 response.data
|
||||
// 例如: return response.data;
|
||||
return response; // 当前返回完整的 AxiosResponse 对象
|
||||
},
|
||||
(error) => {
|
||||
// 全局错误处理
|
||||
// 例如:如果401未授权,则重定向到登录页面
|
||||
if (error.response && error.response.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 清理认证相关的 localStorage (如果适用)
|
||||
// localStorage.removeItem('authToken');
|
||||
// localStorage.removeItem('user');
|
||||
// window.location.href = '/login';
|
||||
// 注意:直接使用 window.location.href 会导致全页面刷新。
|
||||
// 在Next.js中,更好的做法是使用router.push(),但这需要在组件上下文中,或通过事件总线/状态管理触发。
|
||||
// 对于axios拦截器这种非组件上下文,可能需要一个更复杂的解决方案来触发路由跳转,
|
||||
// 或者接受这里的全页面刷新,或者在调用api的地方具体处理401。
|
||||
console.error('Unauthorized, redirecting to login might be needed.');
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export { api };
|
||||
|
||||
// 定义通用的后端响应格式接口,与Spring后端的BaseResponse对应
|
||||
export interface BaseResponse<T> {
|
||||
code: number; // 响应状态码,0表示成功
|
||||
data: T; // 泛型数据,可以是任何类型
|
||||
message?: string; // 可选的消息说明
|
||||
}
|
||||
Reference in New Issue
Block a user