mirror of
https://gitee.com/san-bing/JChargePointProtocol
synced 2026-05-05 18:39:56 +08:00
* !44 comment * !39 添加下行日志打印 * !36 扩展计价领域模型 * !35 webui 初步成型 * !34 webui 初步成型
This commit is contained in:
181
jcpp-web-ui/src/App.css
Normal file
181
jcpp-web-ui/src/App.css
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
font-size: 14px;
|
||||
line-height: 1.5715;
|
||||
color: #000000d9;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 覆盖antd样式 */
|
||||
.ant-layout {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 卡片样式优化 */
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
.ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Modal样式优化 */
|
||||
.ant-modal {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
/* 用户信息显示样式 */
|
||||
.user-info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-info-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(114, 46, 209, 0.1) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info-wrapper:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.user-info-wrapper:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
border-color: rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.user-info-wrapper:hover .dropdown-arrow {
|
||||
color: #495057;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-form-inline .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-info-wrapper {
|
||||
padding: 6px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.user-info-wrapper {
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
85
jcpp-web-ui/src/App.tsx
Normal file
85
jcpp-web-ui/src/App.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {useEffect} from 'react';
|
||||
import {BrowserRouter as Router, Navigate, Route, Routes} from 'react-router-dom';
|
||||
import {ConfigProvider, message} from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import {AuthProvider} from './contexts/AuthContext';
|
||||
import {ToastProvider} from './contexts/ToastContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './components/Login';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import StationManagement from './components/StationManagement';
|
||||
import PileManagement from './components/PileManagement';
|
||||
import GunManagement from './components/GunManagement';
|
||||
import NotFoundRedirect from './components/NotFoundRedirect';
|
||||
import './App.css';
|
||||
|
||||
const App: React.FC = () => {
|
||||
// 配置全局message
|
||||
useEffect(() => {
|
||||
message.config({
|
||||
top: 50, // 距离顶部位置
|
||||
duration: 3, // 默认持续时间3秒
|
||||
maxCount: 3, // 最多同时显示3个
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 登录页面 */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* 根路径重定向 */}
|
||||
<Route path="/" element={<Navigate to="/page/dashboard" replace />} />
|
||||
|
||||
{/* 受保护的路由 - 使用 /page 前缀 */}
|
||||
<Route path="/page/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/page/stations" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<StationManagement />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/page/piles" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<PileManagement />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/page/guns" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<GunManagement />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 智能404重定向 - 根据登录状态决定重定向目标 */}
|
||||
<Route path="*" element={<NotFoundRedirect />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
79
jcpp-web-ui/src/assets/icons/logo192.svg
Normal file
79
jcpp-web-ui/src/assets/icons/logo192.svg
Normal file
@@ -0,0 +1,79 @@
|
||||
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="tableGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2F8B3F;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1B8B3A;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0F6B2F;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pileGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#FFFFFF;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#F0F0F0;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
|
||||
<feOffset dx="2" dy="4" result="offset"/>
|
||||
<feFlood color="#00000040"/>
|
||||
<feComposite in2="offset" operator="in"/>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 麻将桌背景 (圆角矩形) -->
|
||||
<rect x="15.36" y="15.36" width="161.28" height="161.28"
|
||||
rx="28.799999999999997" ry="28.799999999999997"
|
||||
fill="url(#tableGradient)"
|
||||
stroke="#0F6B2F"
|
||||
stroke-width="1.92"
|
||||
filter="url(#shadow)"/>
|
||||
|
||||
<!-- 麻将桌边框装饰 -->
|
||||
<rect x="23.04" y="23.04" width="145.92000000000002" height="145.92000000000002"
|
||||
rx="23.04" ry="23.04"
|
||||
fill="none"
|
||||
stroke="#4CAF50"
|
||||
stroke-width="1.536"
|
||||
opacity="0.6"/>
|
||||
|
||||
<!-- 左边充电桩 -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="67.19999999999999" width="15.36" height="76.80000000000001"
|
||||
rx="3.84" fill="url(#pileGradient)" stroke="#CCCCCC" stroke-width="0.96"/>
|
||||
<rect x="42.24" y="138.24" width="26.880000000000003" height="11.52"
|
||||
rx="2.88" fill="url(#pileGradient)" stroke="#CCCCCC" stroke-width="0.96"/>
|
||||
<!-- 充电口 -->
|
||||
<circle cx="55.67999999999999" cy="86.4" r="2.88" fill="#333"/>
|
||||
<circle cx="55.67999999999999" cy="105.60000000000001" r="2.88" fill="#333"/>
|
||||
</g>
|
||||
|
||||
<!-- 中间充电桩 (稍高一些) -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="88.32000000000001" y="53.760000000000005" width="15.36" height="96"
|
||||
rx="3.84" fill="url(#pileGradient)" stroke="#CCCCCC" stroke-width="0.96"/>
|
||||
<rect x="82.56" y="144" width="26.880000000000003" height="11.52"
|
||||
rx="2.88" fill="url(#pileGradient)" stroke="#CCCCCC" stroke-width="0.96"/>
|
||||
<!-- 充电口 -->
|
||||
<circle cx="96" cy="76.80000000000001" r="2.88" fill="#333"/>
|
||||
<circle cx="96" cy="96" r="2.88" fill="#333"/>
|
||||
<circle cx="96" cy="115.19999999999999" r="2.88" fill="#333"/>
|
||||
</g>
|
||||
|
||||
<!-- 右边充电桩 -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="128.64000000000001" y="67.19999999999999" width="15.36" height="76.80000000000001"
|
||||
rx="3.84" fill="url(#pileGradient)" stroke="#CCCCCC" stroke-width="0.96"/>
|
||||
<rect x="122.88" y="138.24" width="26.880000000000003" height="11.52"
|
||||
rx="2.88" fill="url(#pileGradient)" stroke="#CCCCCC" stroke-width="0.96"/>
|
||||
<!-- 充电口 -->
|
||||
<circle cx="136.32" cy="86.4" r="2.88" fill="#333"/>
|
||||
<circle cx="136.32" cy="105.60000000000001" r="2.88" fill="#333"/>
|
||||
</g>
|
||||
|
||||
<!-- 充电桩顶部指示灯 -->
|
||||
<circle cx="55.67999999999999" cy="71.03999999999999" r="1.92" fill="#52C41A" opacity="0.8"/>
|
||||
<circle cx="96" cy="57.599999999999994" r="1.92" fill="#52C41A" opacity="0.8"/>
|
||||
<circle cx="136.32" cy="71.03999999999999" r="1.92" fill="#52C41A" opacity="0.8"/>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
35
jcpp-web-ui/src/components/AppLogo.tsx
Normal file
35
jcpp-web-ui/src/components/AppLogo.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React from 'react';
|
||||
import logo192 from '../assets/icons/logo192.svg';
|
||||
|
||||
interface AppLogoProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用Logo组件
|
||||
* 使用新的麻将桌风格充电桩图标
|
||||
*/
|
||||
const AppLogo: React.FC<AppLogoProps> = ({ size = 48, className }) => {
|
||||
return (
|
||||
<img
|
||||
src={logo192}
|
||||
alt="JCPP充电桩管理系统"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLogo;
|
||||
447
jcpp-web-ui/src/components/Dashboard.tsx
Normal file
447
jcpp-web-ui/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Alert, Button, Card, Col, Row, Spin, Statistic} from 'antd';
|
||||
import {AimOutlined, EnvironmentOutlined, ReloadOutlined, ThunderboltOutlined} from '@ant-design/icons';
|
||||
import * as echarts from 'echarts';
|
||||
import {type DashboardStats, getDashboardStats} from '../services/dashboardService';
|
||||
import {getErrorMessage} from '../services/api';
|
||||
import {showMessage} from '../utils';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pileChartRef = useRef<HTMLDivElement>(null);
|
||||
const gunChartRef = useRef<HTMLDivElement>(null);
|
||||
const pileChartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const gunChartInstance = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
// 加载仪表盘数据
|
||||
const loadDashboardStats = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDashboardStats();
|
||||
setStats(data);
|
||||
console.log('Dashboard stats loaded:', data);
|
||||
} catch (error: any) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setError(errorMessage);
|
||||
showMessage.error(`加载仪表盘数据失败:${errorMessage}`);
|
||||
console.error('Dashboard stats loading failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardStats();
|
||||
// 每30秒自动刷新数据
|
||||
const interval = setInterval(loadDashboardStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 充电桩状态饼图
|
||||
useEffect(() => {
|
||||
if (!pileChartRef.current || loading || !stats?.pileStatusDistribution) return;
|
||||
|
||||
// 销毁之前的图表实例
|
||||
if (pileChartInstance.current) {
|
||||
pileChartInstance.current.dispose();
|
||||
}
|
||||
|
||||
// 创建新的图表实例
|
||||
const chart = echarts.init(pileChartRef.current);
|
||||
pileChartInstance.current = chart;
|
||||
|
||||
const { pileStatusDistribution } = stats;
|
||||
const data = [
|
||||
{
|
||||
name: '在线',
|
||||
value: pileStatusDistribution.onlinePiles,
|
||||
itemStyle: { color: '#52c41a' }
|
||||
},
|
||||
{
|
||||
name: '离线',
|
||||
value: pileStatusDistribution.offlinePiles,
|
||||
itemStyle: { color: '#ff7875' }
|
||||
}
|
||||
].filter(item => item.value > 0);
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '充电桩在线状态',
|
||||
left: 'center',
|
||||
top: 15,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#262626'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c}台 ({d}%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 12
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 15,
|
||||
data: data.map(item => item.name),
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '充电桩状态',
|
||||
type: 'pie',
|
||||
radius: ['45%', '65%'],
|
||||
center: ['50%', '55%'],
|
||||
data: data,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
formatter: '{b}\n{c}台\n{d}%',
|
||||
fontSize: 11,
|
||||
color: '#666'
|
||||
},
|
||||
labelLine: {
|
||||
length: 10,
|
||||
length2: 5
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
|
||||
// 窗口大小变化时重新调整图表
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [stats, loading]);
|
||||
|
||||
// 充电枪状态饼图
|
||||
useEffect(() => {
|
||||
if (!gunChartRef.current || loading || !stats?.gunStatusDistribution) return;
|
||||
|
||||
// 销毁之前的图表实例
|
||||
if (gunChartInstance.current) {
|
||||
gunChartInstance.current.dispose();
|
||||
}
|
||||
|
||||
// 创建新的图表实例
|
||||
const chart = echarts.init(gunChartRef.current);
|
||||
gunChartInstance.current = chart;
|
||||
|
||||
const { gunStatusDistribution } = stats;
|
||||
|
||||
// 准备数据 - 只显示有数据的状态
|
||||
const statusData = [
|
||||
{ name: '空闲', value: gunStatusDistribution.idleGuns, color: '#52c41a' },
|
||||
{ name: '已插枪', value: gunStatusDistribution.insertedGuns, color: '#faad14' },
|
||||
{ name: '充电中', value: gunStatusDistribution.chargingGuns, color: '#1890ff' },
|
||||
{ name: '充电完成', value: gunStatusDistribution.chargeCompleteGuns, color: '#13c2c2' },
|
||||
{ name: '放电准备', value: gunStatusDistribution.dischargeReadyGuns, color: '#722ed1' },
|
||||
{ name: '放电中', value: gunStatusDistribution.dischargingGuns, color: '#eb2f96' },
|
||||
{ name: '放电完成', value: gunStatusDistribution.dischargeCompleteGuns, color: '#fa8c16' },
|
||||
{ name: '预约', value: gunStatusDistribution.reservedGuns, color: '#a0d911' },
|
||||
{ name: '故障', value: gunStatusDistribution.faultGuns, color: '#ff7875' }
|
||||
].filter(item => item.value > 0);
|
||||
|
||||
const data = statusData.map(item => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
itemStyle: { color: item.color }
|
||||
}));
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '充电枪运行状态',
|
||||
left: 'center',
|
||||
top: 15,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#262626'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
const percentage = ((params.value / gunStatusDistribution.totalGuns) * 100).toFixed(1);
|
||||
return `${params.name}<br/>数量: ${params.value}台<br/>占比: ${percentage}%`;
|
||||
},
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 12
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle',
|
||||
data: data.map(item => item.name),
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#666'
|
||||
},
|
||||
formatter: (name: string) => {
|
||||
const item = statusData.find(d => d.name === name);
|
||||
return item ? `${name} (${item.value})` : name;
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '充电枪状态',
|
||||
type: 'pie',
|
||||
radius: ['35%', '60%'],
|
||||
center: ['65%', '55%'],
|
||||
data: data,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
formatter: '{b}\n{d}%',
|
||||
fontSize: 10,
|
||||
color: '#666'
|
||||
},
|
||||
labelLine: {
|
||||
length: 8,
|
||||
length2: 3
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
|
||||
// 窗口大小变化时重新调整图表
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [stats, loading]);
|
||||
|
||||
// 组件卸载时清理图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pileChartInstance.current) {
|
||||
pileChartInstance.current.dispose();
|
||||
}
|
||||
if (gunChartInstance.current) {
|
||||
gunChartInstance.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 首次加载状态
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>加载仪表盘数据中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && !stats) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Alert
|
||||
message="仪表盘加载失败"
|
||||
description={error}
|
||||
type="error"
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" danger onClick={loadDashboardStats}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', backgroundColor: '#fafafa', minHeight: '100vh' }}>
|
||||
{/* 标题区域 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20
|
||||
}}>
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
color: '#262626'
|
||||
}}>
|
||||
充电站管理仪表盘
|
||||
</h1>
|
||||
<ReloadOutlined
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: loading ? '#1890ff' : '#666',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.3s'
|
||||
}}
|
||||
spin={loading}
|
||||
onClick={loadDashboardStats}
|
||||
title="刷新数据"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 20 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="充电站数量"
|
||||
value={stats?.overview?.totalStations || 0}
|
||||
prefix={<EnvironmentOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ color: '#262626', fontSize: '24px', fontWeight: '500' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="充电桩数量"
|
||||
value={stats?.overview?.totalPiles || 0}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
|
||||
valueStyle={{ color: '#262626', fontSize: '24px', fontWeight: '500' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="充电枪数量"
|
||||
value={stats?.overview?.totalGuns || 0}
|
||||
prefix={<AimOutlined style={{ color: '#722ed1' }} />}
|
||||
valueStyle={{ color: '#262626', fontSize: '24px', fontWeight: '500' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 图表区域 */}
|
||||
{stats?.pileStatusDistribution && stats?.gunStatusDistribution ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
height: '380px'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px', height: '100%' }}
|
||||
loading={loading}
|
||||
>
|
||||
<div
|
||||
ref={pileChartRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
height: '380px'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px', height: '100%' }}
|
||||
loading={loading}
|
||||
>
|
||||
<div
|
||||
ref={gunChartRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
textAlign: 'center',
|
||||
padding: '40px 0'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#999', fontSize: '14px' }}>
|
||||
{stats ? '暂无图表数据' : '等待图表数据加载...'}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
111
jcpp-web-ui/src/components/GlobalToast.tsx
Normal file
111
jcpp-web-ui/src/components/GlobalToast.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Alert} from 'antd';
|
||||
import {CheckCircleOutlined, CloseCircleOutlined} from '@ant-design/icons';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
duration?: number; // 显示时长,毫秒
|
||||
}
|
||||
|
||||
interface GlobalToastProps {
|
||||
messages: ToastMessage[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const GlobalToast: React.FC<GlobalToastProps> = ({ messages, onRemove }) => {
|
||||
const [visibleMessages, setVisibleMessages] = useState<ToastMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleMessages(messages);
|
||||
|
||||
// 为每个消息设置自动消失定时器
|
||||
messages.forEach((message) => {
|
||||
const duration = message.duration || 3000; // 默认3秒
|
||||
setTimeout(() => {
|
||||
onRemove(message.id);
|
||||
}, duration);
|
||||
});
|
||||
}, [messages, onRemove]);
|
||||
|
||||
if (visibleMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 20,
|
||||
right: 20,
|
||||
zIndex: 9999,
|
||||
maxWidth: '400px',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{visibleMessages.map((message, index) => (
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
pointerEvents: 'auto',
|
||||
animation: 'slideInRight 0.3s ease-out',
|
||||
transition: 'all 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
message={message.message}
|
||||
type={message.type}
|
||||
icon={message.type === 'success' ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => onRemove(message.id)}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
|
||||
border: 'none',
|
||||
backgroundColor: message.type === 'success' ? '#f6ffed' : '#fff2f0',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px 16px',
|
||||
minHeight: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backdropFilter: 'blur(8px)',
|
||||
...(message.type === 'success' ? {
|
||||
background: 'linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%)',
|
||||
borderLeft: '4px solid #52c41a'
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, #fff2f0 0%, #ffccc7 100%)',
|
||||
borderLeft: '4px solid #ff4d4f'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<style>
|
||||
{`
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalToast;
|
||||
970
jcpp-web-ui/src/components/GunManagement.tsx
Normal file
970
jcpp-web-ui/src/components/GunManagement.tsx
Normal file
@@ -0,0 +1,970 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Dropdown,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography
|
||||
} from 'antd';
|
||||
import {DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TableOutlined} from '@ant-design/icons';
|
||||
import type {ColumnsType, TableProps} from 'antd/es/table';
|
||||
import {formatTimestamp, generateGunCode, showMessage} from '../utils';
|
||||
import {getErrorMessage} from '../services/api';
|
||||
import * as gunService from '../services/gunService';
|
||||
import * as stationService from '../services/stationService';
|
||||
import {pileService} from '../services/pileService';
|
||||
import type {Gun, GunCreateRequest, PileOption, StationOption} from '../types';
|
||||
|
||||
const { confirm } = Modal;
|
||||
|
||||
const GunManagement: React.FC = () => {
|
||||
const [dataSource, setDataSource] = useState<Gun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchForm] = Form.useForm();
|
||||
const [form] = Form.useForm();
|
||||
const [stationOptions, setStationOptions] = useState<StationOption[]>([]);
|
||||
const [pileOptions, setPileOptions] = useState<PileOption[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [currentRecord, setCurrentRecord] = useState<Gun | null>(null);
|
||||
|
||||
// 分页和搜索状态
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条记录`
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useState<{
|
||||
page: number;
|
||||
size: number;
|
||||
gunName?: string;
|
||||
gunCode?: string;
|
||||
gunNo?: string;
|
||||
stationId?: string;
|
||||
sortField?: string;
|
||||
sortOrder?: string;
|
||||
}>({
|
||||
page: 1,
|
||||
size: 10
|
||||
});
|
||||
|
||||
// 批量删除相关状态
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
|
||||
// 列可见性配置
|
||||
interface ColumnConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
defaultVisible: boolean;
|
||||
}
|
||||
|
||||
const columnConfigs: ColumnConfig[] = [
|
||||
{ key: 'gunName', title: '充电枪名称', defaultVisible: true },
|
||||
{ key: 'gunCode', title: '充电枪编码', defaultVisible: true },
|
||||
{ key: 'gunNo', title: '枪号', defaultVisible: true },
|
||||
{ key: 'stationName', title: '所属充电站', defaultVisible: true },
|
||||
{ key: 'pileName', title: '所属充电桩', defaultVisible: true },
|
||||
{ key: 'runStatus', title: '运行状态', defaultVisible: true },
|
||||
{ key: 'createdTime', title: '创建时间', defaultVisible: true },
|
||||
{ key: 'updatedTime', title: '更新时间', defaultVisible: false },
|
||||
];
|
||||
|
||||
// 列可见性状态
|
||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(() => {
|
||||
const defaultVisible: Record<string, boolean> = {};
|
||||
columnConfigs.forEach(config => {
|
||||
defaultVisible[config.key] = config.defaultVisible;
|
||||
});
|
||||
return defaultVisible;
|
||||
});
|
||||
|
||||
// 列顺序状态(不包含action列,action列始终在最后)
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => {
|
||||
return columnConfigs.map(config => config.key);
|
||||
});
|
||||
|
||||
// 完整的表格列定义
|
||||
const allColumns: ColumnsType<Gun> = useMemo(() => [
|
||||
{
|
||||
title: '充电枪名称',
|
||||
dataIndex: 'gunName',
|
||||
key: 'gunName',
|
||||
width: 200,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '充电枪编码',
|
||||
dataIndex: 'gunCode',
|
||||
key: 'gunCode',
|
||||
width: 150,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '枪号',
|
||||
dataIndex: 'gunNo',
|
||||
key: 'gunNo',
|
||||
width: 55,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '所属充电站',
|
||||
dataIndex: 'stationName',
|
||||
key: 'stationName',
|
||||
width: 150,
|
||||
sorter: true,
|
||||
render: (stationName: string) => stationName || '-',
|
||||
},
|
||||
{
|
||||
title: '所属充电桩',
|
||||
dataIndex: 'pileName',
|
||||
key: 'pileName',
|
||||
width: 150,
|
||||
sorter: true,
|
||||
render: (pileName: string, record: Gun) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{pileName || record.pileCode || '-'}</div>
|
||||
{record.pileCode && pileName && (
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{record.pileCode}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '运行状态',
|
||||
dataIndex: 'runStatus',
|
||||
key: 'runStatus',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
const getRunStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'IDLE': 'green',
|
||||
'INSERTED': 'orange',
|
||||
'CHARGING': 'blue',
|
||||
'CHARGE_COMPLETE': 'cyan',
|
||||
'DISCHARGE_READY': 'purple',
|
||||
'DISCHARGING': 'magenta',
|
||||
'DISCHARGE_COMPLETE': 'lime',
|
||||
'RESERVED': 'geekblue',
|
||||
'FAULT': 'red'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getRunStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
'IDLE': '空闲',
|
||||
'INSERTED': '已插枪',
|
||||
'CHARGING': '充电中',
|
||||
'CHARGE_COMPLETE': '充电完成',
|
||||
'DISCHARGE_READY': '放电准备',
|
||||
'DISCHARGING': '放电中',
|
||||
'DISCHARGE_COMPLETE': '放电完成',
|
||||
'RESERVED': '预约中',
|
||||
'FAULT': '故障'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
return <Tag color={getRunStatusColor(status)}>{getRunStatusText(status)}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdTime',
|
||||
key: 'createdTime',
|
||||
width: 95,
|
||||
sorter: true,
|
||||
render: (timestamp: number) => {
|
||||
const formatted = formatTimestamp(timestamp);
|
||||
if (!formatted || formatted === '-') return formatted;
|
||||
const parts = formatted.split(' ');
|
||||
return (
|
||||
<div style={{ lineHeight: '1.3', fontSize: '13px' }}>
|
||||
<div>{parts[0]}</div>
|
||||
<div style={{ color: '#666' }}>{parts[1]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedTime',
|
||||
key: 'updatedTime',
|
||||
width: 95,
|
||||
sorter: true,
|
||||
render: (timestamp: number) => {
|
||||
const formatted = formatTimestamp(timestamp);
|
||||
if (!formatted || formatted === '-') return formatted;
|
||||
const parts = formatted.split(' ');
|
||||
return (
|
||||
<div style={{ lineHeight: '1.3', fontSize: '13px' }}>
|
||||
<div>{parts[0]}</div>
|
||||
<div style={{ color: '#666' }}>{parts[1]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (record: Gun) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => handleView(record)}>
|
||||
查看
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除充电枪"
|
||||
description={
|
||||
<div>
|
||||
<p>确定要删除充电枪 <strong>{record.gunName}</strong> 吗?</p>
|
||||
<p style={{ color: '#ff4d4f', margin: 0 }}>此操作不可撤销,请谨慎操作!</p>
|
||||
</div>
|
||||
}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定删除"
|
||||
okType="danger"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], []);
|
||||
|
||||
// 根据可见性和顺序过滤并排序列
|
||||
const visibleColumnsData = useMemo(() => {
|
||||
// 先按照用户定义的顺序排序(不包含action)
|
||||
const orderedColumns = columnOrder.map(key => {
|
||||
return allColumns.find(col => col.key === key);
|
||||
}).filter(Boolean) as ColumnsType<Gun>;
|
||||
|
||||
// 过滤出可见的列
|
||||
const filtered = orderedColumns.filter(column => {
|
||||
return visibleColumns[column.key as string];
|
||||
});
|
||||
|
||||
// 找到操作列并确保始终在最后
|
||||
const actionColumn = allColumns.find(col => col.key === 'action');
|
||||
|
||||
return actionColumn ? [...filtered, actionColumn] : filtered;
|
||||
}, [visibleColumns, columnOrder, allColumns]);
|
||||
|
||||
// 列选择器变更处理
|
||||
const handleColumnVisibilityChange = (checkedValues: string[]) => {
|
||||
const newVisibleColumns: Record<string, boolean> = {};
|
||||
columnConfigs.forEach(config => {
|
||||
newVisibleColumns[config.key] = checkedValues.includes(config.key);
|
||||
});
|
||||
setVisibleColumns(newVisibleColumns);
|
||||
};
|
||||
|
||||
// 移动列顺序
|
||||
const moveColumn = (index: number, direction: 'up' | 'down') => {
|
||||
const visibleKeys = columnOrder.filter(key => visibleColumns[key]);
|
||||
const currentKey = visibleKeys[index];
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < visibleKeys.length) {
|
||||
const targetKey = visibleKeys[targetIndex];
|
||||
|
||||
// 在原始顺序中交换位置
|
||||
const newOrder = [...columnOrder];
|
||||
const currentOriginalIndex = newOrder.indexOf(currentKey);
|
||||
const targetOriginalIndex = newOrder.indexOf(targetKey);
|
||||
|
||||
[newOrder[currentOriginalIndex], newOrder[targetOriginalIndex]] =
|
||||
[newOrder[targetOriginalIndex], newOrder[currentOriginalIndex]];
|
||||
|
||||
setColumnOrder(newOrder);
|
||||
}
|
||||
};
|
||||
|
||||
// 列选择器菜单
|
||||
const columnSelectorMenu = {
|
||||
items: [
|
||||
{
|
||||
key: 'column-selector',
|
||||
label: (
|
||||
<div style={{ padding: '8px 0', minWidth: 200 }} onClick={e => e.stopPropagation()}>
|
||||
<Typography.Text strong style={{ fontSize: 12 }}>自定义列显示</Typography.Text>
|
||||
|
||||
{/* 列可见性选择 */}
|
||||
<div style={{ marginTop: 8, marginBottom: 12 }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#666' }}>选择显示列:</Typography.Text>
|
||||
<Checkbox.Group
|
||||
value={Object.keys(visibleColumns).filter(key => visibleColumns[key])}
|
||||
onChange={handleColumnVisibilityChange}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{columnOrder.map(key => {
|
||||
const config = columnConfigs.find(c => c.key === key);
|
||||
if (!config) return null;
|
||||
return (
|
||||
<Checkbox key={config.key} value={config.key} style={{ fontSize: 11 }}>
|
||||
{config.title}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
{/* 列顺序调整 */}
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#666' }}>调整列顺序:</Typography.Text>
|
||||
<div style={{ maxHeight: 120, overflowY: 'auto', marginTop: 4 }}>
|
||||
{columnOrder.filter(key => visibleColumns[key]).map((key, index, visibleKeys) => {
|
||||
const config = columnConfigs.find(c => c.key === key);
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div key={key} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '2px 0',
|
||||
fontSize: 11,
|
||||
gap: 4
|
||||
}}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{config.title}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => moveColumn(index, 'up')}
|
||||
disabled={index === 0}
|
||||
style={{ padding: '0 4px', height: 20, fontSize: 10 }}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => moveColumn(index, 'down')}
|
||||
disabled={index === visibleKeys.length - 1}
|
||||
style={{ padding: '0 4px', height: 20, fontSize: 10 }}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await gunService.getGuns(searchParams);
|
||||
const { records, total } = response;
|
||||
setDataSource(records);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: searchParams.page,
|
||||
pageSize: searchParams.size,
|
||||
total
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('加载充电枪数据失败:', error);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
showMessage.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载充电站选项
|
||||
const loadStationOptions = async () => {
|
||||
try {
|
||||
const response = await stationService.getStationOptions();
|
||||
setStationOptions(Array.isArray(response) ? response : []);
|
||||
} catch (error: any) {
|
||||
console.error('加载充电站选项失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载充电桩选项
|
||||
const loadPileOptions = async () => {
|
||||
try {
|
||||
const response = await pileService.getPileOptions();
|
||||
setPileOptions(response.data || []);
|
||||
} catch (error: any) {
|
||||
console.error('加载充电桩选项失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听搜索参数变化
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// 初始化加载充电站选项和充电桩选项
|
||||
useEffect(() => {
|
||||
loadStationOptions();
|
||||
loadPileOptions();
|
||||
}, []);
|
||||
|
||||
// 处理表格变化
|
||||
const handleTableChange: TableProps<Gun>['onChange'] = (pag, filters, sorter) => {
|
||||
let newParams = {
|
||||
...searchParams,
|
||||
page: pag.current || 1,
|
||||
size: pag.pageSize || 10
|
||||
};
|
||||
|
||||
// 处理排序
|
||||
if (sorter && !Array.isArray(sorter) && sorter.field) {
|
||||
newParams.sortField = sorter.field as string;
|
||||
newParams.sortOrder = sorter.order === 'ascend' ? 'asc' : 'desc';
|
||||
} else {
|
||||
delete newParams.sortField;
|
||||
delete newParams.sortOrder;
|
||||
}
|
||||
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (values: any) => {
|
||||
const newParams = {
|
||||
page: 1,
|
||||
size: pagination.pageSize,
|
||||
...values
|
||||
};
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
const newParams = {
|
||||
page: 1,
|
||||
size: pagination.pageSize
|
||||
};
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 显示新建模态框
|
||||
const showCreateModal = () => {
|
||||
setIsEdit(false);
|
||||
setCurrentRecord(null);
|
||||
setModalVisible(true);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = (record: Gun) => {
|
||||
setIsEdit(true);
|
||||
setCurrentRecord(record);
|
||||
setModalVisible(true);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
gunNo: record.gunNo.toString()
|
||||
});
|
||||
};
|
||||
|
||||
// 处理查看
|
||||
const handleView = (record: Gun) => {
|
||||
showMessage.info('查看功能待实现');
|
||||
};
|
||||
|
||||
// 生成充电枪编码
|
||||
const handleGenerateGunCode = () => {
|
||||
const pileId = form.getFieldValue('pileId');
|
||||
const gunNo = form.getFieldValue('gunNo');
|
||||
|
||||
if (!pileId || !gunNo) {
|
||||
showMessage.warning('请先选择充电桩和填写枪号');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPile = pileOptions.find(p => p.id === pileId);
|
||||
if (selectedPile) {
|
||||
const code = generateGunCode(selectedPile.pileCode, gunNo);
|
||||
form.setFieldValue('gunCode', code);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setModalLoading(true);
|
||||
|
||||
if (isEdit && currentRecord) {
|
||||
// 编辑功能待实现
|
||||
showMessage.info('编辑功能待实现');
|
||||
} else {
|
||||
// 新建充电枪
|
||||
const createData: GunCreateRequest = {
|
||||
gunName: values.gunName,
|
||||
gunNo: values.gunNo,
|
||||
gunCode: values.gunCode,
|
||||
stationId: values.stationId,
|
||||
pileId: values.pileId
|
||||
};
|
||||
await gunService.createGun(createData);
|
||||
showMessage.success('充电枪创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
// 清空选择状态并重新加载数据
|
||||
setSelectedRowKeys([]);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证错误
|
||||
return;
|
||||
}
|
||||
showMessage.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消模态框
|
||||
const handleCancel = () => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = async (record: Gun) => {
|
||||
try {
|
||||
console.log('开始删除充电枪:', record.gunName, 'ID:', record.id);
|
||||
await gunService.deleteGun(record.id);
|
||||
console.log('删除充电枪成功:', record.gunName);
|
||||
showMessage.success(`充电枪 "${record.gunName}" 删除成功`);
|
||||
// 清空选择状态并重新加载数据
|
||||
setSelectedRowKeys([]);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
console.error('删除充电枪失败:', error);
|
||||
console.error('错误详情:', {
|
||||
response: error?.response,
|
||||
data: error?.response?.data,
|
||||
status: error?.response?.status,
|
||||
message: error?.message
|
||||
});
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
console.log('处理后的错误消息:', errorMessage);
|
||||
|
||||
showMessage.error(`删除充电枪 "${record.gunName}" 失败:${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showMessage.warning('请先选择要删除的记录');
|
||||
return;
|
||||
}
|
||||
|
||||
confirm({
|
||||
title: '确认批量删除',
|
||||
content: (
|
||||
<div>
|
||||
<p>您确定要删除选中的 <strong style={{ color: '#ff4d4f' }}>{selectedRowKeys.length}</strong> 条充电枪吗?</p>
|
||||
<p style={{ color: '#ff4d4f', marginTop: 8 }}>⚠️ 此操作不可撤销,请谨慎操作!</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
width: 420,
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setBatchDeleting(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failedNames: string[] = [];
|
||||
const failedReasons: string[] = [];
|
||||
|
||||
try {
|
||||
// 使用 for...of 循环按顺序删除
|
||||
for (const key of selectedRowKeys) {
|
||||
try {
|
||||
await gunService.deleteGun(key as string);
|
||||
successCount++;
|
||||
|
||||
// 每删除一个都更新进度提示
|
||||
if (selectedRowKeys.length > 3) {
|
||||
showMessage.loading(`正在删除... (${successCount}/${selectedRowKeys.length})`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failCount++;
|
||||
const record = dataSource.find(item => item.id === key);
|
||||
const gunName = record?.gunName || `ID: ${key}`;
|
||||
failedNames.push(gunName);
|
||||
|
||||
// 获取详细错误信息
|
||||
const errorMessage = getErrorMessage(error);
|
||||
failedReasons.push(`${gunName}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示删除结果
|
||||
if (failCount === 0) {
|
||||
showMessage.success(`批量删除成功,共删除 ${successCount} 条充电枪`);
|
||||
} else if (successCount === 0) {
|
||||
// 全部失败
|
||||
showMessage.error(
|
||||
`批量删除失败,所有 ${failCount} 条充电枪都删除失败。失败原因:${failedReasons.join('; ')}`
|
||||
);
|
||||
} else {
|
||||
// 部分成功
|
||||
showMessage.warning(
|
||||
`删除完成:成功 ${successCount} 条,失败 ${failCount} 条。失败原因:${failedReasons.join('; ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// 重新加载数据并清空选择
|
||||
setSelectedRowKeys([]);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
// 处理整体操作异常
|
||||
const errorMessage = getErrorMessage(error);
|
||||
showMessage.error(`批量删除操作失败:${errorMessage}`);
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (newSelectedRowKeys: React.Key[]) => {
|
||||
setSelectedRowKeys(newSelectedRowKeys);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#262626' }}>
|
||||
充电枪管理
|
||||
</h2>
|
||||
<Space>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleBatchDelete}
|
||||
loading={batchDeleting}
|
||||
>
|
||||
批量删除 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
|
||||
新建充电枪
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 搜索表单 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Form.Item label="充电枪名称" name="gunName" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="请输入充电枪名称"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="充电枪编码" name="gunCode" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="请输入充电枪编码"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="枪号" name="gunNo" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="请输入枪号"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="所属充电站" name="stationId" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="请选择充电站"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{stationOptions.map(station => (
|
||||
<Select.Option key={station.id} value={station.id}>
|
||||
{station.stationName}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={6}>
|
||||
<Form.Item label="所属充电桩" name="pileId" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="请选择充电桩"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{pileOptions.map(pile => (
|
||||
<Select.Option key={pile.id} value={pile.id}>
|
||||
{pile.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="运行状态" name="runStatus" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="请选择运行状态"
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value="IDLE">空闲</Select.Option>
|
||||
<Select.Option value="INSERTED">已插枪</Select.Option>
|
||||
<Select.Option value="CHARGING">充电中</Select.Option>
|
||||
<Select.Option value="CHARGE_COMPLETE">充电完成</Select.Option>
|
||||
<Select.Option value="DISCHARGE_READY">放电准备</Select.Option>
|
||||
<Select.Option value="DISCHARGING">放电中</Select.Option>
|
||||
<Select.Option value="DISCHARGE_COMPLETE">放电完成</Select.Option>
|
||||
<Select.Option value="RESERVED">预约中</Select.Option>
|
||||
<Select.Option value="FAULT">故障</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<Card
|
||||
title="充电枪列表"
|
||||
extra={
|
||||
<Dropdown
|
||||
menu={columnSelectorMenu}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
overlayStyle={{ minWidth: 180 }}
|
||||
>
|
||||
<Button
|
||||
icon={<TableOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ padding: '4px 8px' }}
|
||||
title="自定义列"
|
||||
/>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowSelection={rowSelection}
|
||||
columns={visibleColumnsData}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑充电枪Modal */}
|
||||
<Modal
|
||||
title={isEdit ? '编辑充电枪' : '新建充电枪'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={modalLoading}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{isEdit && (
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="充电枪名称"
|
||||
name="gunName"
|
||||
rules={[{ required: true, message: '请输入充电枪名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入充电枪名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="所属充电站"
|
||||
name="stationId"
|
||||
rules={[{ required: true, message: '请选择充电站' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择充电站"
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={(input, option) =>
|
||||
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{stationOptions.map(station => (
|
||||
<Select.Option key={station.id} value={station.id}>
|
||||
{station.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="所属充电桩"
|
||||
name="pileId"
|
||||
rules={[{ required: true, message: '请选择充电桩' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择充电桩"
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={(input, option) =>
|
||||
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{pileOptions.map(pile => (
|
||||
<Select.Option key={pile.id} value={pile.id}>
|
||||
{pile.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="枪号"
|
||||
name="gunNo"
|
||||
rules={[{ required: true, message: '请输入枪号' }]}
|
||||
>
|
||||
<Input placeholder="请输入枪号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="充电枪编码"
|
||||
name="gunCode"
|
||||
rules={[{ required: true, message: '请输入充电枪编码' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入充电枪编码"
|
||||
disabled={isEdit}
|
||||
suffix={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleGenerateGunCode}
|
||||
disabled={isEdit}
|
||||
style={{
|
||||
height: '24px',
|
||||
lineHeight: '24px',
|
||||
padding: '0 8px',
|
||||
fontSize: '12px',
|
||||
color: '#1890ff',
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
生成
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GunManagement;
|
||||
171
jcpp-web-ui/src/components/Layout.tsx
Normal file
171
jcpp-web-ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {useState} from 'react';
|
||||
import {Avatar, Button, Dropdown, Layout as AntLayout, Menu, message} from 'antd';
|
||||
import {
|
||||
AimOutlined,
|
||||
DashboardOutlined,
|
||||
DownOutlined,
|
||||
EnvironmentOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useAuth} from '../contexts/AuthContext';
|
||||
|
||||
const { Header, Sider, Content } = AntLayout;
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/page/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/page/stations',
|
||||
icon: <EnvironmentOutlined />,
|
||||
label: '充电站管理',
|
||||
},
|
||||
{
|
||||
key: '/page/piles',
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: '充电桩管理',
|
||||
},
|
||||
{
|
||||
key: '/page/guns',
|
||||
icon: <AimOutlined />,
|
||||
label: '充电枪管理',
|
||||
},
|
||||
];
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
message.success('已退出登录');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const getSelectedKeys = () => {
|
||||
return [location.pathname];
|
||||
};
|
||||
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={220}
|
||||
style={{
|
||||
boxShadow: '2px 0 6px rgba(0, 21, 41, 0.35)',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: 64,
|
||||
lineHeight: '64px',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
marginBottom: 1,
|
||||
}}>
|
||||
{collapsed ? 'JCPP' : 'JCPP管理系统'}
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={getSelectedKeys()}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<AntLayout>
|
||||
<Header style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 1px 4px rgba(0, 21, 41, 0.08)',
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 64,
|
||||
height: 64,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div className="user-info-wrapper">
|
||||
<Avatar
|
||||
size={36}
|
||||
icon={<UserOutlined />}
|
||||
className="user-avatar"
|
||||
/>
|
||||
<div className="user-details">
|
||||
<span className="user-name">
|
||||
{user?.username || '用户'}
|
||||
</span>
|
||||
<span className="user-role">管理员</span>
|
||||
</div>
|
||||
<DownOutlined className="dropdown-arrow" />
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
|
||||
<Content style={{
|
||||
margin: 24,
|
||||
padding: 24,
|
||||
background: '#fff',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
}}>
|
||||
{children}
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
55
jcpp-web-ui/src/components/Login.css
Normal file
55
jcpp-web-ui/src/components/Login.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
98
jcpp-web-ui/src/components/Login.tsx
Normal file
98
jcpp-web-ui/src/components/Login.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {useState} from 'react';
|
||||
import {Button, Card, Form, Input, message} from 'antd';
|
||||
import {LockOutlined, UserOutlined} from '@ant-design/icons';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useAuth} from '../contexts/AuthContext';
|
||||
import './Login.css';
|
||||
|
||||
interface LoginFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const onFinish = async (values: LoginFormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await login(values);
|
||||
if (success) {
|
||||
message.success('登录成功');
|
||||
navigate('/page/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('登录失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-content">
|
||||
<Card className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>JCPP充电桩物联网系统</h1>
|
||||
<p>欢迎登录管理后台</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
initialValues={{
|
||||
username: 'sanbing',
|
||||
password: 'sanbing@123456'
|
||||
}}
|
||||
onFinish={onFinish}
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>默认账号:sanbing,密码:sanbing@123456</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
47
jcpp-web-ui/src/components/NotFoundRedirect.tsx
Normal file
47
jcpp-web-ui/src/components/NotFoundRedirect.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React from 'react';
|
||||
import {Navigate} from 'react-router-dom';
|
||||
import {useAuth} from '../contexts/AuthContext';
|
||||
import {Spin} from 'antd';
|
||||
|
||||
/**
|
||||
* 404页面重定向组件
|
||||
* 根据用户登录状态智能重定向:
|
||||
* - 已登录用户:重定向到仪表盘
|
||||
* - 未登录用户:重定向到登录页
|
||||
*/
|
||||
const NotFoundRedirect: React.FC = () => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
// 如果正在加载认证状态,显示加载动画
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据登录状态进行重定向
|
||||
if (isAuthenticated) {
|
||||
// 已登录用户重定向到仪表盘
|
||||
return <Navigate to="/page/dashboard" replace />;
|
||||
} else {
|
||||
// 未登录用户重定向到登录页
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
};
|
||||
|
||||
export default NotFoundRedirect;
|
||||
|
||||
|
||||
1121
jcpp-web-ui/src/components/PileManagement.tsx
Normal file
1121
jcpp-web-ui/src/components/PileManagement.tsx
Normal file
File diff suppressed because it is too large
Load Diff
40
jcpp-web-ui/src/components/ProtectedRoute.tsx
Normal file
40
jcpp-web-ui/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React from 'react';
|
||||
import {Navigate} from 'react-router-dom';
|
||||
import {Spin} from 'antd';
|
||||
import {useAuth} from '../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
||||
1088
jcpp-web-ui/src/components/StationManagement.tsx
Normal file
1088
jcpp-web-ui/src/components/StationManagement.tsx
Normal file
File diff suppressed because it is too large
Load Diff
139
jcpp-web-ui/src/contexts/AuthContext.tsx
Normal file
139
jcpp-web-ui/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react';
|
||||
import {message} from 'antd';
|
||||
import {api} from '../services/api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (credentials: { username: string; password: string }) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isAuthenticated = !!token && !!user;
|
||||
|
||||
// 初始化认证状态
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
if (savedToken) {
|
||||
setToken(savedToken);
|
||||
try {
|
||||
await fetchUserInfo();
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
logout();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const response = await api.get('/api/user/info');
|
||||
// 适配后端API响应格式:ApiResponse<UserInfo>
|
||||
const userData = response.data?.data || response.data;
|
||||
setUser({
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
email: userData.email
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: { username: string; password: string }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await api.post('/api/auth/login', credentials);
|
||||
console.log('Login response:', response.data); // 调试日志
|
||||
|
||||
// 检查响应数据结构
|
||||
const responseData = response.data;
|
||||
let newToken: string;
|
||||
let userInfo: User;
|
||||
|
||||
// 适配不同的响应格式
|
||||
if (responseData.token) {
|
||||
newToken = responseData.token;
|
||||
userInfo = responseData.user || { id: 'unknown', username: credentials.username };
|
||||
} else if (responseData.data?.token) {
|
||||
newToken = responseData.data.token;
|
||||
userInfo = responseData.data.user || { id: 'unknown', username: credentials.username };
|
||||
} else {
|
||||
// 如果没有token字段,假设整个response就是token
|
||||
newToken = responseData;
|
||||
userInfo = { id: 'unknown', username: credentials.username };
|
||||
}
|
||||
|
||||
console.log('Extracted token:', newToken);
|
||||
console.log('Extracted user:', userInfo);
|
||||
|
||||
setToken(newToken);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('token', newToken);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error);
|
||||
message.error(error.response?.data?.message || '登录失败');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
loading
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
83
jcpp-web-ui/src/contexts/ToastContext.tsx
Normal file
83
jcpp-web-ui/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import React, {createContext, ReactNode, useContext, useState} from 'react';
|
||||
import GlobalToast, {ToastMessage} from '../components/GlobalToast';
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, httpStatus?: number, duration?: number) => void;
|
||||
showSuccess: (message: string, duration?: number) => void;
|
||||
showError: (message: string, duration?: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [messages, setMessages] = useState<ToastMessage[]>([]);
|
||||
|
||||
const generateId = () => {
|
||||
return `toast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
const removeMessage = (id: string) => {
|
||||
setMessages(prev => prev.filter(msg => msg.id !== id));
|
||||
};
|
||||
|
||||
const showToast = (message: string, httpStatus?: number, duration?: number) => {
|
||||
// 根据HTTP状态码判断类型:2xx为成功,其他为错误
|
||||
const type = httpStatus && httpStatus >= 200 && httpStatus < 300 ? 'success' : 'error';
|
||||
|
||||
const newMessage: ToastMessage = {
|
||||
id: generateId(),
|
||||
message,
|
||||
type,
|
||||
duration: duration || 3000
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
};
|
||||
|
||||
const showSuccess = (message: string, duration?: number) => {
|
||||
const newMessage: ToastMessage = {
|
||||
id: generateId(),
|
||||
message,
|
||||
type: 'success',
|
||||
duration: duration || 3000
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
};
|
||||
|
||||
const showError = (message: string, duration?: number) => {
|
||||
const newMessage: ToastMessage = {
|
||||
id: generateId(),
|
||||
message,
|
||||
type: 'error',
|
||||
duration: duration || 3000
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showSuccess, showError }}>
|
||||
{children}
|
||||
<GlobalToast messages={messages} onRemove={removeMessage} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
78
jcpp-web-ui/src/data/china-regions.ts
Normal file
78
jcpp-web-ui/src/data/china-regions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
|
||||
// 基于 china-division 包和 afc163 示例代码实现的省市区级联数据
|
||||
// 参考:https://gist.github.com/afc163/7582f35654fd03d5be7009444345ea17
|
||||
|
||||
const provinces = require('china-division/dist/provinces.json');
|
||||
const cities = require('china-division/dist/cities.json');
|
||||
const areas = require('china-division/dist/areas.json');
|
||||
|
||||
export interface RegionOption {
|
||||
label: string;
|
||||
value: string;
|
||||
children?: RegionOption[];
|
||||
}
|
||||
|
||||
// 区县数据接口
|
||||
interface Area {
|
||||
code: string;
|
||||
name: string;
|
||||
cityCode: string;
|
||||
}
|
||||
|
||||
// 城市数据接口
|
||||
interface City {
|
||||
code: string;
|
||||
name: string;
|
||||
provinceCode: string;
|
||||
children?: RegionOption[];
|
||||
}
|
||||
|
||||
// 省份数据接口
|
||||
interface Province {
|
||||
code: string;
|
||||
name: string;
|
||||
children?: RegionOption[];
|
||||
}
|
||||
|
||||
// 类型转换
|
||||
const typedAreas = areas as Area[];
|
||||
const typedCities = cities as City[];
|
||||
const typedProvinces = provinces as Province[];
|
||||
|
||||
// 为每个城市添加区县数据
|
||||
typedAreas.forEach((area) => {
|
||||
const matchCity = typedCities.find(city => city.code === area.cityCode);
|
||||
if (matchCity) {
|
||||
matchCity.children = matchCity.children || [];
|
||||
matchCity.children.push({
|
||||
label: area.name,
|
||||
value: area.code,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 为每个省份添加城市数据
|
||||
typedCities.forEach((city) => {
|
||||
const matchProvince = typedProvinces.find(province => province.code === city.provinceCode);
|
||||
if (matchProvince) {
|
||||
matchProvince.children = matchProvince.children || [];
|
||||
matchProvince.children.push({
|
||||
label: city.name,
|
||||
value: city.code,
|
||||
children: city.children,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 构建最终的级联选择器数据
|
||||
export const chinaRegions: RegionOption[] = typedProvinces.map(province => ({
|
||||
label: province.name,
|
||||
value: province.code,
|
||||
children: province.children,
|
||||
}));
|
||||
19
jcpp-web-ui/src/index.css
Normal file
19
jcpp-web-ui/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
21
jcpp-web-ui/src/index.tsx
Normal file
21
jcpp-web-ui/src/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
7
jcpp-web-ui/src/react-app-env.d.ts
vendored
Normal file
7
jcpp-web-ui/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
/// <reference types="react-scripts" />
|
||||
176
jcpp-web-ui/src/services/api.ts
Normal file
176
jcpp-web-ui/src/services/api.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import axios, {AxiosError, AxiosResponse} from 'axios';
|
||||
import {message} from 'antd';
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
'Accept': 'application/json;charset=UTF-8'
|
||||
}
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
console.log('Request interceptor - Token:', token ? 'exists' : 'missing'); // 调试日志
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
console.log('Authorization header set:', config.headers.Authorization); // 调试日志
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 根据HTTP状态码返回友好的错误提示
|
||||
const getErrorMessage = (error: AxiosError): string => {
|
||||
if (!error.response) {
|
||||
return '网络连接失败,请检查网络连接';
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
const data = error.response.data as any;
|
||||
|
||||
// 优先使用ApiResponse格式的错误信息
|
||||
const apiErrorMessage = data?.message;
|
||||
const errorCode = data?.errorCode;
|
||||
|
||||
// 根据错误码提供特殊处理
|
||||
if (errorCode) {
|
||||
switch (errorCode) {
|
||||
// 通用错误码
|
||||
case 'BUSINESS_ERROR':
|
||||
return apiErrorMessage || '业务处理失败';
|
||||
case 'SYSTEM_ERROR':
|
||||
return apiErrorMessage || '系统错误';
|
||||
|
||||
// 参数校验相关
|
||||
case 'VALIDATION_ERROR':
|
||||
return apiErrorMessage || '数据验证失败';
|
||||
case 'BINDING_ERROR':
|
||||
return apiErrorMessage || '数据绑定失败';
|
||||
case 'ILLEGAL_ARGUMENT':
|
||||
return apiErrorMessage || '参数错误';
|
||||
case 'ILLEGAL_STATE':
|
||||
return apiErrorMessage || '状态错误';
|
||||
|
||||
// 认证授权相关
|
||||
case 'UNAUTHORIZED':
|
||||
return apiErrorMessage || '用户未认证';
|
||||
case 'AUTH_FAILED':
|
||||
return apiErrorMessage || '用户名或密码错误';
|
||||
case 'JWT_AUTH_FAILED':
|
||||
return apiErrorMessage || 'Token认证失败';
|
||||
case 'FORBIDDEN':
|
||||
return apiErrorMessage || '权限不足';
|
||||
|
||||
// 资源相关
|
||||
case 'NOT_FOUND':
|
||||
return apiErrorMessage || '资源不存在';
|
||||
case 'CONFLICT':
|
||||
return apiErrorMessage || '资源冲突';
|
||||
|
||||
// 业务特定错误码
|
||||
case 'PILE_CODE_EXISTS':
|
||||
return apiErrorMessage || '充电桩编码已存在';
|
||||
case 'STATION_NAME_EXISTS':
|
||||
return apiErrorMessage || '充电站名称已存在';
|
||||
case 'GUN_CODE_EXISTS':
|
||||
return apiErrorMessage || '充电枪编号已存在';
|
||||
case 'PILE_NOT_FOUND':
|
||||
return apiErrorMessage || '充电桩不存在';
|
||||
case 'STATION_NOT_FOUND':
|
||||
return apiErrorMessage || '充电站不存在';
|
||||
case 'GUN_NOT_FOUND':
|
||||
return apiErrorMessage || '充电枪不存在';
|
||||
|
||||
default:
|
||||
// 对于未知错误码,继续使用消息内容
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据HTTP状态码提供后备错误信息
|
||||
switch (status) {
|
||||
case 400:
|
||||
return apiErrorMessage || '请求参数错误,请检查输入信息';
|
||||
case 401:
|
||||
return apiErrorMessage || '未授权,请重新登录';
|
||||
case 403:
|
||||
return apiErrorMessage || '没有权限执行此操作';
|
||||
case 404:
|
||||
return apiErrorMessage || '请求的资源不存在';
|
||||
case 409:
|
||||
return apiErrorMessage || '数据冲突,请刷新后重试';
|
||||
case 422:
|
||||
return apiErrorMessage || '数据验证失败,请检查输入信息';
|
||||
case 500:
|
||||
return apiErrorMessage || '服务器内部错误,请稍后重试';
|
||||
case 502:
|
||||
return '服务器网关错误,请稍后重试';
|
||||
case 503:
|
||||
return '服务不可用,请稍后重试';
|
||||
case 504:
|
||||
return '服务器响应超时,请稍后重试';
|
||||
default:
|
||||
return apiErrorMessage || `操作失败(状态码: ${status})`;
|
||||
}
|
||||
};
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
const { status } = error.response;
|
||||
const config = error.config;
|
||||
const data = error.response.data as any;
|
||||
const apiErrorMessage = data?.message;
|
||||
// const errorCode = data?.errorCode; // 暂时不在拦截器中使用errorCode
|
||||
|
||||
// 如果是登录接口的401错误,不进行全局处理,让组件自己处理
|
||||
if (status === 401 && config?.url?.includes('/api/auth/login')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
message.error(apiErrorMessage || '未授权,请重新登录');
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
break;
|
||||
case 403:
|
||||
message.error(apiErrorMessage || '没有权限访问');
|
||||
break;
|
||||
case 404:
|
||||
message.error(apiErrorMessage || '请求的资源不存在');
|
||||
break;
|
||||
default:
|
||||
// 对于其他错误(包括500),不在拦截器中显示消息,让组件自己处理
|
||||
break;
|
||||
}
|
||||
} else if (error.request) {
|
||||
message.error('网络错误,请检查网络连接');
|
||||
} else {
|
||||
message.error('请求配置错误');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export { getErrorMessage, api };
|
||||
export default api;
|
||||
69
jcpp-web-ui/src/services/dashboardService.ts
Normal file
69
jcpp-web-ui/src/services/dashboardService.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import {api} from './api';
|
||||
|
||||
/**
|
||||
* 总览统计
|
||||
*/
|
||||
export interface Overview {
|
||||
totalStations: number; // 总充电站数
|
||||
totalPiles: number; // 总充电桩数
|
||||
totalGuns: number; // 总充电枪数
|
||||
}
|
||||
|
||||
/**
|
||||
* 充电桩在线状态分布
|
||||
*/
|
||||
export interface PileStatusDistribution {
|
||||
onlinePiles: number; // 在线充电桩数
|
||||
offlinePiles: number; // 离线充电桩数
|
||||
totalPiles: number; // 总充电桩数
|
||||
onlinePercentage: number; // 在线百分比
|
||||
offlinePercentage: number; // 离线百分比
|
||||
}
|
||||
|
||||
/**
|
||||
* 充电枪运行状态分布
|
||||
*/
|
||||
export interface GunStatusDistribution {
|
||||
idleGuns: number; // 空闲 (IDLE)
|
||||
insertedGuns: number; // 已插枪未充电 (INSERTED)
|
||||
chargingGuns: number; // 充电中 (CHARGING)
|
||||
chargeCompleteGuns: number; // 充电完成 (CHARGE_COMPLETE)
|
||||
dischargeReadyGuns: number; // 放电准备 (DISCHARGE_READY)
|
||||
dischargingGuns: number; // 放电中 (DISCHARGING)
|
||||
dischargeCompleteGuns: number; // 放电完成 (DISCHARGE_COMPLETE)
|
||||
reservedGuns: number; // 预约 (RESERVED)
|
||||
faultGuns: number; // 故障 (FAULT)
|
||||
totalGuns: number; // 总充电枪数
|
||||
idlePercentage: number; // 空闲百分比
|
||||
insertedPercentage: number; // 已插枪百分比
|
||||
chargingPercentage: number; // 充电中百分比
|
||||
chargeCompletePercentage: number; // 充电完成百分比
|
||||
dischargeReadyPercentage: number; // 放电准备百分比
|
||||
dischargingPercentage: number; // 放电中百分比
|
||||
dischargeCompletePercentage: number; // 放电完成百分比
|
||||
reservedPercentage: number; // 预约百分比
|
||||
faultPercentage: number; // 故障百分比
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘统计数据
|
||||
*/
|
||||
export interface DashboardStats {
|
||||
overview: Overview;
|
||||
pileStatusDistribution: PileStatusDistribution;
|
||||
gunStatusDistribution: GunStatusDistribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仪表盘统计数据
|
||||
*/
|
||||
export const getDashboardStats = async (): Promise<DashboardStats> => {
|
||||
const response = await api.get('/api/dashboard/stats');
|
||||
return response.data.data;
|
||||
};
|
||||
33
jcpp-web-ui/src/services/gunService.ts
Normal file
33
jcpp-web-ui/src/services/gunService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import {api} from './api';
|
||||
import type {Gun, GunCreateRequest, PageResponse, QueryParams} from '../types';
|
||||
|
||||
export const getGuns = async (params: QueryParams): Promise<PageResponse<Gun>> => {
|
||||
const response = await api.get('/api/guns', { params });
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createGun = async (data: GunCreateRequest): Promise<Gun> => {
|
||||
const response = await api.post('/api/guns', data);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateGun = async (id: string, data: Partial<Gun>): Promise<Gun> => {
|
||||
const response = await api.put(`/api/guns/${id}`, data);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const deleteGun = async (id: string): Promise<void> => {
|
||||
await api.delete(`/api/guns/${id}`);
|
||||
};
|
||||
|
||||
export const getGun = async (id: string): Promise<Gun> => {
|
||||
const response = await api.get(`/api/guns/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
71
jcpp-web-ui/src/services/pileService.ts
Normal file
71
jcpp-web-ui/src/services/pileService.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import api from './api';
|
||||
import {
|
||||
ApiResponse,
|
||||
PageResponse,
|
||||
Pile,
|
||||
PileCreateRequest,
|
||||
PileOption,
|
||||
PileQueryRequest,
|
||||
PileUpdateRequest
|
||||
} from '../types';
|
||||
|
||||
// 增强的API响应类型,包含HTTP状态码
|
||||
export interface EnhancedApiResponse<T> extends ApiResponse<T> {
|
||||
httpStatus: number;
|
||||
}
|
||||
|
||||
// 充电桩相关API
|
||||
export const pileService = {
|
||||
// 分页查询充电桩
|
||||
async getPiles(params: PileQueryRequest): Promise<ApiResponse<PageResponse<Pile>>> {
|
||||
console.log('🔍 前端发送的查询参数:', params); // 添加调试日志
|
||||
const response = await api.get('/api/piles', { params });
|
||||
console.log('📡 后端返回的响应:', response.data); // 添加调试日志
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 根据ID获取充电桩详情
|
||||
async getPile(id: string): Promise<ApiResponse<Pile>> {
|
||||
const response = await api.get(`/api/piles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 创建充电桩
|
||||
async createPile(data: PileCreateRequest): Promise<EnhancedApiResponse<Pile>> {
|
||||
const response = await api.post('/api/piles', data);
|
||||
return {
|
||||
...response.data,
|
||||
httpStatus: response.status
|
||||
};
|
||||
},
|
||||
|
||||
// 更新充电桩
|
||||
async updatePile(id: string, data: PileUpdateRequest): Promise<EnhancedApiResponse<Pile>> {
|
||||
const response = await api.put(`/api/piles/${id}`, data);
|
||||
return {
|
||||
...response.data,
|
||||
httpStatus: response.status
|
||||
};
|
||||
},
|
||||
|
||||
// 删除充电桩
|
||||
async deletePile(id: string): Promise<EnhancedApiResponse<void>> {
|
||||
const response = await api.delete(`/api/piles/${id}`);
|
||||
return {
|
||||
...response.data,
|
||||
httpStatus: response.status
|
||||
};
|
||||
},
|
||||
|
||||
// 获取充电桩选项列表
|
||||
async getPileOptions(): Promise<ApiResponse<PileOption[]>> {
|
||||
const response = await api.get('/api/piles/options');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
22
jcpp-web-ui/src/services/protocolService.ts
Normal file
22
jcpp-web-ui/src/services/protocolService.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import {api} from './api';
|
||||
|
||||
// 协议选项接口
|
||||
export interface ProtocolOption {
|
||||
value: string; // 协议标识符
|
||||
label: string; // 显示名称
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的协议列表
|
||||
* @returns 协议选项列表
|
||||
*/
|
||||
export const getSupportedProtocols = async (): Promise<ProtocolOption[]> => {
|
||||
const response = await api.get('/api/protocols/supported');
|
||||
return response.data.data || [];
|
||||
};
|
||||
42
jcpp-web-ui/src/services/stationService.ts
Normal file
42
jcpp-web-ui/src/services/stationService.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
import {api} from './api';
|
||||
import type {PageResponse, QueryParams, Station, StationOption, StationSearchRequest} from '../types';
|
||||
|
||||
export const getStations = async (params: QueryParams): Promise<PageResponse<Station>> => {
|
||||
const response = await api.get('/api/stations', { params });
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createStation = async (data: Partial<Station>): Promise<Station> => {
|
||||
const response = await api.post('/api/stations', data);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateStation = async (id: string, data: Partial<Station>): Promise<Station> => {
|
||||
const response = await api.put(`/api/stations/${id}`, data);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const deleteStation = async (id: string): Promise<void> => {
|
||||
await api.delete(`/api/stations/${id}`);
|
||||
};
|
||||
|
||||
export const getStation = async (id: string): Promise<Station> => {
|
||||
const response = await api.get(`/api/stations/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const searchStationOptions = async (params: StationSearchRequest): Promise<StationOption[]> => {
|
||||
const response = await api.get('/api/stations/search', { params });
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const getStationOptions = async (): Promise<StationOption[]> => {
|
||||
const response = await api.get('/api/stations/options');
|
||||
return response.data.data;
|
||||
};
|
||||
228
jcpp-web-ui/src/types/index.ts
Normal file
228
jcpp-web-ui/src/types/index.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
|
||||
// API响应通用格式
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PageResponse<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
sortField?: string; // 修改为与后端一致
|
||||
sortOrder?: 'asc' | 'desc'; // 修改为与后端一致的小写
|
||||
search?: string; // 添加后端的search字段
|
||||
}
|
||||
|
||||
// 充电桩相关类型
|
||||
export interface Pile {
|
||||
id: string;
|
||||
pileName: string;
|
||||
pileCode: string;
|
||||
protocol: string;
|
||||
type: 'AC' | 'DC';
|
||||
brand: string;
|
||||
model: string;
|
||||
manufacturer: string;
|
||||
stationId: string;
|
||||
status: 'IDLE' | 'CHARGING' | 'FAULT' | 'OFFLINE';
|
||||
connectedAt?: number;
|
||||
disconnectedAt?: number;
|
||||
lastActiveTime?: number;
|
||||
createdTime: number;
|
||||
updatedTime?: number;
|
||||
}
|
||||
|
||||
// 充电桩创建请求
|
||||
export interface PileCreateRequest {
|
||||
pileName: string;
|
||||
pileCode: string;
|
||||
protocol: string;
|
||||
type: 'AC' | 'DC';
|
||||
brand: string;
|
||||
model: string;
|
||||
manufacturer: string;
|
||||
stationId: string;
|
||||
}
|
||||
|
||||
// 充电桩更新请求
|
||||
export interface PileUpdateRequest {
|
||||
pileName: string;
|
||||
protocol: string;
|
||||
type: 'AC' | 'DC';
|
||||
brand: string;
|
||||
model: string;
|
||||
manufacturer: string;
|
||||
stationId: string;
|
||||
}
|
||||
|
||||
// 充电桩查询请求
|
||||
export interface PileQueryRequest extends PageRequest {
|
||||
pileName?: string;
|
||||
pileCode?: string;
|
||||
protocol?: string; // 添加协议字段
|
||||
stationId?: string; // 添加充电站ID字段
|
||||
brand?: string;
|
||||
model?: string; // 添加型号字段
|
||||
manufacturer?: string;
|
||||
type?: 'AC' | 'DC';
|
||||
status?: 'ONLINE' | 'OFFLINE'; // 修改状态字段
|
||||
}
|
||||
|
||||
// 充电站相关类型
|
||||
export interface Station {
|
||||
id: string;
|
||||
stationName: string;
|
||||
stationCode: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
province?: string;
|
||||
city?: string;
|
||||
county?: string;
|
||||
address?: string;
|
||||
createdTime: number;
|
||||
updatedTime?: number;
|
||||
}
|
||||
|
||||
// 充电站选项
|
||||
export interface StationOption {
|
||||
id: string;
|
||||
label: string;
|
||||
stationName: string;
|
||||
stationCode: string;
|
||||
}
|
||||
|
||||
// 充电站搜索请求
|
||||
export interface StationSearchRequest {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// 充电枪相关类型
|
||||
export interface Gun {
|
||||
id: string;
|
||||
gunName: string;
|
||||
gunCode: string;
|
||||
gunNo: number;
|
||||
stationId: string;
|
||||
stationName?: string; // 所属充电站名称
|
||||
pileId: string;
|
||||
pileCode: string;
|
||||
pileName?: string; // 所属充电桩名称
|
||||
runStatus: 'IDLE' | 'INSERTED' | 'CHARGING' | 'CHARGE_COMPLETE' | 'DISCHARGE_READY' | 'DISCHARGING' | 'DISCHARGE_COMPLETE' | 'RESERVED' | 'FAULT';
|
||||
createdTime: number;
|
||||
updatedTime?: number;
|
||||
}
|
||||
|
||||
// 充电枪创建请求
|
||||
export interface GunCreateRequest {
|
||||
gunName: string;
|
||||
gunNo: string;
|
||||
gunCode: string;
|
||||
stationId: string;
|
||||
pileId: string;
|
||||
}
|
||||
|
||||
// 充电枪更新请求
|
||||
export interface GunUpdateRequest {
|
||||
gunName: string;
|
||||
gunNo: string;
|
||||
stationId: string;
|
||||
pileId: string;
|
||||
}
|
||||
|
||||
// 充电桩选项
|
||||
export interface PileOption {
|
||||
id: string;
|
||||
label: string;
|
||||
pileName: string;
|
||||
pileCode: string;
|
||||
stationId: string;
|
||||
}
|
||||
|
||||
// 仪表盘统计数据
|
||||
export interface DashboardStats {
|
||||
overview: {
|
||||
totalStations: number;
|
||||
totalPiles: number;
|
||||
totalGuns: number;
|
||||
onlineGuns: number;
|
||||
offlineGuns: number;
|
||||
idleGuns: number;
|
||||
chargingGuns: number;
|
||||
faultGuns: number;
|
||||
};
|
||||
recentActivities: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: number;
|
||||
type: 'INFO' | 'WARNING' | 'ERROR';
|
||||
}>;
|
||||
}
|
||||
|
||||
// 通用查询参数
|
||||
export interface QueryParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// 分页组件参数
|
||||
export interface Pagination {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 充电站创建和更新请求
|
||||
export interface StationCreateRequest {
|
||||
stationName: string;
|
||||
stationCode: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
province: string;
|
||||
city: string;
|
||||
county?: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface StationUpdateRequest extends StationCreateRequest {
|
||||
}
|
||||
|
||||
// 充电站查询请求
|
||||
export interface StationQueryRequest extends PageRequest {
|
||||
stationName?: string;
|
||||
stationCode?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
county?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
// 充电枪查询请求
|
||||
export interface GunQueryRequest extends PageRequest {
|
||||
gunName?: string;
|
||||
gunCode?: string;
|
||||
gunNo?: string;
|
||||
stationId?: string;
|
||||
pileId?: string;
|
||||
runStatus?: 'IDLE' | 'INSERTED' | 'CHARGING' | 'CHARGE_COMPLETE' | 'DISCHARGE_READY' | 'DISCHARGING' | 'DISCHARGE_COMPLETE' | 'RESERVED' | 'FAULT';
|
||||
}
|
||||
178
jcpp-web-ui/src/utils/index.ts
Normal file
178
jcpp-web-ui/src/utils/index.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
|
||||
* 微信:mohan_88888
|
||||
* 抖音:程序员三丙
|
||||
* 付费课程知识星球:https://t.zsxq.com/aKtXo
|
||||
*/
|
||||
|
||||
import {message} from 'antd';
|
||||
|
||||
// 格式化时间戳为本地时间字符串 yyyy-MM-dd HH:mm:ss
|
||||
export const formatTimestamp = (timestamp?: number): string => {
|
||||
if (!timestamp || timestamp <= 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// 检查日期是否有效
|
||||
if (isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// 生成充电桩编码(14位时间格式)
|
||||
export const generatePileCode = (): string => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
||||
};
|
||||
|
||||
// 生成充电站编码(S+yyyyMMdd+三位随机数)
|
||||
export const generateStationCode = (): string => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
// 生成三位随机数
|
||||
const randomNum = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
|
||||
|
||||
return `S${year}${month}${day}${randomNum}`;
|
||||
};
|
||||
|
||||
// 生成充电枪编码(充电桩编码+枪号)
|
||||
export const generateGunCode = (pileCode: string, gunNo: string): string => {
|
||||
if (!pileCode || !gunNo) {
|
||||
return '';
|
||||
}
|
||||
return `${pileCode}-${gunNo}`;
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
export const getStatusColor = (status: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
'ONLINE': 'green',
|
||||
'OFFLINE': 'gray'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
export const getStatusText = (status: string): string => {
|
||||
const texts: Record<string, string> = {
|
||||
'ONLINE': '在线',
|
||||
'OFFLINE': '离线'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
// 获取类型文本
|
||||
export const getTypeText = (type: string): string => {
|
||||
return type === 'AC' ? '交流桩' : '直流桩';
|
||||
};
|
||||
|
||||
// 获取协议文本
|
||||
export const getProtocolText = (protocol: string): string => {
|
||||
const protocolTexts: Record<string, string> = {
|
||||
'yunkuaichongV150': '云快充V1.5.0',
|
||||
'yunkuaichongV160': '云快充V1.6.0',
|
||||
'yunkuaichongV170': '云快充V1.7.0',
|
||||
'lvnengV340': '绿能V3.4.0'
|
||||
};
|
||||
return protocolTexts[protocol] || protocol;
|
||||
};
|
||||
|
||||
// 防抖函数
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
// 获取错误信息
|
||||
export const getErrorMessage = (error: any): string => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
|
||||
// 根据HTTP状态码返回相应的错误信息
|
||||
switch (status) {
|
||||
case 400:
|
||||
return data?.message || '请求参数错误,请检查输入信息';
|
||||
case 401:
|
||||
return '未授权,请重新登录';
|
||||
case 403:
|
||||
return '权限不足,无法执行此操作';
|
||||
case 404:
|
||||
return '请求的资源不存在';
|
||||
case 409:
|
||||
return data?.message || '数据冲突,请刷新后重试';
|
||||
case 500:
|
||||
return '服务器内部错误,请稍后重试';
|
||||
default:
|
||||
return data?.message || `操作失败(状态码: ${status})`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
return '网络错误,请检查网络连接';
|
||||
} else {
|
||||
return error.message || '未知错误,请重试';
|
||||
}
|
||||
};
|
||||
|
||||
// Message工具函数 - 根据Ant Design官方推荐的方式
|
||||
export const showMessage = {
|
||||
success: (content: string) => {
|
||||
message.success({
|
||||
content,
|
||||
duration: 3, // 成功消息3秒
|
||||
});
|
||||
},
|
||||
|
||||
error: (content: string) => {
|
||||
message.error({
|
||||
content,
|
||||
duration: 10, // 错误消息10秒
|
||||
});
|
||||
},
|
||||
|
||||
warning: (content: string) => {
|
||||
message.warning({
|
||||
content,
|
||||
duration: 10, // 警告消息10秒
|
||||
});
|
||||
},
|
||||
|
||||
info: (content: string) => {
|
||||
message.info({
|
||||
content,
|
||||
duration: 3, // 信息消息3秒
|
||||
});
|
||||
},
|
||||
|
||||
loading: (content: string) => {
|
||||
return message.loading({
|
||||
content,
|
||||
duration: 0, // 加载消息不自动关闭
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user