* !44 comment
* !39 添加下行日志打印
* !36 扩展计价领域模型
* !35 webui 初步成型
* !34 webui 初步成型
This commit is contained in:
三丙
2025-09-09 08:23:59 +00:00
parent 921045af8f
commit 58580ca11e
372 changed files with 37900 additions and 1206 deletions

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

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

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

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

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

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

View 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>sanbingsanbing@123456</p>
</div>
</Card>
</div>
</div>
);
};
export default Login;

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff