* !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

23
jcpp-web-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

201
jcpp-web-ui/LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

19432
jcpp-web-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
jcpp-web-ui/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "jcpp-web-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@antv/g2": "^5.4.0",
"@types/node": "^16.18.126",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"antd": "^5.27.2",
"axios": "^1.11.0",
"china-division": "^2.7.0",
"echarts": "^6.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5"
},
"scripts": {
"start": "GENERATE_SOURCEMAP=false react-scripts start",
"build": "GENERATE_SOURCEMAP=false react-scripts build",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

159
jcpp-web-ui/pom.xml Normal file
View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
开源代码,仅供学习和交流研究使用,商用请联系三丙
微信mohan_88888
抖音:程序员三丙
付费课程知识星球https://t.zsxq.com/aKtXo
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>sanbing</groupId>
<artifactId>jcpp-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jcpp-web-ui</artifactId>
<packaging>jar</packaging>
<name>JChargePointProtocol Server Web UI Module</name>
<description>前端模块</description>
<properties>
<main.dir>${basedir}/..</main.dir>
</properties>
<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<configuration>
<installDirectory>target</installDirectory>
<workingDirectory>${basedir}</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v22.9.0</nodeVersion>
<npmVersion>10.8.0</npmVersion>
<nodeDownloadRoot>https://npmmirror.com/mirrors/node/</nodeDownloadRoot>
</configuration>
</execution>
<execution>
<id>set npm registry</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>config set registry https://registry.npmmirror.com</arguments>
</configuration>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install --no-optional</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend-build</id>
<phase>compile</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/classes/public</outputDirectory>
<resources>
<resource>
<directory>${basedir}/build</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>npm-build</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<configuration>
<installDirectory>target</installDirectory>
<workingDirectory>${basedir}</workingDirectory>
</configuration>
<executions>
<execution>
<id>npm build</id>
<phase>compile</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>npm-start</id>
<activation>
<property>
<name>npm-start</name>
</property>
</activation>
<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<configuration>
<installDirectory>target</installDirectory>
<workingDirectory>${basedir}</workingDirectory>
</configuration>
<executions>
<execution>
<id>npm start</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>start</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,28 @@
<!--
开源代码,仅供学习和交流研究使用,商用请联系三丙
微信mohan_88888
抖音:程序员三丙
付费课程知识星球https://t.zsxq.com/aKtXo
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="JCPP充电桩管理系统"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>JCPP充电桩管理系统</title>
</head>
<body>
<noscript>您需要启用JavaScript才能运行此应用程序。</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{
"short_name": "JCPP充电桩",
"name": "JCPP充电桩协议管理系统",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,10 @@
====
开源代码,仅供学习和交流研究使用,商用请联系三丙
微信mohan_88888
抖音:程序员三丙
付费课程知识星球https://t.zsxq.com/aKtXo
====
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

181
jcpp-web-ui/src/App.css Normal file
View 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
View 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;

View 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

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

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

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

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

@@ -0,0 +1,7 @@
/*
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
/// <reference types="react-scripts" />

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

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

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

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

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

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

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

View 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, // 加载消息不自动关闭
});
}
};

26
jcpp-web-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}