feat: 完善智慧停车综合管理平台源码

This commit is contained in:
admin
2026-10-10 17:20:00 +08:00
commit 9c9079c998
170 changed files with 27985 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"standard-with-typescript",
"prettier"
],
"overrides": [],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json",
"tsconfigRootDir": "."
},
"settings": {
"react": {
"version": "^18.0.0"
}
},
"plugins": ["react"],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/strict-boolean-expressions": "off"
},
"ignorePatterns": [
"build/**",
"dist/**",
"src/assets/**",
"src/.umi/**",
"src/.umi-production/**"
]
}
+9
View File
@@ -0,0 +1,9 @@
/node_modules
/.env.local
/.umirc.local.ts
/config/config.local.ts
/src/.umi
/src/.umi-production
/src/.umi-test
/dist
.swc
+2
View File
@@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com
strict-peer-dependencies=false
+3
View File
@@ -0,0 +1,3 @@
node_modules
.umi
.umi-production
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}
+134
View File
@@ -0,0 +1,134 @@
import { defineConfig } from 'umi';
import zhCN from 'antd/locale/zh_CN';
import { theme } from 'antd';
const panelDarkTheme = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#22d3ee',
colorInfo: '#38bdf8',
colorSuccess: '#4ade80',
colorWarning: '#fbbf24',
colorError: '#fb7185',
colorTextBase: '#e6f7ff',
colorBgBase: '#07142e',
borderRadius: 14,
wireframe: false,
fontFamily: 'Inter, PingFang SC, Microsoft YaHei, system-ui, sans-serif',
},
components: {
Layout: {
bodyBg: 'transparent',
headerBg: 'transparent',
siderBg: 'transparent',
},
Card: {
colorBgContainer: 'rgba(7, 18, 39, 0.84)',
colorBorderSecondary: 'rgba(103, 232, 249, 0.16)',
headerBg: 'rgba(34, 211, 238, 0.08)',
headerFontSize: 16,
},
Table: {
colorBgContainer: 'rgba(2, 8, 23, 0.2)',
colorHeaderBg: 'rgba(34, 211, 238, 0.12)',
colorBgContainerHover: 'rgba(34, 211, 238, 0.1)',
colorText: '#e6f7ff',
headerColor: '#cffafe',
borderColor: 'rgba(103, 232, 249, 0.16)',
cellPaddingBlock: 10,
},
Input: {
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
colorBorder: 'rgba(103, 232, 249, 0.28)',
activeBorderColor: '#22d3ee',
colorText: '#e6f7ff',
},
InputNumber: {
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
colorBorder: 'rgba(103, 232, 249, 0.28)',
activeBorderColor: '#22d3ee',
colorText: '#e6f7ff',
},
Select: {
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
colorBorder: 'rgba(103, 232, 249, 0.28)',
optionSelectedBg: 'rgba(34, 211, 238, 0.18)',
colorText: '#e6f7ff',
},
DatePicker: {
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
colorBorder: 'rgba(103, 232, 249, 0.28)',
colorText: '#e6f7ff',
},
Button: {
primaryShadow: '0 12px 26px rgba(37, 99, 235, 0.28)',
defaultBg: 'rgba(255, 255, 255, 0.05)',
defaultColor: '#a5f3fc',
},
Modal: {
colorBgContainer: '#081428',
headerColor: '#e6f7ff',
contentColor: '#e6f7ff',
},
Drawer: {
colorBgElevated: '#081428',
colorText: '#e6f7ff',
},
Popover: {
colorBgElevated: '#0b1b38',
colorText: '#e6f7ff',
},
Tooltip: {
colorBgSpotlight: '#0b1b38',
colorTextLightSolid: '#e6f7ff',
},
Tabs: {
itemColor: 'rgba(230, 247, 255, 0.68)',
itemSelectedColor: '#67e8f9',
inkBarColor: '#22d3ee',
},
Collapse: {
colorBgContainer: 'rgba(7, 18, 39, 0.68)',
colorBorder: 'rgba(103, 232, 249, 0.18)',
headerBg: 'rgba(34, 211, 238, 0.07)',
},
},
};
export default defineConfig({
plugins: ['@umijs/plugins/dist/antd', '@umijs/plugins/dist/locale'],
routes: [
{ path: '/', component: '@/pages/index', layout: false },
{ path: '/login', component: '@/pages/login', layout: false },
{ path: '/overview', component: '@/pages/overview' },
{ path: '/campusState', component: '@/pages/campusState' },
{ path: '/monitScreen', component: '@/pages/monitScreen' },
{ path: '/alarms', component: '@/pages/alarms' },
{ path: '/ai-chat', component: '@/pages/aiChat' },
{ path: '/userInfo', component: '@/pages/userInfo' },
{ path: '/admin/mapManage', component: '@/pages/admin/mapManage' },
{ path: '/admin/camerasManage', component: '@/pages/admin/camerasManage' },
{
path: '/admin/alarmRulesManage',
component: '@/pages/admin/alarmRulesManage',
},
{ path: '/admin/usersManage', component: '@/pages/admin/usersManage' },
{ path: '/*', redirect: '/' },
],
clientLoader: {},
npmClient: 'pnpm',
antd: {
configProvider: {
locale: zhCN,
theme: panelDarkTheme,
},
},
locale: { default: 'zh-CN', antd: false, baseSeparator: '-' },
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
history: { type: 'hash' },
metas: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'theme-color', content: '#07142e' },
],
links: [{ rel: 'icon', type: 'image/svg+xml', href: './favicon.svg' }],
});
+490
View File
@@ -0,0 +1,490 @@
import constants from '../src/constants';
import type ServiceTypes from '../src/services/serviceTypes';
const MockApis: { [api in keyof ServiceTypes]: ServiceTypes[api]['res'] } = {
'POST /api/user/login': {
data: {
token: '123',
userInfo: {
avatarURL:
'https://himg.bdimg.com/sys/portrait/item/pp.1.2c68ba76.fN8DC9UiXh1lKjACEyEBTg.jpg',
nickname: 'admin',
role: constants.userRole.ADMIN,
tel: '12345678901',
email: '',
username: 'admin',
},
},
message: '',
success: true,
},
'GET /api/user/getCampusState': {
data: {
cameraTotal: 20,
cameraOnline: 10,
cameraAlarm: 5,
alarmEventPending: 3,
cameraList: [
{
cameraName: '摄像头1',
cameraID: 1,
cameraStatus: 'normal',
latlng: [36.666394717516695, 117.13263524798919],
},
{
cameraName: '摄像头2',
cameraID: 2,
cameraStatus: 'offline',
latlng: [36.66553375772535, 117.13452323144844],
},
{
cameraName: '摄像头3',
cameraID: 3,
cameraStatus: 'alarm',
latlng: [36.666747708247264, 117.13250652184426],
},
],
},
message: '',
success: true,
},
'POST /api/user/resolveAlarm': {
data: {},
success: true,
message: '',
},
'GET /api/user/getCameraInfo': {
data: {
cameraName: '摄像头1',
cameraID: 1,
cameraStatus: 'normal',
latlng: [36.666394717516695, 117.13263524798919],
cameraModel: '摄像头型号1',
alarmRules: [
{
alarmRuleID: 1,
alarmRuleName: '报警规则1',
},
{
alarmRuleID: 2,
alarmRuleName: '报警规则2',
},
{
alarmRuleID: 3,
alarmRuleName: '报警规则3',
},
],
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
alarmEvents: [
{
eventID: 1,
alarmTime: '2020-01-01 00:00:00',
alarmRule: { alarmRuleID: 1, alarmRuleName: '报警规则1' },
resolved: false,
},
{
eventID: 2,
alarmTime: '2020-01-01 00:00:00',
alarmRule: { alarmRuleID: 1, alarmRuleName: '报警规则1' },
resolved: false,
},
],
},
message: '',
success: true,
},
'GET /api/user/getAlarmEvents': {
data: {
list: [
{
eventID: 549,
alarmTime: '2023-04-26 15:44:49',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/2141dae8fb8bea60d85b.jpg',
},
{
eventID: 548,
alarmTime: '2023-04-26 15:37:20',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/aba3863a01e7236920d5.jpg',
},
{
eventID: 547,
alarmTime: '2023-04-26 15:35:04',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/1f1ceed3f0c35bb87910.jpg',
},
{
eventID: 546,
alarmTime: '2023-04-26 15:26:14',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/75b9aebaa9b7fc58a79b.jpg',
},
{
eventID: 545,
alarmTime: '2023-04-26 14:12:00',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/ec08fa2c1ed46495370a.jpg',
},
{
eventID: 544,
alarmTime: '2023-04-26 13:45:49',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/744465efbe7d7b40fa34.jpg',
},
{
eventID: 543,
alarmTime: '2023-04-26 13:44:15',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/459467958020cade7923.jpg',
},
{
eventID: 542,
alarmTime: '2023-04-26 13:42:49',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/d537b15a67ce7cdef3ac.jpg',
},
{
eventID: 541,
alarmTime: '2023-04-26 13:41:47',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/61077f07aa8afd27e6ae.jpg',
},
{
eventID: 540,
alarmTime: '2023-04-26 13:40:03',
alarmRule: {
alarmRuleID: 1,
alarmRuleName: '人体检测-2人以上',
},
resolved: false,
cameraID: 3,
cameraName: '本机测试摄像头',
cameraLatlng: [36.6670821627257, 117.13324069971353],
cameraModel: 'USB2.0 HD UVC WebCam',
alarmPicUrl: '/public/668c2c59bab771870e4f.jpg',
},
],
total: 549,
},
message: '',
success: true,
},
'GET /api/user/getMonitList': {
data: [
{
cameraName: '摄像头1',
cameraID: 1,
cameraStatus: 'normal',
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
},
{
cameraName: '摄像头2',
cameraID: 2,
cameraStatus: 'normal',
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
},
{
cameraName: '摄像头3',
cameraID: 3,
cameraStatus: 'normal',
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
},
{
cameraName: '摄像头4',
cameraID: 4,
cameraStatus: 'normal',
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
},
{
cameraName: '摄像头5',
cameraID: 5,
cameraStatus: 'normal',
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
},
],
message: '',
success: true,
},
'GET /api/user/getUserInfo': {
data: {
avatarURL:
'https://himg.bdimg.com/sys/portrait/item/pp.1.2c68ba76.fN8DC9UiXh1lKjACEyEBTg.jpg',
username: 'admin',
role: constants.userRole.ADMIN,
tel: '12345678901',
email: '123@mail.com',
nickname: 'admin',
},
message: '',
success: true,
},
'POST /api/user/updateUserInfo': {
data: {},
message: '',
success: true,
},
'POST /api/user/updatePassword': {
data: {},
message: '',
success: true,
},
'GET /api/user/getMapConfig': {
data: {
mapOptions: {
attributionControl: false,
center: [36.66669, 117.13272],
zoom: 17,
minZoom: 17,
maxZoom: 17,
zoomControl: false,
},
layer: {
type: 'tileLayer',
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
},
},
message: '',
success: true,
},
'POST /api/admin/updateMapConfig': {
data: {},
message: '',
success: true,
},
'GET /api/admin/getCameraList': {
data: [
{
cameraName: '摄像头1',
cameraID: 1,
cameraStatus: 'normal',
latlng: [36.666394717516695, 117.13263524798919],
cameraModel: '摄像头型号1',
alarmRules: [{ alarmRuleID: 1, alarmRuleName: '报警规则1' }],
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
rtspUrl:
'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov',
},
{
cameraName: '摄像头2',
cameraID: 2,
cameraStatus: 'offline',
latlng: [36.66553375772535, 117.13452323144844],
cameraModel: '摄像头型号2',
alarmRules: [{ alarmRuleID: 2, alarmRuleName: '报警规则2' }],
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
rtspUrl:
'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov',
},
{
cameraName: '摄像头3',
cameraID: 3,
cameraStatus: 'alarm',
latlng: [36.666747708247264, 117.13250652184426],
cameraModel: '摄像头型号3',
alarmRules: [{ alarmRuleID: 3, alarmRuleName: '报警规则3' }],
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
rtspUrl:
'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov',
},
],
message: '',
success: true,
},
'POST /api/admin/addCamera': {
data: {},
message: '',
success: true,
},
'POST /api/admin/updateCamera': {
data: {},
message: '',
success: true,
},
'POST /api/admin/deleteCamera': {
data: {},
message: '',
success: true,
},
'GET /api/admin/getAlarmRuleList': {
data: [
{
alarmRuleID: 1,
alarmRuleName: '报警规则1',
relatedCameras: [
{ cameraID: 1, cameraName: '摄像头1' },
{ cameraID: 2, cameraName: '摄像头2' },
],
algorithmType: 'body',
enabled: true,
triggerCondition: {
count: { max: 2, min: 1 },
time: { dayOfWeek: [1, 2, 3], timeRange: ['00:00:00', '23:59:59'] },
},
},
{
alarmRuleID: 2,
alarmRuleName: '报警规则2',
relatedCameras: [
{ cameraID: 1, cameraName: '摄像头1' },
{ cameraID: 2, cameraName: '摄像头2' },
],
algorithmType: 'vehicle',
enabled: true,
triggerCondition: {
count: { max: 2, min: 1 },
time: { dayOfWeek: [1, 2, 3], timeRange: ['00:00:00', '23:59:59'] },
},
},
{
alarmRuleID: 3,
alarmRuleName: '报警规则3',
relatedCameras: [
{ cameraID: 1, cameraName: '摄像头1' },
{ cameraID: 2, cameraName: '摄像头2' },
],
algorithmType: 'body',
enabled: false,
triggerCondition: {
count: { max: 2, min: 1 },
time: { dayOfWeek: [1, 2, 3], timeRange: ['00:00:00', '23:59:59'] },
},
},
],
message: '',
success: true,
},
'POST /api/admin/addAlarmRule': {
data: {},
message: '',
success: true,
},
'POST /api/admin/updateAlarmRule': {
data: {},
message: '',
success: true,
},
'POST /api/admin/deleteAlarmRule': {
data: {},
message: '',
success: true,
},
'GET /api/admin/getUserList': {
data: [
{
username: 'admin',
role: 'admin',
tel: '12345678901',
email: '123@456.com',
nickname: 'admin1',
avatarURL:
'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
},
{
username: 'user',
role: 'user',
tel: '12345678901',
email: '123@456.com',
nickname: 'admin2',
avatarURL:
'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
},
],
message: '',
success: true,
},
'POST /api/admin/addUser': {
data: {},
message: '',
success: true,
},
'POST /api/admin/updateUser': {
data: {},
message: '',
success: true,
},
'POST /api/admin/deleteUser': {
data: {},
message: '',
success: true,
},
'GET /api/ai/getOfflineCameraList': {
data: [{ cameraID: 1 }, { cameraID: 2 }, { cameraID: 3 }],
message: '',
success: true,
},
};
export default MockApis;
+45
View File
@@ -0,0 +1,45 @@
{
"private": true,
"author": "",
"scripts": {
"build": "umi build",
"dev": "umi dev",
"postinstall": "umi setup",
"lint": "tsc && eslint .",
"setup": "umi setup",
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@umijs/plugins": "^4.0.66",
"ahooks": "^3.7.6",
"antd": "^5.4.4",
"antd-img-crop": "^4.12.2",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.7",
"hls.js": "^1.4.0",
"leaflet": "^1.9.3",
"mobx": "^6.9.0",
"mobx-react": "^7.6.0",
"umi": "^4.0.66"
},
"devDependencies": {
"@types/crypto-js": "^4.1.1",
"@types/leaflet": "^1.9.3",
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"eslint": "^8.38.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard-with-typescript": "^34.0.1",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.32.2",
"prettier": "^2.8.7",
"prettier-plugin-organize-imports": "^2.3.4",
"prettier-plugin-packagejson": "^2.4.3",
"tslib": "^2.5.0",
"typescript": "^4.9.5"
}
}
+11055
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="12" y1="8" x2="116" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#07142e"/>
<stop offset="0.52" stop-color="#0b2a52"/>
<stop offset="1" stop-color="#1e1b4b"/>
</linearGradient>
<linearGradient id="cyan" x1="28" y1="24" x2="100" y2="104" gradientUnits="userSpaceOnUse">
<stop stop-color="#67e8f9"/>
<stop offset="0.52" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3.2" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect x="10" y="10" width="108" height="108" rx="28" fill="url(#bg)" stroke="#67e8f9" stroke-opacity=".45" stroke-width="2"/>
<path d="M31 46h46l20-12v60l-20-12H31a8 8 0 0 1-8-8V54a8 8 0 0 1 8-8Z" fill="#081a35" stroke="url(#cyan)" stroke-width="4" filter="url(#glow)"/>
<circle cx="55" cy="68" r="17" fill="#020617" stroke="#67e8f9" stroke-width="4"/>
<circle cx="55" cy="68" r="7" fill="#22d3ee" opacity=".9"/>
<path d="M82 35a42 42 0 0 1 0 66M91 25a56 56 0 0 1 0 86" fill="none" stroke="#8b5cf6" stroke-width="4" stroke-linecap="round" opacity=".72"/>
<path d="M25 25h18M25 25v18M103 103H85m18 0V85" stroke="#67e8f9" stroke-width="3" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
import { defineApp } from 'umi';
import './global.less';
export default defineApp({});
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="12" y1="8" x2="116" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#07142e"/>
<stop offset="0.52" stop-color="#0b2a52"/>
<stop offset="1" stop-color="#1e1b4b"/>
</linearGradient>
<linearGradient id="cyan" x1="28" y1="24" x2="100" y2="104" gradientUnits="userSpaceOnUse">
<stop stop-color="#67e8f9"/>
<stop offset="0.52" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3.2" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect x="10" y="10" width="108" height="108" rx="28" fill="url(#bg)" stroke="#67e8f9" stroke-opacity=".45" stroke-width="2"/>
<path d="M31 46h46l20-12v60l-20-12H31a8 8 0 0 1-8-8V54a8 8 0 0 1 8-8Z" fill="#081a35" stroke="url(#cyan)" stroke-width="4" filter="url(#glow)"/>
<circle cx="55" cy="68" r="17" fill="#020617" stroke="#67e8f9" stroke-width="4"/>
<circle cx="55" cy="68" r="7" fill="#22d3ee" opacity=".9"/>
<path d="M82 35a42 42 0 0 1 0 66M91 25a56 56 0 0 1 0 86" fill="none" stroke="#8b5cf6" stroke-width="4" stroke-linecap="round" opacity=".72"/>
<path d="M25 25h18M25 25v18M103 103H85m18 0V85" stroke="#67e8f9" stroke-width="3" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,29 @@
import { type FC, type ReactElement, useEffect } from 'react';
import { observer } from 'mobx-react';
import mobxStore from '@/mobxStore';
import { history, useLocation } from 'umi';
import { message } from 'antd';
const AccessAuth: FC<{ children: ReactElement }> = ({ children }) => {
const location = useLocation();
// 管理员才可访问的页面
const requiredAdminPages: string[] = [];
useEffect(() => {
if (mobxStore.user.token === '') {
void message.warning('请登录');
history.replace('/login');
return;
}
if (
mobxStore.user.role !== 'admin' &&
requiredAdminPages.some((page) => location.pathname === page)
) {
void message.warning('无权限');
history.back();
}
}, [location.pathname, mobxStore.user.token, mobxStore.user.role]);
return children;
};
export default observer(AccessAuth);
@@ -0,0 +1,17 @@
import constants from '@/constants';
import { Badge } from 'antd';
import { type FC } from 'react';
const CameraStatusBadge: FC<{
status?: 'normal' | 'offline' | 'alarm';
}> = (props) => {
return props.status === constants.cameraStatus.NORMAL ? (
<Badge status="success" text="正常" />
) : props.status === constants.cameraStatus.OFFLINE ? (
<Badge status="default" text="离线" />
) : (
<Badge status="error" text="报警" />
);
};
export default CameraStatusBadge;
@@ -0,0 +1,3 @@
.cameraIcon {
transition: all 0.2s;
}
+122
View File
@@ -0,0 +1,122 @@
import { observer } from 'mobx-react';
import { type FC, useEffect, useRef } from 'react';
import Style from './index.module.less';
import leaflet from 'leaflet';
import 'leaflet/dist/leaflet.css';
import cameraNormal from '../../assets/camera-normal.png';
import cameraAlarm from '../../assets/camera-alarm.png';
import cameraOffline from '../../assets/camera-offline.png';
import type ServiceTypes from '@/services/serviceTypes';
export const createCameraIcon = (
type: 'normal' | 'alarm' | 'offline',
selected: boolean,
): leaflet.Icon => {
const iconUrl =
type === 'normal'
? cameraNormal
: type === 'alarm'
? cameraAlarm
: cameraOffline;
return selected
? leaflet.icon({
iconUrl,
iconSize: [48, 48],
iconAnchor: [24, 48],
tooltipAnchor: [24, -24],
popupAnchor: [0, -48],
className: Style.cameraIcon,
})
: leaflet.icon({
iconUrl,
iconSize: [32, 32],
iconAnchor: [16, 32],
tooltipAnchor: [16, -16],
popupAnchor: [0, -32],
className: Style.cameraIcon,
});
};
const CampusMap: FC<{
cameraList?: ServiceTypes['GET /api/user/getCampusState']['res']['data']['cameraList'];
onCameraClick?: (
currentCameraId: number,
cameraInfo: ServiceTypes['GET /api/user/getCampusState']['res']['data']['cameraList'][0],
) => any;
selectedCameraIds?: number[];
className?: string;
mapConfig?: leaflet.MapOptions;
onInit?: (map: leaflet.Map) => any;
}> = (props) => {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapObjRef = useRef<leaflet.Map>();
const markersRef = useRef<leaflet.LayerGroup<leaflet.Marker>>();
const selectedCameraIdsRef = useRef<number[]>(props.selectedCameraIds ?? []);
const onCameraSelectRef = useRef(props.onCameraClick);
useEffect(() => {
// init map
if (mapDivRef.current != null) {
mapObjRef.current = new leaflet.Map(mapDivRef.current, props.mapConfig);
props.onInit?.(mapObjRef.current);
return () => {
mapObjRef.current?.remove();
};
}
}, [props.mapConfig]);
useEffect(() => {
onCameraSelectRef.current = props.onCameraClick;
}, [props.onCameraClick]);
useEffect(() => {
// render camera markers
if (mapObjRef.current != null && props.cameraList != null) {
markersRef.current = leaflet.layerGroup();
props.cameraList.forEach((camera) => {
const marker = leaflet
.marker(camera.latlng, {
icon: createCameraIcon(
camera.cameraStatus as 'normal' | 'offline' | 'alarm',
selectedCameraIdsRef.current?.includes(camera.cameraID),
),
attribution: JSON.stringify(camera),
})
.bindTooltip(camera.cameraName);
marker.on('click', () => {
onCameraSelectRef.current?.(camera.cameraID, camera);
});
markersRef.current != null && marker.addTo(markersRef.current);
});
markersRef.current?.addTo(mapObjRef.current);
return () => {
markersRef.current?.remove();
};
}
}, [props.cameraList, mapObjRef.current]);
useEffect(() => {
// sync selectedCameraIds with props, and update selected camera icon
if (props.selectedCameraIds == null) return;
selectedCameraIdsRef.current = props.selectedCameraIds;
markersRef.current?.eachLayer((marker) => {
const theCamera: ServiceTypes['GET /api/user/getCampusState']['res']['data']['cameraList'][0] =
JSON.parse(marker.getAttribution?.() ?? '');
(marker as leaflet.Marker).setIcon(
createCameraIcon(
theCamera.cameraStatus as 'normal' | 'offline' | 'alarm',
selectedCameraIdsRef.current?.includes(theCamera.cameraID),
),
);
// set map center to the first selected camera
if (theCamera.cameraID === props.selectedCameraIds?.[0]) {
mapObjRef.current?.setView(theCamera.latlng);
}
});
}, [props.selectedCameraIds, mapObjRef.current]);
return <div ref={mapDivRef} className={props.className} />;
};
export default observer(CampusMap);
@@ -0,0 +1,6 @@
.player {
// hide the timeline
&::-webkit-media-controls-timeline {
display: none;
}
}
@@ -0,0 +1,59 @@
import { useRef, type FC, useEffect } from 'react';
import Styles from './index.module.less';
import constants from '@/constants';
import HLS from 'hls.js';
const HlsPlayer: FC<{
item: {
cameraName: string;
cameraID: number;
cameraStatus: string;
hlsUrl: string;
};
className?: string;
}> = ({ item, className }) => {
const videosRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const videoRef = videosRef.current;
if (!videoRef || item.cameraStatus === constants.cameraStatus.OFFLINE) {
return undefined;
}
// hls.js 优先(Chrome/Edge/Firefox);桌面 Chromium 的 canPlayType 可能返回
// 'maybe' 但并不原生支持 HLS,因此必须先判定 hls.js,避免直接设置原生 src 失败。
if (HLS.isSupported()) {
const hlsPlayer = new HLS(constants.HLS_LOWLATENCY_OPTION);
hlsPlayer.loadSource(item.hlsUrl);
hlsPlayer.attachMedia(videoRef);
// muted + autoPlay 已在元素上声明,这里再兜底触发播放以应对自动播放策略
hlsPlayer.on(HLS.Events.MANIFEST_PARSED, () => {
void videoRef.play().catch(() => undefined);
});
return () => {
hlsPlayer.destroy();
};
}
if (videoRef.canPlayType('application/vnd.apple.mpegurl').length > 0) {
videoRef.src = item.hlsUrl;
void videoRef.play().catch(() => undefined);
return undefined;
}
videoRef.innerText = '您的浏览器不支持查看摄像头视频';
return undefined;
}, [item.hlsUrl, item.cameraStatus]);
return (
<video
key={item.cameraID}
className={className ? className + ' ' + Styles.player : Styles.player}
controls
muted
autoPlay={item.cameraStatus !== constants.cameraStatus.OFFLINE}
ref={videosRef}
/>
);
};
export default HlsPlayer;
+18
View File
@@ -0,0 +1,18 @@
export default {
userRole: { ADMIN: 'admin', USER: 'user' },
cameraStatus: { NORMAL: 'normal', OFFLINE: 'offline', ALARM: 'alarm' },
alarmRuleAlgorithmType: {
BODY: 'body',
VEHICLE: 'vehicle',
},
SHA256KEY: 'smart-parking-platform',
FETCH_ROOT: '',
HLS_LOWLATENCY_OPTION: {
enableWorker: true,
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 4,
liveDurationInfinity: true,
highBufferWatchdogPeriod: 1,
lowLatencyMode: true,
},
};
+568
View File
@@ -0,0 +1,568 @@
:root {
--panel-bg: rgba(8, 18, 38, 0.68);
--panel-bg-strong: rgba(8, 20, 44, 0.88);
--panel-border: rgba(103, 232, 249, 0.24);
--panel-border-strong: rgba(34, 211, 238, 0.72);
--panel-radius: 18px;
--panel-blur: 18px;
--panel-shadow: 0 18px 50px rgba(2, 8, 23, 0.48);
--glow-cyan: #22d3ee;
--glow-blue: #3b82f6;
--glow-violet: #8b5cf6;
--text-primary: #e6f7ff;
--text-secondary: rgba(226, 242, 255, 0.68);
--grid-line: rgba(56, 189, 248, 0.08);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(34, 211, 238, 0.2), transparent 30%),
radial-gradient(circle at 88% 2%, rgba(139, 92, 246, 0.2), transparent 32%),
linear-gradient(135deg, #020617, #071120 48%, #0f172a);
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
}
body::before {
z-index: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(to bottom, black, transparent 88%);
}
body::after {
z-index: 0;
background: radial-gradient(circle at 50% 78%, rgba(59, 130, 246, 0.14), transparent 36%);
animation: aurora 28s ease-in-out infinite alternate;
}
@keyframes aurora {
from { transform: translate3d(-1.5%, 0, 0) scale(1); }
to { transform: translate3d(1.5%, -2%, 1.04); }
}
.ant-layout,
.ant-layout-has-sider {
background: transparent !important;
}
.glass-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 20px;
background: rgba(4, 13, 30, 0.72) !important;
border-bottom: 1px solid rgba(103, 232, 249, 0.2);
box-shadow: 0 12px 34px rgba(2, 8, 23, 0.38);
backdrop-filter: blur(18px);
}
.brand-block {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-block img {
width: 40px;
height: 40px;
object-fit: contain;
filter: drop-shadow(0 0 12px rgba(34, 211, 238, 0.75));
}
.brand-title {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.04em;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 0 18px rgba(34, 211, 238, 0.38);
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border: 1px solid rgba(103, 232, 249, 0.22);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.user-chip:hover {
border-color: rgba(34, 211, 238, 0.7);
box-shadow: 0 0 22px rgba(34, 211, 238, 0.16);
}
.user-chip .ant-avatar {
border: 1px solid rgba(103, 232, 249, 0.45);
}
.user-name {
color: var(--text-primary);
font-size: 14px;
}
.glass-sider {
background: rgba(4, 13, 30, 0.58) !important;
border-right: 1px solid rgba(103, 232, 249, 0.16);
backdrop-filter: blur(16px);
}
.glass-sider .ant-menu {
padding: 10px 8px;
background: transparent !important;
border-inline-end: 0 !important;
}
.glass-sider .ant-menu-item,
.glass-sider .ant-menu-submenu-title {
height: 42px;
margin-inline: 0;
border-radius: 12px;
color: rgba(230, 247, 255, 0.76);
}
.glass-sider .ant-menu-item:hover,
.glass-sider .ant-menu-submenu-title:hover {
color: #a5f3fc !important;
background: rgba(34, 211, 238, 0.1) !important;
}
.glass-sider .ant-menu-item-selected {
color: #ecfeff !important;
background: linear-gradient(90deg, rgba(34, 211, 238, 0.28), rgba(139, 92, 246, 0.18)) !important;
box-shadow: inset 0 0 0 1px rgba(103, 232, 249, 0.35), 0 0 20px rgba(34, 211, 238, 0.14);
}
.glass-sider .ant-menu-submenu-selected > .ant-menu-submenu-title {
color: #a5f3fc;
}
.panel-content {
position: relative;
z-index: 1;
height: calc(100vh - 64px - 36px);
overflow: auto;
background: transparent;
}
.glass-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 36px;
padding: 0 16px;
color: var(--text-secondary);
background: rgba(4, 13, 30, 0.7);
border-top: 1px solid rgba(103, 232, 249, 0.18);
backdrop-filter: blur(16px);
}
.glass-footer a {
color: #67e8f9;
text-decoration: none;
}
.glass-footer a:hover {
color: #cffafe;
text-shadow: 0 0 12px rgba(34, 211, 238, 0.75);
}
.footer-group {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.ant-card {
color: var(--text-primary);
border: 1px solid var(--panel-border);
border-radius: var(--panel-radius);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.08), var(--panel-bg) 42%, rgba(255, 255, 255, 0.03));
box-shadow: var(--panel-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(var(--panel-blur));
overflow: hidden;
}
.ant-card-head {
min-height: 48px;
color: var(--text-primary);
background: rgba(34, 211, 238, 0.06);
border-bottom: 1px solid rgba(103, 232, 249, 0.16);
}
.ant-card-head-title,
.ant-card-extra {
color: var(--text-primary);
}
.ant-card-body {
position: relative;
z-index: 1;
}
.ant-table {
color: var(--text-primary);
background: transparent;
}
.ant-table-thead > tr > th {
color: #cffafe !important;
background: rgba(34, 211, 238, 0.1) !important;
border-bottom-color: rgba(103, 232, 249, 0.2) !important;
}
.ant-table-tbody > tr > td,
.ant-table-summary > tr > th,
.ant-table-summary > tr > td {
color: var(--text-primary) !important;
background: transparent !important;
border-bottom-color: rgba(103, 232, 249, 0.12) !important;
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(34, 211, 238, 0.08) !important;
}
.ant-pagination .ant-pagination-item,
.ant-pagination .ant-pagination-prev .ant-pagination-item-link,
.ant-pagination .ant-pagination-next .ant-pagination-item-link {
color: #a5f3fc;
border-color: rgba(103, 232, 249, 0.25);
background: rgba(255, 255, 255, 0.04);
}
.ant-pagination .ant-pagination-item-active {
border-color: var(--panel-border-strong);
box-shadow: 0 0 14px rgba(34, 211, 238, 0.16);
}
.ant-pagination .ant-pagination-item-ellipsis,
.ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon,
.ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon {
color: #a5f3fc !important;
text-shadow: none !important;
}
.ant-descriptions .ant-descriptions-item-label,
.ant-descriptions .ant-descriptions-item-content {
color: var(--text-primary);
background: transparent;
}
.ant-descriptions .ant-descriptions-view,
.ant-descriptions .ant-descriptions-row > th,
.ant-descriptions .ant-descriptions-row > td {
border-color: rgba(103, 232, 249, 0.14);
}
.ant-btn-primary {
border: 0;
background: linear-gradient(135deg, #0891b2, #2563eb 52%, #7c3aed);
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.28);
}
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background: linear-gradient(135deg, #06b6d4, #3b82f6 52%, #8b5cf6) !important;
box-shadow: 0 0 26px rgba(34, 211, 238, 0.26) !important;
}
.ant-btn-default {
color: #a5f3fc;
border-color: rgba(103, 232, 249, 0.28);
background: rgba(255, 255, 255, 0.04);
}
.ant-btn-default:hover {
color: #cffafe !important;
border-color: rgba(34, 211, 238, 0.7) !important;
}
.ant-tag {
border-color: rgba(103, 232, 249, 0.28);
background: rgba(34, 211, 238, 0.1);
}
.ant-modal-content,
.ant-drawer-content {
color: var(--text-primary);
border: 1px solid var(--panel-border);
background: var(--panel-bg-strong);
backdrop-filter: blur(20px);
}
.ant-modal-header,
.ant-drawer-header,
.ant-modal-footer {
color: var(--text-primary);
background: transparent;
}
.ant-modal-title,
.ant-drawer-title,
.ant-drawer-header-title,
.ant-drawer-close,
.ant-modal-close {
color: var(--text-primary);
}
.ant-select-dropdown {
color: var(--text-primary);
border: 1px solid var(--panel-border);
background: var(--panel-bg-strong);
backdrop-filter: blur(18px);
}
.ant-select-item {
color: var(--text-primary);
}
.ant-select-item-option-active,
.ant-select-item-option-selected {
background: rgba(34, 211, 238, 0.16) !important;
}
.ant-spin-dot-item {
background-color: #67e8f9;
}
:focus-visible {
outline: 2px solid rgba(103, 232, 249, 0.85);
outline-offset: 2px;
}
@media (max-width: 768px) {
.glass-header {
padding: 0 12px;
}
.brand-title {
display: none;
}
.footer-group {
gap: 8px;
}
.footer-hide-sm {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
/* Dark-theme fallback: prevent component library default white surfaces from showing inside dark panels. */
#root .ant-card,
#root .ant-table,
#root .ant-descriptions-view,
#root .ant-modal-content,
#root .ant-drawer-content,
#root .ant-select-dropdown,
#root .ant-picker-dropdown .ant-picker-panel-container,
#root .ant-popover-inner,
#root .ant-collapse,
#root .ant-list,
#root .ant-pagination .ant-pagination-item,
#root .ant-pagination .ant-pagination-item-link {
color: #e6f7ff;
}
#root .ant-table,
#root .ant-descriptions-view,
#root .ant-modal-content,
#root .ant-drawer-content,
#root .ant-select-dropdown,
#root .ant-picker-dropdown .ant-picker-panel-container,
#root .ant-popover-inner,
#root .ant-collapse {
background-color: rgba(7, 18, 39, 0.9);
}
#root .ant-table-cell {
background-color: transparent;
color: #e6f7ff;
}
#root .ant-picker,
#root .ant-input,
#root .ant-input-affix-wrapper,
#root .ant-select-selector,
#root .ant-input-number,
#root .ant-btn-default {
color: #e6f7ff;
}
#root .ant-descriptions-item-label,
#root .ant-descriptions-item-content,
#root .ant-form-item,
#root .ant-list-item,
#root .ant-select-selection-item,
#root .ant-select-selection-placeholder,
#root .ant-picker-input > input {
color: inherit;
}
#root .ant-empty-description,
#root .ant-typography,
#root .ant-modal .ant-modal-title,
#root .ant-drawer .ant-drawer-title {
color: #e6f7ff;
}
/* Dark panel contrast hardening: alerts, status tags and auxiliary controls */
.ant-alert {
color: var(--text-primary);
border-color: rgba(103, 232, 249, 0.24);
background: linear-gradient(135deg, rgba(15, 35, 66, 0.88), rgba(7, 18, 39, 0.9));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ant-alert-message,
.ant-alert-description {
color: var(--text-primary) !important;
}
.ant-alert-icon {
color: #67e8f9 !important;
}
.ant-alert.ant-alert-warning {
border-color: rgba(251, 191, 36, 0.32);
background: linear-gradient(135deg, rgba(67, 48, 12, 0.86), rgba(28, 24, 12, 0.9));
}
.ant-alert.ant-alert-warning .ant-alert-icon {
color: #fbbf24 !important;
}
.ant-tag-green,
.ant-tag-cyan,
.ant-tag-blue,
.ant-tag-gold,
.ant-tag-orange,
.ant-tag-red,
.ant-tag-purple,
.ant-tag-magenta {
color: #dffbff;
border-color: rgba(103, 232, 249, 0.28);
background: rgba(34, 211, 238, 0.14);
}
.ant-tag-green {
color: #bbf7d0;
border-color: rgba(74, 222, 128, 0.32);
background: rgba(34, 197, 94, 0.15);
}
.ant-tag-cyan {
color: #a5f3fc;
border-color: rgba(34, 211, 238, 0.36);
background: rgba(8, 145, 178, 0.18);
}
.ant-tag-blue {
color: #bfdbfe;
border-color: rgba(96, 165, 250, 0.34);
background: rgba(37, 99, 235, 0.18);
}
.ant-tag-gold,
.ant-tag-orange {
color: #fde68a;
border-color: rgba(251, 191, 36, 0.34);
background: rgba(217, 119, 6, 0.18);
}
.ant-tag-red {
color: #fecdd3;
border-color: rgba(251, 113, 133, 0.34);
background: rgba(225, 29, 72, 0.18);
}
.ant-tag-purple {
color: #ddd6fe;
border-color: rgba(167, 139, 250, 0.34);
background: rgba(124, 58, 237, 0.18);
}
.ant-select-arrow,
.ant-select-suffix .anticon,
.ant-input-password-icon,
.ant-input-suffix .anticon,
.ant-input-clear-icon {
color: #a5f3fc !important;
}
.ant-input-number-handler,
.ant-input-number-handler-up-inner,
.ant-input-number-handler-down-inner {
color: #a5f3fc !important;
background: rgba(14, 30, 56, 0.92);
}
.ant-form-item-extra,
.ant-form-item-explain,
.ant-form-item-explain-success,
.ant-form-item-explain-warning,
.ant-form-item-explain-error {
color: #b7d7e8 !important;
}
.ant-modal-close,
.ant-drawer-close {
color: #a5f3fc !important;
background: transparent !important;
}
.ant-modal-close:hover,
.ant-drawer-close:hover {
color: #ecfeff !important;
background: rgba(34, 211, 238, 0.12) !important;
}
.ant-alert-close-icon,
.ant-alert-close-icon .anticon,
.ant-alert-close-icon .anticon-close {
color: #a5f3fc !important;
}
.ant-alert-close-icon:hover,
.ant-alert-close-icon:hover .anticon,
.ant-alert-close-icon:hover .anticon-close {
color: #ecfeff !important;
}
+42
View File
@@ -0,0 +1,42 @@
import { type FC, useCallback } from 'react';
import { useIntl } from 'umi';
import type locales from '../locales/zh-CN';
/**
* 快速使用国际化formatMessage的Hook
*
* 使用方法:
* ```jsx
* export default ()=>{
* const f = useFmtMsg();
* return <div> { f('homepage.title') } </div>
* }
* ```
* @returns formatMessage函数语法糖
*/
export function useFmtMsg() {
const { formatMessage } = useIntl();
return useCallback(
(id: keyof typeof locales, value?: Record<string, any>) =>
formatMessage({ id }, value),
[],
);
}
export function withFmtMsgHook(ClassComponent: any) {
const WithFmtMsgHookWrapper: FC<any> = (props) => {
const f = useFmtMsg();
return <ClassComponent f={f} {...props} />;
};
return WithFmtMsgHookWrapper;
}
export function renderWithFmtMsg(
renderer: (fmtMsgFunc: ReturnType<typeof useFmtMsg>) => any,
) {
const FmtMsgWrapper: FC<any> = () => {
const f = useFmtMsg();
return renderer(f);
};
return <FmtMsgWrapper />;
}
+56
View File
@@ -0,0 +1,56 @@
body {
margin: 0;
height: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0;
}
.title {
display: flex;
align-content: center;
align-items: center;
flex-grow: 1;
color: white;
}
.title > img {
height: 50px;
}
.title > h1 {
margin: 0;
margin-left: 50px;
}
.userinfo {
display: flex;
align-content: center;
justify-content: center;
align-items: center;
}
.userinfo > * {
margin: 0;
margin-right: 10px;
}
.userinfo > *:last-child {
margin-right: 0;
}
.avatar {
margin-right: 20px;
}
.username {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
color: white;
}
.content {
height: calc(100vh - 64px);
width: 100%;
overflow-y: auto;
}
+165
View File
@@ -0,0 +1,165 @@
import { type FC, useEffect, useMemo, useState } from 'react';
import {
Layout as AntdLayout,
Menu,
Dropdown,
Avatar,
Space,
type MenuProps,
} from 'antd';
import { observer } from 'mobx-react';
import { history, Outlet, useLocation } from 'umi';
import { MessageOutlined, ThunderboltOutlined } from '@ant-design/icons';
import mobxStore from '@/mobxStore';
import constants from '@/constants';
import { useFmtMsg } from '@/hooks/useFmtMsg';
import AccessAuth from '@/components/accessAuth';
import logoSvg from '@/assets/logo.svg';
const Layout: FC = () => {
const f = useFmtMsg();
const { pathname } = useLocation();
const siderMenuItems = useMemo(() => {
const items: MenuProps['items'] = [
{
label: (
<Space>
<ThunderboltOutlined />
概览
</Space>
),
key: '/overview',
},
{ label: '园区态势', key: '/campusState' },
{ label: '监控大屏', key: '/monitScreen' },
{ label: '异常报警', key: '/alarms' },
{
label: (
<Space>
<MessageOutlined />
AI对话
</Space>
),
key: '/ai-chat',
},
{ label: '个人信息维护', key: '/userInfo' },
mobxStore.user.role === constants.userRole.ADMIN
? {
label: '系统管理',
key: '/admin',
children: [
{ label: '园区地图管理', key: '/admin/mapManage' },
{ label: '摄像头管理', key: '/admin/camerasManage' },
{ label: '报警规则配置', key: '/admin/alarmRulesManage' },
{ label: '系统用户管理', key: '/admin/usersManage' },
],
}
: null,
];
return items;
}, [mobxStore.user.role]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([pathname]);
const [now, setNow] = useState(() => new Date());
const handleSelect: MenuProps['onSelect'] = (info) => {
setSelectedKeys([info.key]);
history.push(info.key);
};
useEffect(() => {
document.title = `${f(pathname as any)} - ${f('title')}`;
}, [pathname]);
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
const handleLogoff = () => {
mobxStore.user.logoff();
history.replace('/login');
};
return (
<AccessAuth>
<AntdLayout style={{ position: 'fixed', height: '100%', width: '100%' }}>
<AntdLayout.Header className="glass-header">
<div className="brand-block">
<img src={logoSvg} alt="智慧停车管理平台 Logo" />
<h1 className="brand-title">{f('title')}</h1>
</div>
<Dropdown
menu={{
items: [{ label: '注销登录', key: 'logout', onClick: handleLogoff }],
}}
>
<div className="user-chip" aria-label="用户菜单">
<Avatar
shape="circle"
size="default"
icon={<img src={mobxStore.user.avatarURL} alt="用户头像" />}
/>
<span className="user-name">{mobxStore.user.nickname}</span>
</div>
</Dropdown>
</AntdLayout.Header>
<AntdLayout>
<AntdLayout.Sider
className="glass-sider"
breakpoint="lg"
collapsedWidth="48px"
width={220}
>
<Menu
mode="inline"
items={siderMenuItems}
onSelect={handleSelect}
selectedKeys={selectedKeys}
defaultOpenKeys={['/admin']}
/>
</AntdLayout.Sider>
<AntdLayout>
<AntdLayout.Content className="panel-content">
<Outlet />
</AntdLayout.Content>
<AntdLayout.Footer className="glass-footer">
<div className="footer-group">
<span>v1.0.0</span>
<span className="footer-hide-sm">
系统运行状态:
<span style={{ color: '#4ade80' }}>正常</span>
</span>
</div>
<div className="footer-group">
<a
href="https://www.gitcc.com/"
target="_blank"
rel="noreferrer"
>
开源社区
</a>
<a
href="https://www.gitvv.com/"
target="_blank"
rel="noreferrer"
>
GCC AI Coding
</a>
<span className="footer-hide-sm">
{now.toLocaleString('zh-CN', { hour12: false })}
</span>
</div>
</AntdLayout.Footer>
</AntdLayout>
</AntdLayout>
</AntdLayout>
</AccessAuth>
);
};
export default observer(Layout);
+6
View File
@@ -0,0 +1,6 @@
export default {
title: 'Smart Parking Management Platform',
helloWorld: 'Hello World',
switchLang: 'Switch language',
goLogin: 'Go to login',
};
+13
View File
@@ -0,0 +1,13 @@
export default {
title: '智慧停车管理平台',
'/campusState': '园区态势',
'/login': '登录',
'/monitScreen': '监控大屏',
'/alarms': '异常报警',
'/ai-chat': 'AI对话',
'/userInfo': '个人信息维护',
'/admin/mapManage': '园区地图管理',
'/admin/camerasManage': '摄像头管理',
'/admin/alarmRulesManage': '报警规则管理',
'/admin/usersManage': '系统用户管理',
};
+10
View File
@@ -0,0 +1,10 @@
import { configure } from 'mobx';
import MapConfig from './mapConfig';
import User from './user';
configure({ enforceActions: 'observed' });
const user = new User();
const mapConfig = new MapConfig();
export default { user, mapConfig };
+50
View File
@@ -0,0 +1,50 @@
import services from '@/services';
import { CRS, ImageOverlay, type MapOptions, TileLayer } from 'leaflet';
import { action, makeAutoObservable } from 'mobx';
class MapConfig {
private _config: MapOptions | null = null;
constructor() {
makeAutoObservable(this);
}
@action setConfig(config: MapOptions) {
if (this._config != null) {
Object.assign(this._config, config);
} else {
this._config = config;
}
}
// lazy fetch map config
get config(): MapOptions {
if (this._config != null) {
return this._config;
} else {
this.fetchAndSetMapConfig().catch(console.error);
return {
center: [0, 0],
zoom: 0,
attributionControl: false,
zoomControl: false,
};
}
}
async fetchAndSetMapConfig() {
const data = (await services['GET /api/user/getMapConfig']()).data;
this.setConfig({
...data.mapOptions,
crs: data.layer.type === 'tileLayer' ? CRS.EPSG3857 : CRS.Simple,
layers: [
data.layer.type === 'tileLayer'
? new TileLayer(data.layer.url)
: new ImageOverlay(data.layer.url, data.layer.bounds),
],
});
return data;
}
}
export default MapConfig;
+39
View File
@@ -0,0 +1,39 @@
import { action, makeAutoObservable } from 'mobx';
export default class User {
token = localStorage.getItem('user.token') ?? '';
role = localStorage.getItem('user.role') ?? '';
avatarURL = localStorage.getItem('user.avatarURL') ?? '';
nickname = localStorage.getItem('user.nickname') ?? '';
constructor() {
makeAutoObservable(this);
}
@action setToken = (token: string) => {
this.token = token;
localStorage.setItem('user.token', token);
};
@action setUserInfo = (userinfo: {
avatarURL: string;
nickname: string;
role: string;
}) => {
this.avatarURL = userinfo.avatarURL;
this.nickname = userinfo.nickname;
this.role = userinfo.role;
localStorage.setItem('user.avatarURL', userinfo.avatarURL);
localStorage.setItem('user.nickname', userinfo.nickname);
localStorage.setItem('user.role', userinfo.role);
};
@action logoff = () => {
this.setToken('');
this.setUserInfo({ avatarURL: '', nickname: '', role: '' });
};
getToken() {
return this.token;
}
}
@@ -0,0 +1,3 @@
.content {
padding: 8px;
}
@@ -0,0 +1,436 @@
import { type FC, useEffect, useState } from 'react';
import Styles from './index.module.less';
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Spin,
Switch,
Table,
Tag,
TimePicker,
message,
} from 'antd';
import { observer } from 'mobx-react';
import services from '@/services';
import { type ColumnType } from 'antd/es/table';
import { useForm } from 'antd/es/form/Form';
import dayjs, { type Dayjs } from 'dayjs';
import type ServiceTypes from '@/services/serviceTypes';
import constants from '@/constants';
interface EditFormData {
alarmRuleID: number;
alarmRuleName: string;
relatedCameraIds: number[];
enabled: boolean;
algorithmType: 'body' | 'vehicle';
triggerCondition: {
time: {
dayOfWeek: number[];
timeRange: [Dayjs, Dayjs];
};
count: {
min: number;
max: number;
};
};
}
type AddFormData = Omit<EditFormData, 'alarmRuleID'>;
type AlarmRuleFull =
ServiceTypes['GET /api/admin/getAlarmRuleList']['res']['data'][number];
const AlarmRulesManage: FC = () => {
const [alarmRuleList, setAlarmRuleList] = useState<AlarmRuleFull[]>([]);
const [fetchLoading, setFetchLoading] = useState<boolean>(false);
const [editForm] = useForm<EditFormData>();
const [addForm] = useForm<AddFormData>();
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
const [addModalVisible, setAddModalVisible] = useState<boolean>(false);
const [cameraList, setCameraList] = useState<AlarmRuleFull['relatedCameras']>(
[],
);
const fetchAlarmRuleList = async (): Promise<void> => {
try {
setFetchLoading(true);
setAlarmRuleList(
(await services['GET /api/admin/getAlarmRuleList']()).data,
);
} catch (error) {
console.error(error);
void message.error(String(error));
} finally {
setFetchLoading(false);
}
};
const fetchCameraList = async (): Promise<void> => {
try {
setCameraList((await services['GET /api/admin/getCameraList']()).data);
} catch (error) {
console.error(error);
void message.error(String(error));
}
};
useEffect(() => {
void fetchAlarmRuleList();
void fetchCameraList();
}, []);
const handleAdd = () => {
addForm.resetFields();
addForm.setFieldValue('enabled', true);
setAddModalVisible(true);
};
const handleEdit = async (data: AlarmRuleFull) => {
editForm.resetFields();
editForm.setFieldsValue({
...data,
relatedCameraIds: data.relatedCameras?.map((v) => v.cameraID) ?? [],
triggerCondition: {
...data.triggerCondition,
time: {
...data.triggerCondition.time,
timeRange: [
dayjs(data.triggerCondition.time.timeRange[0], 'HH:mm:ss'),
dayjs(data.triggerCondition.time.timeRange[1], 'HH:mm:ss'),
],
},
},
});
setEditModalVisible(true);
};
const handleDelete = async (alarmRuleID: number) => {
try {
await services['POST /api/admin/deleteAlarmRule']({
alarmRuleID,
});
void message.success('删除成功');
void fetchAlarmRuleList();
} catch (error) {
console.error(error);
void message.error(String(error));
}
};
const handleAddSubmit = async () => {
const formData = await addForm.validateFields();
try {
await services['POST /api/admin/addAlarmRule']({
alarmRuleName: formData.alarmRuleName,
algorithmType: formData.algorithmType,
enabled: formData.enabled,
relatedCameraIds: formData.relatedCameraIds,
triggerCondition: {
count: {
max: formData.triggerCondition.count.max,
min: formData.triggerCondition.count.min,
},
time: {
dayOfWeek: formData.triggerCondition.time.dayOfWeek,
timeRange: [
formData.triggerCondition.time.timeRange[0].format('HH:mm:ss'),
formData.triggerCondition.time.timeRange[1].format('HH:mm:ss'),
],
},
},
});
void message.success('添加成功');
setAddModalVisible(false);
void fetchAlarmRuleList();
} catch (error) {
console.error(error);
void message.error(String(error));
}
};
const handleEditSubmit = async () => {
const formData = await editForm.validateFields();
try {
await services['POST /api/admin/updateAlarmRule']({
alarmRuleID: formData.alarmRuleID,
alarmRuleName: formData.alarmRuleName,
algorithmType: formData.algorithmType,
enabled: formData.enabled,
relatedCameraIds: formData.relatedCameraIds,
triggerCondition: {
count: {
max: formData.triggerCondition.count.max,
min: formData.triggerCondition.count.min,
},
time: {
dayOfWeek: formData.triggerCondition.time.dayOfWeek,
timeRange: [
formData.triggerCondition.time.timeRange[0].format('HH:mm:ss'),
formData.triggerCondition.time.timeRange[1].format('HH:mm:ss'),
],
},
},
});
void message.success('修改成功');
setEditModalVisible(false);
void fetchAlarmRuleList();
} catch (error) {
console.error(error);
void message.error(String(error));
}
};
const columns: Array<ColumnType<AlarmRuleFull>> = [
{
title: '规则ID',
dataIndex: 'alarmRuleID',
},
{
title: '规则名称',
dataIndex: 'alarmRuleName',
},
{
title: '关联摄像头',
dataIndex: 'relatedCameraIDs',
render: (_v, record) =>
record.relatedCameras?.map((v) => (
<Tag key={v.cameraID}>{v.cameraName}</Tag>
)),
},
{
title: '状态',
dataIndex: 'status',
render: (_v, record) =>
record.enabled ? (
<Tag color="green">已启用</Tag>
) : (
<Tag color="grey">已禁用</Tag>
),
},
{
title: '监控算法',
dataIndex: 'algorithmType',
render: (_v, record) =>
record.algorithmType === constants.alarmRuleAlgorithmType.BODY
? '人体检测'
: '车辆检测',
},
{
title: '操作',
render: (_v, record) => (
<Button.Group>
<Button
type="link"
onClick={() => {
void handleEdit(record);
}}
>
配置
</Button>
<Popconfirm
title="确认删除该条规则?"
onConfirm={() => {
void handleDelete(record.alarmRuleID);
}}
>
<Button type="link">删除</Button>
</Popconfirm>
</Button.Group>
),
},
];
const commonFormItems = (
<>
<Form.Item
label="规则名称"
name="alarmRuleName"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="关联摄像头"
name="relatedCameraIds"
rules={[{ required: true }]}
>
<Select<number[], AlarmRuleFull['relatedCameras'][number]>
mode="multiple"
allowClear
options={cameraList}
fieldNames={{ label: 'cameraName', value: 'cameraID' }}
/>
</Form.Item>
<Form.Item
label="启用"
name="enabled"
valuePropName="checked"
rules={[{ required: true }]}
>
<Switch />
</Form.Item>
<Form.Item
label="监控算法"
name="algorithmType"
rules={[{ required: true }]}
>
<Select
options={[
{ label: '人体检测', value: constants.alarmRuleAlgorithmType.BODY },
{
label: '车辆检测',
value: constants.alarmRuleAlgorithmType.VEHICLE,
},
]}
/>
</Form.Item>
<Form.Item
name={['triggerCondition', 'time', 'dayOfWeek']}
label="触发周期"
rules={[{ required: true }]}
>
<Select<number[]>
mode="multiple"
allowClear
options={[
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 },
]}
/>
</Form.Item>
<Form.Item
name={['triggerCondition', 'time', 'timeRange']}
label="触发时间"
rules={[{ required: true }]}
>
<TimePicker.RangePicker />
</Form.Item>
<Form.Item
label="最小触发数量"
name={['triggerCondition', 'count', 'min']}
rules={[{ required: true }]}
>
<InputNumber min={-1} />
</Form.Item>
<Form.Item
label="最大触发数量"
name={['triggerCondition', 'count', 'max']}
help="设置为-1表示不限制"
rules={[{ required: true }]}
>
<InputNumber min={-1} />
</Form.Item>
</>
);
return (
<Spin spinning={fetchLoading}>
<div className={Styles.content}>
<Card
title="报警规则管理"
extra={
<Button type="primary" onClick={handleAdd}>
新增规则
</Button>
}
>
<Table<AlarmRuleFull>
columns={columns}
dataSource={alarmRuleList}
rowKey="alarmRuleID"
/>
</Card>
<Modal
open={addModalVisible}
title="新增规则"
onCancel={() => {
setAddModalVisible(false);
}}
footer={
<Button.Group>
<Button type="primary" onClick={handleAddSubmit}>
提交
</Button>
<Button
onClick={() => {
setAddModalVisible(false);
}}
>
返回
</Button>
</Button.Group>
}
>
<Form
form={addForm}
requiredMark={false}
labelCol={{ span: 5 }}
labelAlign="right"
>
{commonFormItems}
</Form>
</Modal>
<Modal
open={editModalVisible}
title="配置规则"
onCancel={() => {
setEditModalVisible(false);
}}
footer={
<Button.Group>
<Button type="primary" onClick={handleEditSubmit}>
保存
</Button>
<Button
onClick={() => {
setEditModalVisible(false);
}}
>
返回
</Button>
</Button.Group>
}
>
<Form
form={editForm}
requiredMark={false}
labelCol={{ span: 5 }}
labelAlign="right"
>
<Form.Item
label="规则ID"
name="alarmRuleID"
rules={[{ required: true }]}
>
<Input disabled />
</Form.Item>
{commonFormItems}
</Form>
</Modal>
</div>
</Spin>
);
};
export default observer(AlarmRulesManage);
@@ -0,0 +1,10 @@
.content {
padding: 8px;
}
.campusMap {
width: 100%;
height: 400px;
}
.alert {
margin: 8px;
}
@@ -0,0 +1,435 @@
import CameraStatusBadge from '@/components/cameraStatusBadge';
import CampusMap from '@/components/campusMap';
import mobxStore from '@/mobxStore';
import services from '@/services';
import type ServiceTypes from '@/services/serviceTypes';
import {
Alert,
Button,
Card,
Form,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Select,
Space,
Spin,
Table,
Tag,
} from 'antd';
import { useForm } from 'antd/es/form/Form';
import { type ColumnsType } from 'antd/es/table';
import { type LeafletMouseEvent, type Map } from 'leaflet';
import { observer } from 'mobx-react';
import { type FC, useEffect, useRef, useState } from 'react';
import Styles from './index.module.less';
type CameraList = ServiceTypes['GET /api/admin/getCameraList']['res']['data'];
interface AddFormData {
cameraName: string;
Lattitude: number;
Longitude: number;
cameraModel: string;
alarmRuleIDs: number[];
rtspUrl: string;
}
interface EditFormData {
cameraID: number;
cameraName: string;
Lattitude: number;
Longitude: number;
cameraModel: string;
alarmRuleIDs: number[];
rtspUrl: string;
}
const CamerasManage: FC = () => {
const [cameraList, setCameraList] = useState<CameraList>([]);
const [alarmRuleList, setAlarmRuleList] = useState<
ServiceTypes['GET /api/admin/getAlarmRuleList']['res']['data']
>([]);
const [loading, setLoading] = useState(false);
const [selectedCameraIds, setSelectedCameraIds] = useState<number[]>([]);
const [editModalOpen, setEditModalOpen] = useState(false);
const [addModalOpen, setAddModalOpen] = useState(false);
const [addCameraForm] = useForm<AddFormData>();
const [editCameraForm] = useForm<EditFormData>();
const fetchCameraList = async () => {
try {
setLoading(true);
setCameraList((await services['GET /api/admin/getCameraList']()).data);
} catch (error) {
console.error(error);
message.error(String(error));
} finally {
setLoading(false);
}
};
const fetchAlarmRuleList = async () => {
try {
setAlarmRuleList(
(await services['GET /api/admin/getAlarmRuleList']()).data,
);
} catch (error) {
console.error(error);
message.error(String(error));
}
};
useEffect(() => {
fetchCameraList();
fetchAlarmRuleList();
}, []);
const handleConfigCamera = (cameraInfo: CameraList[number]) => {
editCameraForm.resetFields();
editCameraForm.setFieldsValue({
...cameraInfo,
Lattitude: cameraInfo.latlng[0],
Longitude: cameraInfo.latlng[1],
alarmRuleIDs: cameraInfo.alarmRules.map((item) => item.alarmRuleID),
});
setEditModalOpen(true);
};
const handleLocateCamera = (cameraID: number) => {
setSelectedCameraIds([cameraID]);
};
const handleDeleteCamera = async (cameraID: number) => {
try {
await services['POST /api/admin/deleteCamera']({ cameraID });
message.success('删除成功');
fetchCameraList();
} catch (error) {
console.error(error);
message.error(String(error));
}
};
const handleMapCameraClick = (cameraID: number) => {
if (selectedCameraIds[0] === cameraID) {
const cameraInfo = cameraList.find((item) => item.cameraID === cameraID);
cameraInfo != null && handleConfigCamera(cameraInfo);
return;
}
setSelectedCameraIds([cameraID]);
};
const handleConfigSubmit = async () => {
const formData = await editCameraForm.validateFields();
try {
await services['POST /api/admin/updateCamera']({
cameraID: formData.cameraID,
cameraName: formData.cameraName,
latlng: [formData.Lattitude, formData.Longitude],
cameraModel: formData.cameraModel,
alarmRuleIDs: formData.alarmRuleIDs,
rtspUrl: formData.rtspUrl,
});
message.success('配置成功');
setEditModalOpen(false);
fetchCameraList();
} catch (error) {
console.error(error);
message.error(String(error));
}
};
const handleAddSubmit = async () => {
const formData = await addCameraForm.validateFields();
try {
await services['POST /api/admin/addCamera']({
cameraName: formData.cameraName,
latlng: [formData.Lattitude, formData.Longitude],
cameraModel: formData.cameraModel,
alarmRuleIDs: formData.alarmRuleIDs,
rtspUrl: formData.rtspUrl,
});
message.success('添加成功');
setAddModalOpen(false);
fetchCameraList();
} catch (error) {
console.error(error);
message.error(String(error));
}
};
const handleMapClick = (e: LeafletMouseEvent) => {
addCameraForm.resetFields();
addCameraForm.setFieldsValue({
Lattitude: e.latlng.lat,
Longitude: e.latlng.lng,
});
setAddModalOpen(true);
};
const handleMapClickRef = useRef(handleMapClick);
useEffect(() => {
handleMapClickRef.current = handleMapClick;
}, [handleMapClick]);
const handleMapInit = async (map: Map) => {
console.log('map init', map);
map.on('click', handleMapClickRef.current);
};
const columns: ColumnsType<CameraList[number]> = [
{
title: '摄像头ID',
dataIndex: 'cameraID',
},
{
title: '摄像头名称',
dataIndex: 'cameraName',
},
{
title: '位置',
dataIndex: 'latlng',
render: (_v, { latlng }) => (
<div>
{latlng[0]}
<br />
{latlng[1]}
</div>
),
},
{
title: '状态',
dataIndex: 'cameraStatus',
render: (_v, { cameraStatus }) => (
<CameraStatusBadge
status={cameraStatus as 'normal' | 'offline' | 'alarm'}
/>
),
},
{
title: '型号',
dataIndex: 'cameraModel',
},
{
title: '报警规则',
dataIndex: 'alarmRules',
render: (_v, { alarmRules }) => (
<Space size="small" wrap>
{alarmRules?.map((item) => (
<Tag key={item.alarmRuleID}>{item.alarmRuleName}</Tag>
))}
</Space>
),
},
{
title: '操作',
render: (_v, record) => (
<Button.Group>
<Button
type="link"
onClick={() => {
handleLocateCamera(record.cameraID);
}}
>
定位
</Button>
<Button
type="link"
onClick={() => {
handleConfigCamera(record);
}}
>
配置
</Button>
<Popconfirm
title="确认删除该摄像头?"
onConfirm={async () => {
await handleDeleteCamera(record.cameraID);
}}
>
<Button type="link">删除</Button>
</Popconfirm>
</Button.Group>
),
},
];
const commonFormItems = (
<>
<Form.Item
label="摄像头名称"
name="cameraName"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="位置坐标" required>
<Space wrap>
<Form.Item
label="坐标经度"
name="Longitude"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="经度" />
</Form.Item>
<Form.Item
label="坐标纬度"
name="Lattitude"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="纬度" />
</Form.Item>
</Space>
</Form.Item>
<Form.Item label="型号" name="cameraModel" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
label="RTSP拉流地址"
name="rtspUrl"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="报警规则" name="alarmRuleIDs">
<Select<
EditFormData['alarmRuleIDs'][number],
(typeof alarmRuleList)[number]
>
mode="multiple"
allowClear
options={alarmRuleList}
fieldNames={{ label: 'alarmRuleName', value: 'alarmRuleID' }}
/>
</Form.Item>
</>
);
return (
<div className={Styles.content}>
<Spin spinning={loading}>
<Card title="摄像头管理">
<CampusMap
className={Styles.campusMap}
mapConfig={mobxStore.mapConfig.config}
cameraList={cameraList}
selectedCameraIds={selectedCameraIds}
onCameraClick={handleMapCameraClick}
onInit={handleMapInit}
/>
<Alert
className={Styles.alert}
message="提示:单击地图可在对应位置添加摄像头"
type="info"
showIcon
closable
/>
<Table<CameraList[number]>
columns={columns}
dataSource={cameraList}
rowKey="cameraID"
/>
</Card>
</Spin>
<Modal
title="新增摄像头"
open={addModalOpen}
onCancel={() => {
setAddModalOpen(false);
}}
centered
footer={
<>
<Button type="primary" onClick={handleAddSubmit}>
提交
</Button>
<Button
onClick={() => {
setAddModalOpen(false);
}}
>
返回
</Button>
</>
}
>
<Form
form={addCameraForm}
labelCol={{ span: 6 }}
labelAlign="right"
requiredMark="optional"
>
{commonFormItems}
</Form>
</Modal>
<Modal
title="配置摄像头"
open={editModalOpen}
onCancel={() => {
setEditModalOpen(false);
}}
centered
footer={
<>
<Button type="primary" onClick={handleConfigSubmit}>
提交
</Button>
<Popconfirm
title="确认删除摄像头?"
onConfirm={async () => {
await handleDeleteCamera(
editCameraForm.getFieldValue('cameraID'),
);
setEditModalOpen(false);
}}
>
<Button type="default" danger>
删除
</Button>
</Popconfirm>
<Button
onClick={() => {
setEditModalOpen(false);
}}
>
返回
</Button>
</>
}
>
<Form
form={editCameraForm}
labelCol={{ span: 6 }}
labelAlign="right"
requiredMark="optional"
>
<Form.Item
label="摄像头ID"
name="cameraID"
rules={[{ required: true }]}
>
<Input disabled />
</Form.Item>
{commonFormItems}
</Form>
</Modal>
</div>
);
};
export default observer(CamerasManage);
@@ -0,0 +1,8 @@
.content {
padding: 8px;
}
.campusMap {
width: 100%;
height: 400px;
}
@@ -0,0 +1,456 @@
import CampusMap from '@/components/campusMap';
import mobxStore from '@/mobxStore';
import services from '@/services';
import { UploadOutlined } from '@ant-design/icons';
import {
Button,
Card,
Form,
Input,
InputNumber,
message,
Radio,
Space,
Spin,
Upload,
type UploadFile,
} from 'antd';
import { type FormProps, useForm, useWatch } from 'antd/es/form/Form';
import {
CRS,
ImageOverlay,
type MapOptions,
TileLayer,
type LeafletMouseEvent,
} from 'leaflet';
import { observer } from 'mobx-react';
import { type FC, useEffect, useState, useRef } from 'react';
import Styles from './index.module.less';
const getBase64 = async (file: File): Promise<string> =>
await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = (error) => {
reject(error);
};
});
type IFormData =
| {
sourceType: 'osm';
sourceURL: string;
centerLat: number;
centerLnt: number;
zoomLevel: number;
minZoomLevel: number;
maxZoomLevel: number;
}
| {
sourceType: 'custom';
sourceFile: [UploadFile];
borderLeftBottomY: number;
borderLeftBottomX: number;
borderRightTopY: number;
borderRightTopX: number;
centerY: number;
centerX: number;
zoomLevel: number;
minZoomLevel: number;
maxZoomLevel: number;
};
const MapManage: FC = () => {
const [fetchLoading, setFetchLoading] = useState(false);
const [mapConfig, setMapConfig] = useState<MapOptions>({
center: [0, 0],
zoom: 0,
attributionControl: false,
zoomControl: false,
});
const [form] = useForm<IFormData>();
const sourceType = useWatch('sourceType', form);
const [submitLoading, setSubmitLoading] = useState(false);
const previewMap = async (formData: IFormData) => {
if (formData.sourceType === 'osm') {
setMapConfig({
center: [formData.centerLat, formData.centerLnt],
zoom: formData.zoomLevel,
minZoom: formData.minZoomLevel,
maxZoom: formData.maxZoomLevel,
zoomControl: false,
attributionControl: false,
layers: [new TileLayer(formData.sourceURL)],
});
} else if (formData.sourceType === 'custom') {
let sourceURL: string;
if (formData.sourceFile?.[0]?.originFileObj != null) {
sourceURL = await getBase64(formData.sourceFile[0].originFileObj);
} else {
sourceURL = formData.sourceFile?.[0]?.url ?? '';
}
setMapConfig({
center: [formData.centerY, formData.centerX],
zoom: formData.zoomLevel,
minZoom: formData.minZoomLevel,
maxZoom: formData.maxZoomLevel,
zoomControl: false,
attributionControl: false,
crs: CRS.Simple,
layers: [
new ImageOverlay(sourceURL, [
[formData.borderLeftBottomY, formData.borderLeftBottomX],
[formData.borderRightTopY, formData.borderRightTopX],
]),
],
});
}
};
const fetchAndInitData = async () => {
try {
setFetchLoading(true);
const data = await mobxStore.mapConfig.fetchAndSetMapConfig();
if (data.layer.type === 'imageOverlay') {
const formData: IFormData = {
sourceType: 'custom',
sourceFile: [
{
uid: '1',
name: 'map.png',
status: 'done',
url: data.layer.url,
},
],
borderLeftBottomY: data.layer.bounds[0][0],
borderLeftBottomX: data.layer.bounds[0][1],
borderRightTopY: data.layer.bounds[1][0],
borderRightTopX: data.layer.bounds[1][1],
centerY: data.mapOptions.center[0],
centerX: data.mapOptions.center[1],
zoomLevel: data.mapOptions.zoom,
minZoomLevel: data.mapOptions.minZoom,
maxZoomLevel: data.mapOptions.maxZoom,
};
form.setFieldsValue(formData);
previewMap(formData);
} else if (data.layer.type === 'tileLayer') {
const formData: IFormData = {
sourceType: 'osm',
sourceURL: data.layer.url,
centerLat: data.mapOptions.center[0],
centerLnt: data.mapOptions.center[1],
zoomLevel: data.mapOptions.zoom,
minZoomLevel: data.mapOptions.minZoom,
maxZoomLevel: data.mapOptions.maxZoom,
};
form.setFieldsValue(formData);
previewMap(formData);
}
} catch (error) {
console.error(error);
message.error(String(error));
} finally {
setFetchLoading(false);
}
};
useEffect(() => {
fetchAndInitData();
}, []);
const handleSubmit = async (formData: IFormData) => {
try {
setSubmitLoading(true);
if (formData.sourceType === 'osm') {
await services['POST /api/admin/updateMapConfig']({
layer: {
type: 'tileLayer',
url: formData.sourceURL,
},
mapOptions: {
center: [formData.centerLat, formData.centerLnt],
zoom: formData.zoomLevel,
minZoom: formData.minZoomLevel,
maxZoom: formData.maxZoomLevel,
zoomControl: false,
attributionControl: false,
},
});
} else {
let sourceURL: string;
if (formData.sourceFile?.[0]?.originFileObj != null) {
sourceURL = await getBase64(formData.sourceFile[0].originFileObj);
} else {
sourceURL = formData.sourceFile?.[0]?.url ?? '';
}
await services['POST /api/admin/updateMapConfig']({
layer: {
type: 'imageOverlay',
url: sourceURL,
bounds: [
[formData.borderLeftBottomY, formData.borderLeftBottomX],
[formData.borderRightTopY, formData.borderRightTopX],
],
},
mapOptions: {
center: [formData.centerY, formData.centerX],
zoom: formData.zoomLevel,
minZoom: formData.minZoomLevel,
maxZoom: formData.maxZoomLevel,
zoomControl: false,
attributionControl: false,
},
});
}
message.success('保存成功');
fetchAndInitData();
} catch (error) {
console.error(error);
message.error(String(error));
} finally {
setSubmitLoading(false);
}
};
const handlePreview = async () => {
const formData = await form.validateFields();
await previewMap(formData);
};
const handleReset: FormProps['onReset'] = (e) => {
e?.preventDefault();
fetchAndInitData();
};
const handleMapClick = (e: LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
if (sourceType === 'osm') {
form.setFieldsValue({
centerLat: lat,
centerLnt: lng,
});
} else if (sourceType === 'custom') {
form.setFieldsValue({
centerY: lat,
centerX: lng,
});
}
};
const handleMapClickRef = useRef(handleMapClick);
handleMapClickRef.current = handleMapClick;
return (
<Spin spinning={fetchLoading}>
<div className={Styles.content}>
<Card title="地图管理">
<Form
form={form}
onFinish={handleSubmit}
onReset={handleReset}
requiredMark={false}
labelCol={{ span: 2 }}
labelAlign="right"
labelWrap
wrapperCol={{ span: 12 }}
>
<Form.Item
label="地图源"
name="sourceType"
rules={[{ required: true }]}
>
<Radio.Group>
<Radio value="osm">OpenStreetMap</Radio>
<Radio value="custom">自定义</Radio>
</Radio.Group>
</Form.Item>
{sourceType === 'osm' && (
<>
<Form.Item
label="地图源地址"
name="sourceURL"
rules={[{ required: true }]}
>
<Input type="url" />
</Form.Item>
<Form.Item label="中心坐标" required>
<Space>
<Form.Item
label="中心坐标纬度"
name="centerLat"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="纬度" />
</Form.Item>
<Form.Item
label="中心坐标经度"
name="centerLnt"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="经度" />
</Form.Item>
</Space>
</Form.Item>
</>
)}
{sourceType === 'custom' && (
<>
<Form.Item
label="自定义地图源文件"
name="sourceFile"
valuePropName="fileList"
getValueFromEvent={(e) => e?.fileList}
rules={[{ required: true }]}
// labelCol={{ span: 3.5 }}
>
<Upload
beforeUpload={() => false}
maxCount={1}
accept="image/*"
>
<Button icon={<UploadOutlined />}>上传</Button>
</Upload>
</Form.Item>
<Form.Item label="边界坐标" required>
<Space direction="vertical">
<Space>
<Form.Item
label="边界左下角坐标X轴"
name="borderLeftBottomX"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="左下角X轴" />
</Form.Item>
<Form.Item
label="边界左下角坐标Y轴"
name="borderLeftBottomY"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="左下角Y轴" />
</Form.Item>
</Space>
<Space>
<Form.Item
label="边界右上角坐标X轴"
name="borderRightTopX"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="右上角X轴" />
</Form.Item>
<Form.Item
label="边界右上角坐标Y轴"
name="borderRightTopY"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="右上角Y轴" />
</Form.Item>
</Space>
</Space>
</Form.Item>
<Form.Item label="中心坐标" required>
<Space>
<Form.Item
label="中心坐标X轴"
name="centerX"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="X轴" />
</Form.Item>
<Form.Item
label="中心坐标Y轴"
name="centerY"
noStyle
rules={[{ required: true }]}
>
<InputNumber addonBefore="Y轴" />
</Form.Item>
</Space>
</Form.Item>
</>
)}
<Form.Item
label="默认缩放等级"
name="zoomLevel"
rules={[{ required: true }]}
>
<InputNumber />
</Form.Item>
<Form.Item
label="最小缩放等级"
name="minZoomLevel"
rules={[{ required: true }]}
>
<InputNumber />
</Form.Item>
<Form.Item
label="最大缩放等级"
name="maxZoomLevel"
rules={[{ required: true }]}
>
<InputNumber />
</Form.Item>
<Form.Item wrapperCol={{ offset: 2 }}>
<Space>
<Button
type="primary"
htmlType="submit"
loading={submitLoading}
>
保存
</Button>
<Button
type="default"
htmlType="button"
onClick={handlePreview}
>
预览
</Button>
<Button type="link" htmlType="reset">
重置
</Button>
</Space>
</Form.Item>
</Form>
<Form layout="vertical">
<Form.Item
label="地图预览"
help="提示:单击地图可设置对应位置为中心坐标"
>
<CampusMap
className={Styles.campusMap}
mapConfig={mapConfig}
onInit={(map) => map.on('click', handleMapClickRef?.current)}
/>
</Form.Item>
</Form>
</Card>
</div>
</Spin>
);
};
export default observer(MapManage);
@@ -0,0 +1,3 @@
.content {
padding: 8px;
}
@@ -0,0 +1,284 @@
import { observer } from 'mobx-react';
import { type FC, useEffect, useState } from 'react';
import Styles from './index.module.less';
import {
Button,
Card,
Form,
Input,
Modal,
Popconfirm,
Select,
Spin,
Table,
message,
} from 'antd';
import type ServiceTypes from '@/services/serviceTypes';
import services from '@/services';
import { type ColumnType } from 'antd/es/table';
import constants from '@/constants';
import { useForm } from 'antd/es/form/Form';
import CryptoJS from 'crypto-js';
type AddFormData = ServiceTypes['POST /api/admin/addUser']['req'];
type EditFormData = ServiceTypes['POST /api/admin/updateUser']['req'];
type UserInfo =
ServiceTypes['GET /api/admin/getUserList']['res']['data'][number];
const UsersManage: FC = () => {
const [userList, setUserList] = useState<UserInfo[]>([]);
const [fetchLoading, setFetchLoading] = useState<boolean>(false);
const [addForm] = useForm<AddFormData>();
const [editForm] = useForm<EditFormData>();
const [addModalVisible, setAddModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const fetchUserList = async () => {
try {
setFetchLoading(true);
setUserList((await services['GET /api/admin/getUserList']()).data);
} catch (error) {
console.log(error);
message.error(String(error));
} finally {
setFetchLoading(false);
}
};
useEffect(() => {
fetchUserList();
}, []);
const handleAddUser = () => {
addForm.resetFields();
setAddModalVisible(true);
};
const handleEditUser = (record: UserInfo) => {
editForm.resetFields();
editForm.setFieldsValue(record);
setEditModalVisible(true);
};
const handleDeleteUser = async (username: string) => {
try {
await services['POST /api/admin/deleteUser']({ username });
message.success('删除用户成功');
fetchUserList();
} catch (error) {
console.log(error);
message.error(String(error));
}
};
const handleAddSubmit = async () => {
const values = await addForm.validateFields();
try {
await services['POST /api/admin/addUser']({
...values,
password: CryptoJS.HmacSHA256(
values.password,
constants.SHA256KEY,
).toString(CryptoJS.enc.Base64),
});
message.success('新增用户成功');
setAddModalVisible(false);
fetchUserList();
} catch (error) {
console.log(error);
message.error(String(error));
}
};
const handleEditSubmit = async () => {
const values = await editForm.validateFields();
try {
await services['POST /api/admin/updateUser']({
...values,
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
newPassword: values.newPassword
? CryptoJS.HmacSHA256(
values.newPassword,
constants.SHA256KEY,
).toString(CryptoJS.enc.Base64)
: undefined,
});
message.success('编辑用户成功');
setEditModalVisible(false);
fetchUserList();
} catch (error) {
console.log(error);
message.error(String(error));
}
};
const columns: Array<ColumnType<UserInfo>> = [
{ title: '账号', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{
title: '权限',
dataIndex: 'role',
render: (_v, record) =>
record.role === constants.userRole.ADMIN ? '管理员' : '普通用户',
},
{ title: '邮箱', dataIndex: 'email' },
{ title: '电话', dataIndex: 'tel' },
{
title: '操作',
render: (_v, record) => (
<Button.Group>
<Button
type="link"
onClick={() => {
handleEditUser(record);
}}
>
编辑
</Button>
<Popconfirm
title="确认删除该用户?"
onConfirm={async () => {
await handleDeleteUser(record.username);
}}
>
<Button type="link">删除</Button>
</Popconfirm>
</Button.Group>
),
},
];
const commonFormItems = (
<>
<Form.Item label="权限" name="role" rules={[{ required: true }]}>
<Select
options={[
{ label: '管理员', value: 'admin' },
{ label: '普通用户', value: 'user' },
]}
/>
</Form.Item>
<Form.Item label="昵称" name="nickname" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[{ type: 'email' }]}>
<Input type="email" />
</Form.Item>
<Form.Item
label="电话"
name="tel"
rules={[{ pattern: /^\d{11}$/, message: '请输入11位手机号' }]}
>
<Input type="tel" />
</Form.Item>
</>
);
return (
<Spin spinning={fetchLoading}>
<div className={Styles.content}>
<Card
title="系统用户管理"
extra={
<Button type="primary" onClick={handleAddUser}>
新增用户
</Button>
}
>
<Table<UserInfo>
columns={columns}
dataSource={userList}
rowKey="username"
/>
</Card>
<Modal
open={addModalVisible}
onCancel={() => {
setAddModalVisible(false);
}}
title="新增用户"
footer={
<Button.Group>
<Button type="primary" onClick={handleAddSubmit}>
提交
</Button>
<Button
onClick={() => {
setAddModalVisible(false);
}}
>
取消
</Button>
</Button.Group>
}
>
<Form form={addForm} labelCol={{ span: 3 }} labelAlign="right">
<Form.Item
label="账号"
name="username"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true }]}
>
<Input type="password" />
</Form.Item>
{commonFormItems}
</Form>
</Modal>
<Modal
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false);
}}
title="编辑用户"
footer={
<Button.Group>
<Button type="primary" onClick={handleEditSubmit}>
提交
</Button>
<Button
onClick={() => {
setEditModalVisible(false);
}}
>
取消
</Button>
</Button.Group>
}
>
<Form form={editForm} labelCol={{ span: 4 }} labelAlign="right">
<Form.Item
label="用户名"
name="username"
rules={[{ required: true }]}
>
<Input disabled />
</Form.Item>
{commonFormItems}
<Form.Item label="新密码" name="newPassword">
<Input type="password" />
</Form.Item>
</Form>
</Modal>
</div>
</Spin>
);
};
export default observer(UsersManage);
+232
View File
@@ -0,0 +1,232 @@
.ai-chat-page {
height: 100%;
padding: 18px;
}
.panel-card {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 36px - 36px);
min-height: 420px;
padding: 18px;
border: 1px solid rgba(103, 232, 249, 0.24);
border-radius: 22px;
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.08),
rgba(8, 18, 38, 0.72) 42%,
rgba(255, 255, 255, 0.03)
);
box-shadow: 0 18px 50px rgba(2, 8, 23, 0.48);
backdrop-filter: blur(18px);
}
.ai-chat-header {
display: flex;
justify-content: space-between;
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(103, 232, 249, 0.16);
}
.ai-chat-header h2 {
margin: 0;
color: #e6f7ff;
font-size: 20px;
}
.ai-chat-header p {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0 0;
color: rgba(226, 242, 255, 0.68);
}
.ai-robot-avatar {
border: 1px solid rgba(103, 232, 249, 0.5);
background: linear-gradient(135deg, #0891b2, #7c3aed);
box-shadow: 0 0 20px rgba(34, 211, 238, 0.24);
}
.ai-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 12px rgba(34, 197, 94, 0.9);
}
.ai-config-alert {
margin: 14px 0;
}
.ai-message-list {
flex: 1;
padding: 18px 6px 18px 0;
overflow-y: auto;
}
.ai-message-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.ai-message-row.is-user {
flex-direction: row-reverse;
}
.ai-message-avatar {
flex: 0 0 auto;
border: 1px solid rgba(103, 232, 249, 0.38);
background: rgba(14, 30, 56, 0.86);
}
.ai-bubble {
max-width: min(760px, 78%);
}
.ai-bubble-content {
padding: 12px 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid rgba(103, 232, 249, 0.2);
border-radius: 16px;
background: rgba(14, 30, 56, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.is-user .ai-bubble-content {
border-color: rgba(96, 165, 250, 0.34);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.68), rgba(14, 165, 233, 0.42));
}
.is-assistant .ai-bubble-content {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 0 24px rgba(34, 211, 238, 0.08);
}
.ai-bubble-time {
margin-top: 5px;
color: rgba(226, 242, 255, 0.45);
font-size: 12px;
}
.is-user .ai-bubble-time {
text-align: right;
}
.ai-input-bar {
display: flex;
gap: 12px;
align-items: flex-end;
padding-top: 14px;
border-top: 1px solid rgba(103, 232, 249, 0.16);
}
.ai-textarea {
flex: 1;
}
:global(.ant-input),
:global(.ant-input-affix-wrapper),
:global(.ant-input-css-var) {
color: #e6f7ff;
border-color: rgba(103, 232, 249, 0.24);
background: rgba(2, 8, 23, 0.42);
}
:global(.ant-input:focus),
:global(.ant-input-focused),
:global(.ant-input-affix-wrapper-focused) {
border-color: rgba(34, 211, 238, 0.78);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.12);
}
:global(.ant-input::placeholder) {
color: rgba(226, 242, 255, 0.38);
}
.api-key-tip {
padding: 16px;
margin-bottom: 18px;
color: #d6f4ff;
border: 1px solid rgba(103, 232, 249, 0.28);
border-radius: 14px;
background: linear-gradient(
160deg,
rgba(34, 211, 238, 0.12),
rgba(8, 18, 38, 0.5)
);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.api-key-tip p {
margin: 6px 0;
}
.api-key-tip-title {
color: #67e8f9;
font-size: 15px;
}
.api-key-tip-sub {
color: rgba(214, 244, 255, 0.72);
font-size: 13px;
line-height: 1.7;
}
.api-key-steps {
padding-left: 20px;
margin: 10px 0;
}
.api-key-steps li {
margin-bottom: 8px;
color: rgba(214, 244, 255, 0.85);
font-size: 13px;
line-height: 1.7;
}
.api-key-steps a {
color: #22d3ee;
}
.api-key-tip code {
padding: 1px 6px;
color: #a5f3fc;
border: 1px solid rgba(103, 232, 249, 0.3);
border-radius: 6px;
background: rgba(2, 8, 23, 0.6);
font-size: 12px;
}
.api-key-tip-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
padding-top: 8px;
border-top: 1px dashed rgba(103, 232, 249, 0.2);
}
.api-key-tip-note {
margin-bottom: 0;
color: rgba(214, 244, 255, 0.55);
font-size: 12px;
}
@media (max-width: 768px) {
.ai-chat-page {
padding: 10px;
}
.panel-card {
height: calc(100vh - 64px - 36px - 20px);
padding: 12px;
}
.ai-bubble {
max-width: 86%;
}
}
+485
View File
@@ -0,0 +1,485 @@
import {
ApiOutlined,
ClearOutlined,
RobotOutlined,
SendOutlined,
SettingOutlined,
StopOutlined,
UserOutlined,
} from '@ant-design/icons';
import {
Alert,
Avatar,
Button,
Drawer,
Empty,
Form,
Input,
Space,
Spin,
Tag,
Tooltip,
message,
} from 'antd';
import { type FC, useEffect, useMemo, useRef, useState } from 'react';
import './index.less';
interface ChatMessage {
id: string;
role: 'system' | 'user' | 'assistant';
content: string;
createdAt: string;
}
interface AiSettings {
baseUrl: string;
apiKey: string;
model: string;
}
// v2:服务商切换为 TokenFree,旧版本地缓存不再复用,避免过期默认值残留
const SETTINGS_KEY = 'parking-ai-settings-v2';
const LEGACY_SETTINGS_KEY = 'parking-ai-settings';
const MESSAGES_KEY = 'parking-ai-messages';
const PROVIDER_HOME = 'https://www.tokenfree.com/';
const DEFAULT_SETTINGS: AiSettings = {
baseUrl: 'https://www.tokenfree.com/v1',
apiKey: '',
model: 'kimi-k2.6',
};
const loadSettings = (): AiSettings => {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
// 仅迁移用户在旧服务商下已填写的 API Key,地址/模型强制使用新默认值
const legacyRaw = localStorage.getItem(LEGACY_SETTINGS_KEY);
if (legacyRaw) {
const legacy = JSON.parse(legacyRaw) as Partial<AiSettings>;
const migrated = {
...DEFAULT_SETTINGS,
apiKey: typeof legacy.apiKey === 'string' ? legacy.apiKey : '',
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(migrated));
return migrated;
}
return DEFAULT_SETTINGS;
} catch {
return DEFAULT_SETTINGS;
}
};
const createMessage = (
role: ChatMessage['role'],
content: string,
): ChatMessage => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
role,
content,
createdAt: new Date().toLocaleString(),
});
const AiChat: FC = () => {
const [settings, setSettings] = useState<AiSettings>(loadSettings);
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
return JSON.parse(localStorage.getItem(MESSAGES_KEY) || '[]');
} catch {
return [];
}
});
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsForm] = Form.useForm<AiSettings>();
const abortRef = useRef<AbortController | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const normalizedBaseUrl = useMemo(
() => settings.baseUrl.replace(/\/+$/, ''),
[settings.baseUrl],
);
useEffect(() => {
document.title = 'AI对话 - 智慧停车管理平台';
}, []);
useEffect(() => {
localStorage.setItem(MESSAGES_KEY, JSON.stringify(messages));
listRef.current?.scrollTo({
top: listRef.current.scrollHeight,
behavior: 'smooth',
});
}, [messages]);
const saveSettings = async () => {
const values = await settingsForm.validateFields();
const next = {
...values,
baseUrl: values.baseUrl.replace(/\/+$/, ''),
};
setSettings(next);
localStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
setSettingsOpen(false);
message.success('AI 接口配置已保存');
};
const testConnection = async () => {
const values = settingsForm.getFieldsValue();
const baseUrl = values.baseUrl.replace(/\/+$/, '');
if (!baseUrl) {
message.warning('请先填写 API Base URL');
return;
}
try {
const res = await fetch(`${baseUrl}/models`, {
headers: { Authorization: `Bearer ${values.apiKey}` },
});
if (!res.ok) throw new Error(res.statusText);
message.success('连接测试成功');
} catch (error) {
message.error(`连接失败:${String(error)}`);
}
};
const stopGenerating = () => {
abortRef.current?.abort();
setLoading(false);
};
const sendMessage = async () => {
const content = input.trim();
if (!content) return;
if (!settings.apiKey) {
setSettingsOpen(true);
message.warning('请先配置 API Key');
return;
}
const userMessage = createMessage('user', content);
const assistantMessage = createMessage('assistant', '');
const nextMessages = [...messages, userMessage, assistantMessage];
setMessages(nextMessages);
setInput('');
setLoading(true);
const controller = new AbortController();
abortRef.current = controller;
const payload = {
model: settings.model,
stream: true,
messages: [
{
role: 'system',
content:
'你是智慧停车管理平台AI助手,可解答停车运营、设备状态、异常报警、园区监控和数据分析相关问题。',
},
...messages
.filter((item) => item.role !== 'system')
.map((item) => ({ role: item.role, content: item.content })),
{ role: 'user', content },
],
};
try {
const response = await fetch(`${normalizedBaseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${settings.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok || !response.body) {
throw new Error(response.statusText || 'AI 服务响应异常');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let answer = '';
const patchLastMessage = (text: string) => {
setMessages((current) =>
current.map((item, index) =>
index === current.length - 1 ? { ...item, content: text } : item,
),
);
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() || '';
chunks.forEach((chunk) => {
chunk
.split('\n')
.filter((line) => line.startsWith('data:'))
.forEach((line) => {
const data = line.replace(/^data:\s*/, '').trim();
if (!data || data === '[DONE]') return;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
answer += delta;
patchLastMessage(answer);
} catch {
// 忽略心跳或非标准分片
}
});
});
}
if (!answer) patchLastMessage('暂未收到有效回复,请稍后重试。');
} catch (error) {
const text =
error instanceof DOMException && error.name === 'AbortError'
? answerOrStopped()
: `AI 服务连接失败:${String(error)}`;
setMessages((current) =>
current.map((item, index) =>
index === current.length - 1 ? { ...item, content: text } : item,
),
);
} finally {
setLoading(false);
abortRef.current = null;
}
};
const answerOrStopped = () => '已停止生成。';
const clearHistory = () => {
setMessages([]);
localStorage.removeItem(MESSAGES_KEY);
message.success('对话记录已清空');
};
const openSettings = () => {
settingsForm.setFieldsValue(settings);
setSettingsOpen(true);
};
return (
<div className="ai-chat-page">
<div className="ai-chat-shell panel-card">
<div className="ai-chat-header">
<div>
<Space>
<Avatar
className="ai-robot-avatar"
icon={<RobotOutlined />}
size="large"
/>
<div>
<h2>智慧停车 AI 助手</h2>
<p>
<Tag color="cyan">{settings.model}</Tag>
<span className="ai-status-dot" />
流式对话已就绪
</p>
</div>
</Space>
</div>
<Space>
<Tooltip title="接口配置">
<Button
icon={<SettingOutlined />}
onClick={openSettings}
shape="circle"
/>
</Tooltip>
<Tooltip title="清空对话">
<Button
danger
icon={<ClearOutlined />}
onClick={clearHistory}
shape="circle"
/>
</Tooltip>
</Space>
</div>
{!settings.apiKey && (
<Alert
className="ai-config-alert"
message="尚未配置 API Key"
description="默认已接入 TokenFree(标准 Chat Completions 接口),请点击右上角设置按钮填写您的 API Key 后即可开始对话。"
type="warning"
showIcon
action={
<Button size="small" type="primary" onClick={openSettings}>
立即配置
</Button>
}
/>
)}
<div className="ai-message-list" ref={listRef}>
{messages.length === 0 ? (
<Empty
description="开始询问停车运营、设备状态或异常报警问题"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
messages.map((item) => (
<div
key={item.id}
className={`ai-message-row ${
item.role === 'user' ? 'is-user' : 'is-assistant'
}`}
>
<Avatar
className="ai-message-avatar"
icon={
item.role === 'user' ? <UserOutlined /> : <RobotOutlined />
}
/>
<div className="ai-bubble">
<div className="ai-bubble-content">
{item.content || (loading ? <Spin size="small" /> : '')}
</div>
<div className="ai-bubble-time">{item.createdAt}</div>
</div>
</div>
))
)}
</div>
<div className="ai-input-bar">
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 4 }}
className="ai-textarea"
placeholder="输入你的问题,Enter 发送,Shift + Enter 换行"
value={input}
onChange={(event) => setInput(event.target.value)}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault();
void sendMessage();
}
}}
/>
{loading ? (
<Button
danger
icon={<StopOutlined />}
onClick={stopGenerating}
size="large"
>
停止
</Button>
) : (
<Button
icon={<SendOutlined />}
onClick={() => void sendMessage()}
size="large"
type="primary"
>
发送
</Button>
)}
</div>
</div>
<Drawer
className="ai-settings-drawer"
title={
<Space>
<ApiOutlined />
AI 接口配置
</Space>
}
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
width={380}
extra={
<Space>
<Button onClick={testConnection}>测试连接</Button>
<Button type="primary" onClick={() => void saveSettings()}>
保存
</Button>
</Space>
}
>
<div className="api-key-tip">
<p className="api-key-tip-title">
<strong>TokenFree 使用教程</strong>
</p>
<p className="api-key-tip-sub">
TokenFree 是统一 AI API 网关,支持标准 Chat Completions 接口协议
(流式输出 / Models 列表),注册后即可调用
Kimi、Qwen、Claude 等多家模型。
</p>
<ol className="api-key-steps">
<li>
打开{' '}
<a href={PROVIDER_HOME} target="_blank" rel="noreferrer">
<strong>https://www.tokenfree.com/</strong>
</a>
,点击注册并完成邮箱验证后登录。
</li>
<li>
登录后进入控制台「令牌 / API Keys」页面,点击「添加令牌」,
复制生成的 Key(以 <code>sk-</code> 开头)。
</li>
<li>
将 API Key 粘贴到下方表单;接口地址与默认模型已预填,
一般无需修改。
</li>
<li>
点击「测试连接」验证 Key 是否有效,通过后点击「保存」,
返回页面即可开始对话。
</li>
</ol>
<p className="api-key-tip-meta">
<span>接口地址:<code>https://www.tokenfree.com/v1</code></span>
<span>默认模型:<code>kimi-k2.6</code></span>
</p>
<p className="api-key-tip-note">
提示:也可在「模型 / Model」处填写 TokenFree 支持的其他模型名称;
Key 仅保存在本地浏览器,不会上传到本系统服务器。
</p>
</div>
<Form layout="vertical" form={settingsForm}>
<Form.Item
label="API Base URL"
name="baseUrl"
rules={[{ required: true, message: '请输入 API Base URL' }]}
extra="默认:https://www.tokenfree.com/v1(标准 Chat Completions 接口,一般无需修改)"
>
<Input placeholder="https://www.tokenfree.com/v1" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
rules={[{ required: true, message: '请输入 API Key' }]}
extra="在 TokenFree 控制台「令牌」页面创建并复制"
>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item
label="Model"
name="model"
rules={[{ required: true, message: '请输入模型名称' }]}
extra="默认 kimi-k2.6,也可填写 TokenFree 支持的其他模型"
>
<Input placeholder="kimi-k2.6" />
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default AiChat;
@@ -0,0 +1,3 @@
.content {
padding: 8px;
}
+243
View File
@@ -0,0 +1,243 @@
import services from '@/services';
import type ServiceTypes from '@/services/serviceTypes';
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import {
Button,
Card,
Descriptions,
Form,
Input,
message,
Modal,
Select,
Spin,
Table,
Tag,
} from 'antd';
import { type ColumnType } from 'antd/es/table';
import { observer } from 'mobx-react';
import { type FC, useState } from 'react';
import Styles from './index.module.less';
import { useAntdTable } from 'ahooks';
import { useForm } from 'antd/es/form/Form';
type TableData =
ServiceTypes['GET /api/user/getAlarmEvents']['res']['data']['list'][number];
interface FormData {
alarmType?: string;
cameraName?: string;
alarmStatus?: string;
}
const Alarms: FC = () => {
const columns: Array<ColumnType<TableData>> = [
{
title: '事件ID',
dataIndex: 'eventID',
},
{
title: '报警类型',
dataIndex: ['alarmType'],
render: (_v, record) => record.alarmRule.alarmRuleName,
},
{ title: '报警源摄像头名称', dataIndex: 'cameraName' },
{
title: '摄像头型号',
dataIndex: 'cameraModel',
},
{ title: '摄像头ID', dataIndex: 'cameraID' },
{
title: '状态',
dataIndex: 'alarmStatus',
render: (_v, record) =>
record.resolved ? (
<Tag icon={<CheckCircleOutlined />} color="success">
已处理
</Tag>
) : (
<Tag icon={<ExclamationCircleOutlined />} color="warning">
未处理
</Tag>
),
},
{ title: '报警时间', dataIndex: 'alarmTime' },
{
title: '操作',
render: (_v, record) => (
<Button
type="link"
onClick={() => {
setModalContext(record);
setModalVisible(true);
}}
>
{record.resolved ? '查看' : '查看并处理'}
</Button>
),
},
];
const [form] = useForm<FormData>();
const { tableProps, refreshAsync, search, loading } = useAntdTable(
async (params, formData: FormData) => {
try {
const res = await services['GET /api/user/getAlarmEvents']({
current: params.current,
pageSize: params.pageSize,
...formData,
});
return {
total: res.data.total,
list: res.data.list,
};
} catch (error) {
message.error(String(error));
console.error(error);
return {
total: 0,
list: [],
};
}
},
{ form },
);
const [modalVisible, setModalVisible] = useState(false);
const [modalContext, setModalContext] = useState<TableData | null>(null);
const [checkLoading, setCheckLoading] = useState(false);
const handleCheck = async (eventID: number) => {
try {
setCheckLoading(true);
await services['POST /api/user/resolveAlarm']({ eventID });
message.success('处理成功');
setModalVisible(false);
refreshAsync();
} catch (error) {
message.error(String(error));
console.error(error);
} finally {
setCheckLoading(false);
}
};
return (
<Spin spinning={loading}>
<div className={Styles.content}>
<Card title="异常报警事件查询">
<Form form={form} layout="inline">
<Form.Item name="alarmType" label="报警类型">
<Input />
</Form.Item>
<Form.Item name="cameraName" label="报警源摄像头名称">
<Input />
</Form.Item>
<Form.Item name="resolved" label="状态">
<Select
placeholder="请选择"
options={[
{ label: '已处理', value: true },
{ label: '未处理', value: false },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={search.submit}>
查询
</Button>
<Button type="link" onClick={search.reset}>
重置
</Button>
</Form.Item>
</Form>
<Table columns={columns} rowKey="eventID" {...tableProps} />
</Card>
<Modal
open={modalVisible}
centered
onCancel={() => {
setModalVisible(false);
}}
okText
closable={false}
width={800}
footer={
<>
{modalContext?.resolved === false && (
<Button
type="primary"
onClick={async () => {
await handleCheck(modalContext.eventID);
}}
loading={checkLoading}
>
处理
</Button>
)}
<Button
onClick={() => {
setModalVisible(false);
}}
>
关闭
</Button>
</>
}
>
{modalContext != null && (
<Descriptions title="报警事件详情" bordered column={2}>
<Descriptions.Item label="事件ID">
{modalContext.eventID}
</Descriptions.Item>
<Descriptions.Item label="报警类型">
{modalContext.alarmRule.alarmRuleName}
</Descriptions.Item>
<Descriptions.Item label="报警时间">
{modalContext.alarmTime}
</Descriptions.Item>
<Descriptions.Item label="报警源摄像头名称">
{modalContext.cameraName}
</Descriptions.Item>
<Descriptions.Item label="摄像头型号">
{modalContext.cameraModel}
</Descriptions.Item>
<Descriptions.Item label="状态">
{modalContext.resolved ? (
<Tag icon={<CheckCircleOutlined />} color="success">
已处理
</Tag>
) : (
<Tag icon={<ExclamationCircleOutlined />} color="warning">
未处理
</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="监控位置" span={2}>
{modalContext.cameraLatlng[0]}
<br />
{modalContext.cameraLatlng[1]}
</Descriptions.Item>
<Descriptions.Item label="报警图片" span={2}>
<img
src={modalContext.alarmPicUrl}
alt="报警图片"
style={{ width: '100%' }}
/>
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
</Spin>
);
};
export default observer(Alarms);
@@ -0,0 +1,172 @@
import type ServiceTypes from '@/services/serviceTypes';
import { observer } from 'mobx-react';
import { type FC, useEffect, useState } from 'react';
import { Button, Card, Descriptions, message, Spin, Table, Tag } from 'antd';
import Styles from './index.module.less';
import { type ColumnType } from 'antd/es/table';
import constants from '@/constants';
import services from '@/services';
import CameraStatusBadge from '@/components/cameraStatusBadge';
import HlsPlayer from '@/components/hlsPlayer';
const CameraInfo: FC<{
cameraID: number;
}> = (props) => {
const [data, setData] =
useState<ServiceTypes['GET /api/user/getCameraInfo']['res']['data']>();
const [fetchLoading, setFetchLoading] = useState(false);
const [resolveLoading, setResolveLoading] = useState(false);
const fetchCameraInfo = async () => {
try {
setFetchLoading(true);
setData(
(
await services['GET /api/user/getCameraInfo']({
cameraID: props.cameraID,
})
).data,
);
} catch (error) {
message.error(String(error));
console.error(error);
} finally {
setFetchLoading(false);
}
};
useEffect(() => {
fetchCameraInfo();
const timer = setInterval(() => {
fetchCameraInfo();
}, 10000);
return () => {
clearInterval(timer);
};
}, [props.cameraID]);
const handleResolveAlarm = async (eventID: number) => {
try {
setResolveLoading(true);
await services['POST /api/user/resolveAlarm']({ eventID });
setData(
(
await services['GET /api/user/getCameraInfo']({
cameraID: props.cameraID,
})
).data,
);
} catch (error) {
message.error(String(error));
console.error(error);
} finally {
setResolveLoading(false);
}
};
const tableColumns: Array<
ColumnType<
ServiceTypes['GET /api/user/getCameraInfo']['res']['data']['alarmEvents'][0]
>
> = [
{
title: '报警时间',
dataIndex: 'alarmTime',
render: (value, record) =>
record.resolved ? (
<div>{value}</div>
) : (
<div style={{ color: 'red' }}>{value}</div>
),
width: '100px',
sortOrder: 'descend',
sorter: (a, b) => a.eventID - b.eventID,
},
{
title: '报警类型',
render: (_v, record) =>
record.resolved ? (
<div>{record.alarmRule.alarmRuleName}</div>
) : (
<div style={{ color: 'red' }}>{record.alarmRule.alarmRuleName}</div>
),
width: '180px',
},
{
title: '操作',
align: 'center',
render: (_value, record) =>
record.resolved ? (
'已处理'
) : (
<Button
type="link"
onClick={async () => {
await handleResolveAlarm(record.eventID);
}}
loading={resolveLoading}
>
快速处理
</Button>
),
},
];
return (
<Card
title="摄像头信息"
className={Styles.cameraInfoCard}
bordered={false}
size="small"
>
<Spin spinning={fetchLoading}>
{data && data?.cameraStatus !== constants.cameraStatus.OFFLINE && (
<HlsPlayer item={data} className={Styles.cameraVideo} />
)}
<Descriptions column={1} size="small" className={Styles.cameraDesc}>
<Descriptions.Item label="摄像头ID">
{data?.cameraID}
</Descriptions.Item>
<Descriptions.Item label="名称">{data?.cameraName}</Descriptions.Item>
<Descriptions.Item label="坐标">
纬度:{data?.latlng[0]} <br />
经度:{data?.latlng[1]}
</Descriptions.Item>
<Descriptions.Item label="型号">
{data?.cameraModel}
</Descriptions.Item>
<Descriptions.Item label="状态">
<CameraStatusBadge
status={data?.cameraStatus as 'normal' | 'offline' | 'alarm'}
/>
</Descriptions.Item>
<Descriptions.Item label="报警规则">
{data?.alarmRules.map((rule) => (
<Tag key={rule.alarmRuleID}>{rule.alarmRuleName}</Tag>
))}
</Descriptions.Item>
</Descriptions>
<Descriptions
layout="vertical"
column={1}
size="small"
className={Styles.cameraDesc}
>
<Descriptions.Item label="异常报警事件">
<Table
pagination={{ pageSizeOptions: [5, 10] }}
size="small"
columns={tableColumns}
dataSource={data?.alarmEvents}
showHeader={false}
rowKey="eventID"
className={Styles.alarmEventsTable}
/>
</Descriptions.Item>
</Descriptions>
</Spin>
</Card>
);
};
export default observer(CameraInfo);
@@ -0,0 +1,59 @@
import CameraStatusBadge from '@/components/cameraStatusBadge';
import type ServiceTypes from '@/services/serviceTypes';
import { Button, Card, Table } from 'antd';
import { type ColumnType } from 'antd/es/table';
import { observer } from 'mobx-react';
import { type FC } from 'react';
import Styles from './index.module.less';
const CameraList: FC<{
data?: ServiceTypes['GET /api/user/getCampusState']['res']['data']['cameraList'];
onCameraCheck: (cameraID: number) => any;
}> = (props) => {
const columns: Array<
ColumnType<
ServiceTypes['GET /api/user/getCampusState']['res']['data']['cameraList'][0]
>
> = [
{ title: '摄像头名称', dataIndex: 'cameraName' },
{
title: '状态',
dataIndex: 'cameraStatus',
render: (_value, record) => (
<CameraStatusBadge
status={record.cameraStatus as 'normal' | 'offline' | 'alarm'}
/>
),
},
{
title: '操作',
render: (_value, record) => {
return (
<Button
type="link"
onClick={() => props.onCameraCheck(record.cameraID)}
>
查看
</Button>
);
},
},
];
return (
<Card
title="摄像头列表"
bordered={false}
size="small"
className={Styles.cameraListCard}
>
<Table
className={Styles.table}
size="small"
columns={columns}
dataSource={props.data}
rowKey="cameraID"
/>
</Card>
);
};
export default observer(CameraList);
@@ -0,0 +1,91 @@
.campusPage {
position: relative;
width: 100%;
height: calc(100vh - 64px - 36px);
overflow: hidden;
}
.map {
width: 100%;
height: 100%;
}
.topPanel {
position: absolute;
z-index: 800;
top: 16px;
left: 16px;
right: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 46px;
padding: 8px 14px;
border: 1px solid rgba(103, 232, 249, 0.24);
border-radius: 14px;
background: rgba(4, 13, 30, 0.72);
box-shadow: 0 12px 30px rgba(2, 8, 23, 0.34), 0 0 24px rgba(34, 211, 238, 0.08);
backdrop-filter: blur(16px);
}
.leftPanel,
.rightPanel {
position: absolute;
z-index: 800;
top: 78px;
display: flex;
}
.leftPanel {
left: 16px;
flex-direction: column;
}
.rightPanel {
right: 16px;
}
.cameraListCard,
.cameraInfoCard {
max-height: calc(100vh - 64px - 36px - 96px);
overflow-y: auto;
}
.cameraInfoCard {
max-width: 400px;
}
.cameraVideo {
width: 100%;
height: 220px;
}
.alarmEventsTable {
width: 100%;
}
:global(.leaflet-container) {
background: #071120;
}
@media (max-width: 768px) {
.topPanel {
flex-wrap: wrap;
right: 10px;
left: 10px;
}
.leftPanel,
.rightPanel {
top: 118px;
}
.leftPanel {
left: 10px;
}
.rightPanel {
right: 10px;
}
}
+84
View File
@@ -0,0 +1,84 @@
import services from '@/services';
import type ServiceTypes from '@/services/serviceTypes';
import { message, Spin } from 'antd';
import { observer } from 'mobx-react';
import { type FC, useCallback, useEffect, useState } from 'react';
import CameraInfo from './cameraInfo';
import CameraList from './cameraList';
import CampusMap from '../../components/campusMap';
import Styles from './index.module.less';
import StatusHeader from './statusHeader';
import mobxStore from '@/mobxStore';
const CampusState: FC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] =
useState<ServiceTypes['GET /api/user/getCampusState']['res']['data']>();
const [selectedCameraIds, setSelectedCameraIds] = useState<number[]>([]);
const fetchCampusState = useCallback(async () => {
try {
setLoading(true);
const res = await services['GET /api/user/getCampusState']();
setData(res.data);
} catch (error) {
message.error(String(error));
console.error(error);
} finally {
setLoading(false);
}
}, []);
const handleSelectCamera = useCallback(
(currentCameraId: number) => {
setSelectedCameraIds((current) =>
current.includes(currentCameraId) ? [] : [currentCameraId],
);
},
[],
);
const handleCheckCamera = useCallback((cameraID: number) => {
setSelectedCameraIds((current) =>
current.includes(cameraID) ? current : [cameraID],
);
}, []);
useEffect(() => {
void fetchCampusState();
const timer = window.setInterval(() => {
void fetchCampusState();
}, 10000);
return () => window.clearInterval(timer);
}, [fetchCampusState]);
return (
<Spin spinning={loading}>
<div className={Styles.campusPage}>
<CampusMap
className={Styles.map}
cameraList={data?.cameraList}
onCameraClick={handleSelectCamera}
selectedCameraIds={selectedCameraIds}
mapConfig={mobxStore.mapConfig.config}
/>
<div className={Styles.topPanel}>
<StatusHeader data={data} />
</div>
<div className={Styles.leftPanel}>
<CameraList data={data?.cameraList} onCameraCheck={handleCheckCamera} />
</div>
<div className={Styles.rightPanel}>
{selectedCameraIds.length > 0 && (
<CameraInfo cameraID={selectedCameraIds[0]} />
)}
</div>
</div>
</Spin>
);
};
export default observer(CampusState);
@@ -0,0 +1,28 @@
import type ServiceTypes from '@/services/serviceTypes';
import { observer } from 'mobx-react';
import { type FC, useEffect, useState } from 'react';
const StatusHeader: FC<{
data?: ServiceTypes['GET /api/user/getCampusState']['res']['data'];
}> = ({ data }) => {
const [timeStr, setTimeStr] = useState(() => new Date().toLocaleString());
useEffect(() => {
const timer = window.setInterval(() => {
setTimeStr(new Date().toLocaleString());
}, 1000);
return () => window.clearInterval(timer);
}, []);
return (
<>
<div>{timeStr}</div>
<div>总监控数量:{data?.cameraTotal ?? 0}</div>
<div>监控在线数量:{data?.cameraOnline ?? 0}</div>
<div>异常报警监控数量:{data?.cameraAlarm ?? 0}</div>
<div>未处理异常事件:{data?.alarmEventPending ?? 0}</div>
</>
);
};
export default observer(StatusHeader);
+17
View File
@@ -0,0 +1,17 @@
import { observer } from 'mobx-react';
import { type FC, useEffect } from 'react';
import { history } from 'umi';
import mobxStore from '@/mobxStore';
const IndexPage: FC = () => {
useEffect(() => {
if (mobxStore.user.token.length > 0) {
// 已登陆,跳转至首页
history.push('/campusState');
} else {
history.push('/login');
}
}, []);
return <div></div>;
};
export default observer(IndexPage);
+202
View File
@@ -0,0 +1,202 @@
.loginPage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
overflow: hidden;
background:
radial-gradient(circle at 18% 14%, rgba(34, 211, 238, 0.26), transparent 30%),
radial-gradient(circle at 80% 18%, rgba(139, 92, 246, 0.26), transparent 34%),
linear-gradient(135deg, #020617, #081120 50%, #111827);
}
.gridLayer {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(103, 232, 249, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(103, 232, 249, 0.1) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: radial-gradient(circle, black 30%, transparent 78%);
}
.orbOne,
.orbTwo {
position: absolute;
border-radius: 50%;
filter: blur(3px);
opacity: 0.72;
}
.orbOne {
width: 260px;
height: 260px;
left: 12%;
bottom: 10%;
border: 1px solid rgba(103, 232, 249, 0.32);
animation: floatOne 18s ease-in-out infinite alternate;
}
.orbTwo {
width: 340px;
height: 340px;
right: 8%;
top: 8%;
border: 1px solid rgba(139, 92, 246, 0.32);
animation: floatTwo 22s ease-in-out infinite alternate;
}
@keyframes floatOne {
to { transform: translate(28px, -24px) scale(1.08); }
}
@keyframes floatTwo {
to { transform: translate(-34px, 26px) scale(1.06); }
}
.loginPanel {
position: relative;
z-index: 2;
width: min(420px, calc(100vw - 36px));
padding: 30px;
border: 1px solid rgba(103, 232, 249, 0.28);
border-radius: 24px;
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.1),
rgba(7, 18, 39, 0.78) 42%,
rgba(255, 255, 255, 0.04)
);
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.48),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 36px rgba(34, 211, 238, 0.12);
backdrop-filter: blur(22px);
}
.loginPanel::before,
.loginPanel::after {
content: "";
position: absolute;
width: 28px;
height: 28px;
pointer-events: none;
}
.loginPanel::before {
top: 12px;
left: 12px;
border-top: 2px solid #67e8f9;
border-left: 2px solid #67e8f9;
}
.loginPanel::after {
right: 12px;
bottom: 12px;
border-right: 2px solid #8b5cf6;
border-bottom: 2px solid #8b5cf6;
}
.brandRow {
display: flex;
align-items: center;
gap: 14px;
}
.brandRow img {
width: 58px;
height: 58px;
filter: drop-shadow(0 0 16px rgba(34, 211, 238, 0.72));
}
.brandRow h1 {
margin: 0;
color: #e6f7ff;
font-size: 23px;
letter-spacing: 0.03em;
}
.brandRow p {
margin: 4px 0 0;
color: rgba(226, 242, 255, 0.58);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.panelTitle {
display: flex;
align-items: center;
gap: 10px;
margin: 26px 0 18px;
color: #cffafe;
font-size: 16px;
}
.panelTitle span {
width: 4px;
height: 18px;
border-radius: 999px;
background: linear-gradient(180deg, #67e8f9, #8b5cf6);
box-shadow: 0 0 14px rgba(34, 211, 238, 0.8);
}
.loginForm :global(.ant-form-item-label > label) {
color: rgba(230, 247, 255, 0.82);
}
.loginForm :global(.ant-input),
.loginForm :global(.ant-input-affix-wrapper) {
color: #e6f7ff;
border-color: rgba(103, 232, 249, 0.24);
background: rgba(2, 8, 23, 0.46);
}
.loginForm :global(.ant-input-affix-wrapper-focused),
.loginForm :global(.ant-input:focus) {
border-color: rgba(34, 211, 238, 0.78);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.12);
}
.loginForm :global(.ant-input::placeholder) {
color: rgba(226, 242, 255, 0.38);
}
.loginButton {
height: 44px;
margin-top: 8px;
border: 0;
background: linear-gradient(135deg, #0891b2, #2563eb 52%, #7c3aed);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.3);
}
.loginButton:hover {
box-shadow: 0 0 30px rgba(34, 211, 238, 0.28);
}
.loginFooter {
display: flex;
justify-content: center;
gap: 18px;
margin-top: 24px;
padding-top: 18px;
color: rgba(226, 242, 255, 0.52);
font-size: 13px;
border-top: 1px solid rgba(103, 232, 249, 0.14);
}
@media (max-width: 480px) {
.loginPanel {
padding: 24px 20px;
}
.brandRow h1 {
font-size: 20px;
}
.loginFooter {
gap: 12px;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input, message } from 'antd';
import crypto from 'crypto-js';
import { observer } from 'mobx-react';
import { type FC, useEffect, useState } from 'react';
import { history } from 'umi';
import logoSvg from '@/assets/logo.svg';
import constants from '@/constants';
import mobxStore from '@/mobxStore';
import services from '@/services';
import Style from './index.module.less';
interface IFormData {
username: string;
password: string;
}
const Login: FC = () => {
const [loading, setLoading] = useState(false);
const [loginForm] = Form.useForm<IFormData>();
useEffect(() => {
document.title = '登录 - 智慧停车管理平台';
}, []);
useEffect(() => {
if (mobxStore.user.token.length > 0) {
history.push('/campusState');
}
}, [mobxStore.user.token]);
const handleLogin = async (formData: IFormData) => {
setLoading(true);
try {
const res = await services['POST /api/user/login']({
username: formData.username,
password: crypto
.HmacSHA256(formData.password, constants.SHA256KEY)
.toString(crypto.enc.Base64),
});
const { token, userInfo } = res.data;
mobxStore.user.setToken(token);
mobxStore.user.setUserInfo(userInfo);
} catch (error) {
console.error(error);
message.error(String(error));
} finally {
setLoading(false);
}
};
return (
<div className={Style.loginPage}>
<div className={Style.gridLayer} aria-hidden="true" />
<div className={Style.orbOne} aria-hidden="true" />
<div className={Style.orbTwo} aria-hidden="true" />
<section className={Style.loginPanel}>
<div className={Style.brandRow}>
<img src={logoSvg} alt="智慧停车管理平台 Logo" />
<div>
<h1>智慧停车管理平台</h1>
<p>Smart Parking Operation Center</p>
</div>
</div>
<div className={Style.panelTitle}>
<span />
账号登录
</div>
<Form
className={Style.loginForm}
form={loginForm}
onFinish={handleLogin}
layout="vertical"
requiredMark={false}
>
<Form.Item
label="账号"
name="username"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入账号"
autoComplete="username"
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
autoComplete="current-password"
/>
</Form.Item>
<Button
block
className={Style.loginButton}
htmlType="submit"
loading={loading}
size="large"
type="primary"
>
进入系统
</Button>
</Form>
<div className={Style.loginFooter}>
<span>实时监控</span>
<span>智能报警</span>
<span>运营分析</span>
</div>
</section>
</div>
);
};
export default observer(Login);
@@ -0,0 +1,7 @@
.content {
padding: 8px;
}
.video {
width: 100%;
height: 320px;
}
+82
View File
@@ -0,0 +1,82 @@
import services from '@/services';
import type ServiceTypes from '@/services/serviceTypes';
import { Badge, Card, List, Spin, message } from 'antd';
import { observer } from 'mobx-react';
import { type FC, useEffect, useState } from 'react';
import Styles from './index.module.less';
import constants from '@/constants';
import HlsPlayer from '../../components/hlsPlayer';
const MonitScreen: FC = () => {
const [data, setData] = useState<
ServiceTypes['GET /api/user/getMonitList']['res']['data']
>([]);
const [loading, setLoading] = useState(false);
const [update, setUpdate] = useState(false);
const fetchData = async () => {
try {
setLoading(true);
setData((await services['GET /api/user/getMonitList']()).data);
} catch (error) {
message.error(String(error));
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const timer = setInterval(() => {
fetchData();
}, 10000);
return () => {
clearInterval(timer);
};
}, []);
return (
<div className={Styles.content}>
<Spin spinning={loading}>
<List
grid={{ column: 3, gutter: 16 }}
dataSource={data}
pagination={{
defaultPageSize: 6,
size: 'small',
position: 'bottom',
align: 'center',
pageSizeOptions: [6, 12, 18, 24],
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: () => {
setUpdate(!update);
},
hideOnSinglePage: true,
}}
renderItem={(item) => (
<List.Item>
<Card
title={item.cameraName}
size="small"
extra={
item.cameraStatus === constants.cameraStatus.NORMAL ? (
<Badge status="success" text="正常" />
) : item.cameraStatus === constants.cameraStatus.OFFLINE ? (
<Badge status="default" text="离线" />
) : (
<Badge status="error" text="报警" />
)
}
>
<HlsPlayer item={item} className={Styles.video} />
</Card>
</List.Item>
)}
/>
</Spin>
</div>
);
};
export default observer(MonitScreen);
+354
View File
@@ -0,0 +1,354 @@
.overview-screen {
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
padding: 4px;
overflow-y: auto;
}
/* ---------- header ---------- */
.overview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 22px;
border: 1px solid rgba(103, 232, 249, 0.24);
border-radius: 18px;
background: linear-gradient(120deg, rgba(34, 211, 238, 0.14), rgba(8, 18, 38, 0.66));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 0 32px rgba(34, 211, 238, 0.1);
}
.overview-title {
display: flex;
align-items: center;
gap: 14px;
}
.overview-title-icon {
font-size: 30px;
color: #22d3ee;
text-shadow: 0 0 18px rgba(34, 211, 238, 0.8);
}
.overview-title h1 {
margin: 0;
color: #e6f7ff;
font-size: 22px;
letter-spacing: 2px;
}
.overview-title p {
margin: 4px 0 0;
color: rgba(103, 232, 249, 0.85);
font-size: 11px;
letter-spacing: 3px;
}
.overview-head-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.overview-links {
display: flex;
gap: 14px;
}
.overview-links a {
padding: 5px 14px;
color: #a5f3fc;
border: 1px solid rgba(103, 232, 249, 0.35);
border-radius: 20px;
background: rgba(34, 211, 238, 0.1);
font-size: 13px;
transition: all 0.25s ease;
}
.overview-links a:hover {
color: #06121f;
background: #22d3ee;
box-shadow: 0 0 18px rgba(34, 211, 238, 0.7);
}
.overview-clock {
display: flex;
align-items: center;
gap: 8px;
color: #a5f3fc;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
/* ---------- KPI cards ---------- */
.overview-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
}
.kpi-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
overflow: hidden;
border: 1px solid rgba(103, 232, 249, 0.2);
border-radius: 16px;
background: linear-gradient(150deg, rgba(14, 30, 56, 0.85), rgba(8, 18, 38, 0.6));
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.kpi-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 30px rgba(2, 8, 23, 0.5);
}
.kpi-icon {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
flex: 0 0 auto;
font-size: 22px;
border-radius: 12px;
}
.kpi-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.kpi-label {
color: rgba(214, 244, 255, 0.6);
font-size: 12px;
}
.kpi-value {
color: #e6f7ff;
font-size: 24px;
font-weight: 700;
line-height: 1.25;
font-variant-numeric: tabular-nums;
}
.kpi-sub {
color: rgba(214, 244, 255, 0.72);
font-size: 11px;
}
.kpi-corner {
position: absolute;
top: 0;
right: 0;
width: 40px;
height: 40px;
clip-path: polygon(100% 0, 0 0, 100% 100%);
opacity: 0.5;
}
.tone-cyan .kpi-icon { color: #22d3ee; background: rgba(34, 211, 238, 0.14); }
.tone-cyan .kpi-corner { background: #22d3ee; }
.tone-green .kpi-icon { color: #4ade80; background: rgba(74, 222, 128, 0.14); }
.tone-green .kpi-corner { background: #4ade80; }
.tone-red .kpi-icon { color: #fb7185; background: rgba(251, 113, 133, 0.14); }
.tone-red .kpi-corner { background: #fb7185; }
.tone-purple .kpi-icon { color: #c084fc; background: rgba(192, 132, 252, 0.14); }
.tone-purple .kpi-corner { background: #c084fc; }
.tone-blue .kpi-icon { color: #60a5fa; background: rgba(96, 165, 250, 0.14); }
.tone-blue .kpi-corner { background: #60a5fa; }
/* ---------- panels row ---------- */
.overview-panels {
display: grid;
grid-template-columns: 1.5fr 1fr 1.2fr;
gap: 14px;
flex: 1;
min-height: 300px;
}
.ov-panel {
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid rgba(103, 232, 249, 0.2);
border-radius: 16px;
background: linear-gradient(160deg, rgba(14, 30, 56, 0.78), rgba(8, 18, 38, 0.6));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.panel-title {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid rgba(103, 232, 249, 0.14);
}
.panel-title-bar {
width: 4px;
height: 16px;
border-radius: 2px;
background: #22d3ee;
box-shadow: 0 0 10px rgba(34, 211, 238, 0.8);
}
.panel-title h3 {
margin: 0;
color: #e6f7ff;
font-size: 15px;
}
.panel-title-tag {
margin-inline-start: auto;
color: #67e8f9;
border-color: rgba(103, 232, 249, 0.3);
background: rgba(34, 211, 238, 0.08);
font-size: 10px;
letter-spacing: 1px;
}
/* ---------- trend chart ---------- */
.trend-chart {
flex: 1;
display: flex;
flex-direction: column;
}
.trend-chart svg {
flex: 1;
width: 100%;
min-height: 160px;
}
.trend-x {
display: flex;
justify-content: space-between;
padding: 6px 26px 0;
}
.trend-x span {
color: rgba(214, 244, 255, 0.55);
font-size: 11px;
}
/* ---------- donut ---------- */
.donut-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.donut-svg {
width: 150px;
}
.donut-total {
fill: #e6f7ff;
font-size: 26px;
font-weight: 700;
}
.donut-label {
fill: rgba(214, 244, 255, 0.5);
font-size: 11px;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 0;
margin: 0;
list-style: none;
}
.donut-legend li {
display: flex;
align-items: center;
gap: 8px;
color: rgba(214, 244, 255, 0.75);
font-size: 12px;
}
.donut-legend strong {
margin-inline-start: auto;
color: #e6f7ff;
font-variant-numeric: tabular-nums;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* ---------- latest alarms ---------- */
.latest-list {
flex: 1;
padding: 0;
margin: 0;
overflow-y: auto;
list-style: none;
}
.latest-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px dashed rgba(103, 232, 249, 0.12);
}
.latest-text {
display: inline-flex;
flex-direction: column;
line-height: 1.4;
}
.latest-name {
color: #d6f4ff;
font-size: 13px;
}
.latest-rule {
color: rgba(214, 244, 255, 0.72);
font-size: 11px;
}
.latest-time {
color: rgba(214, 244, 255, 0.5);
font-size: 11px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.latest-empty {
justify-content: center;
color: rgba(214, 244, 255, 0.45);
font-size: 13px;
}
/* ---------- responsive ---------- */
@media (max-width: 1400px) {
.overview-kpis { grid-template-columns: repeat(3, 1fr); }
.overview-panels { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 900px) {
.overview-kpis { grid-template-columns: repeat(2, 1fr); }
.overview-panels { grid-template-columns: 1fr; }
.overview-header { flex-direction: column; gap: 10px; align-items: flex-start; }
}
+296
View File
@@ -0,0 +1,296 @@
import services from '@/services';
import type ServiceTypes from '@/services/serviceTypes';
import { Badge, Spin, Tag } from 'antd';
import { type FC, useEffect, useMemo, useState } from 'react';
import {
AppstoreOutlined,
CameraOutlined,
ClockCircleOutlined,
SafetyCertificateOutlined,
TeamOutlined,
ThunderboltOutlined,
WarningOutlined,
} from '@ant-design/icons';
import './index.less';
type OverviewData =
ServiceTypes['GET /api/user/getOverviewStats']['res']['data'];
const PALETTE = ['#22d3ee', '#fb7185', '#94a3b8'];
const Overview: FC = () => {
const [data, setData] = useState<OverviewData>();
const [loading, setLoading] = useState(false);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setData((await services['GET /api/user/getOverviewStats']()).data);
} finally {
setLoading(false);
}
};
void fetchData();
const dataTimer = window.setInterval(fetchData, 10000);
const clockTimer = window.setInterval(() => setNow(new Date()), 1000);
return () => {
window.clearInterval(dataTimer);
window.clearInterval(clockTimer);
};
}, []);
const trendPath = useMemo(() => buildLinePath(data?.trend ?? []), [data]);
return (
<Spin spinning={loading}>
<div className="overview-screen">
<div className="overview-header">
<div className="overview-title">
<ThunderboltOutlined className="overview-title-icon" />
<div>
<h1>智慧停车综合管理 · 项目概述大屏</h1>
<p>SMART PARKING OVERVIEW DASHBOARD</p>
</div>
</div>
<div className="overview-head-right">
<div className="overview-links">
<a href="https://www.gitcc.com/" target="_blank" rel="noreferrer">
开源社区
</a>
<a href="https://www.gitvv.com/" target="_blank" rel="noreferrer">
GCC AI Coding
</a>
</div>
<div className="overview-clock">
<ClockCircleOutlined />
<span>{now.toLocaleString('zh-CN', { hour12: false })}</span>
</div>
</div>
</div>
<div className="overview-kpis">
<KpiCard
icon={<CameraOutlined />}
label="监控摄像头"
value={data?.camera.total ?? 0}
sub={`在线 ${data?.camera.online ?? 0} · 离线 ${data?.camera.offline ?? 0}`}
tone="cyan"
/>
<KpiCard
icon={<AppstoreOutlined />}
label="设备在线率"
value={`${data?.onlineRate ?? 0}%`}
sub="实时设备健康度"
tone="green"
/>
<KpiCard
icon={<WarningOutlined />}
label="报警事件"
value={data?.alarmEvent.total ?? 0}
sub={`待处理 ${data?.alarmEvent.pending ?? 0} · 已处理 ${data?.alarmEvent.resolved ?? 0}`}
tone="red"
/>
<KpiCard
icon={<SafetyCertificateOutlined />}
label="报警规则"
value={data?.alarmRuleTotal ?? 0}
sub="启用智能检测策略"
tone="purple"
/>
<KpiCard
icon={<TeamOutlined />}
label="系统用户"
value={data?.userTotal ?? 0}
sub="管理员 / 操作员"
tone="blue"
/>
</div>
<div className="overview-panels">
<section className="ov-panel ov-trend">
<PanelTitle title="近 7 天报警趋势" tag="TREND" />
<TrendChart trend={data?.trend ?? []} path={trendPath} />
</section>
<section className="ov-panel ov-dist">
<PanelTitle title="设备状态分布" tag="STATUS" />
<DonutChart dist={data?.cameraStatusDist ?? []} />
</section>
<section className="ov-panel ov-latest">
<PanelTitle title="最新报警动态" tag="LATEST" />
<ul className="latest-list">
{(data?.latestAlarms ?? []).map((item) => (
<li key={item.eventID}>
<Badge
status={item.resolved ? 'success' : 'error'}
text={
<span className="latest-text">
<span className="latest-name">{item.cameraName}</span>
<span className="latest-rule">{item.alarmRuleName}</span>
</span>
}
/>
<span className="latest-time">{item.alarmTime}</span>
</li>
))}
{(data?.latestAlarms ?? []).length === 0 && (
<li className="latest-empty">暂无报警动态</li>
)}
</ul>
</section>
</div>
</div>
</Spin>
);
};
const KpiCard: FC<{
icon: React.ReactNode;
label: string;
value: React.ReactNode;
sub: string;
tone: string;
}> = ({ icon, label, value, sub, tone }) => (
<div className={`kpi-card tone-${tone}`}>
<div className="kpi-icon">{icon}</div>
<div className="kpi-meta">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
<span className="kpi-sub">{sub}</span>
</div>
<span className="kpi-corner" />
</div>
);
const PanelTitle: FC<{ title: string; tag: string }> = ({ title, tag }) => (
<div className="panel-title">
<span className="panel-title-bar" />
<h3>{title}</h3>
<Tag className="panel-title-tag">{tag}</Tag>
</div>
);
const TrendChart: FC<{
trend: { date: string; total: number; pending: number }[];
path: { line: string; area: string };
}> = ({ trend, path }) => {
const W = 560;
const H = 220;
return (
<div className="trend-chart">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.45" />
<stop offset="100%" stopColor="#22d3ee" stopOpacity="0" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((r) => (
<line
key={r}
x1="0"
x2={W}
y1={H * r}
y2={H * r}
stroke="rgba(103,232,249,0.12)"
strokeDasharray="4 6"
/>
))}
<path d={path.area} fill="url(#trendFill)" />
<path
d={path.line}
fill="none"
stroke="#22d3ee"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="trend-x">
{trend.map((t) => (
<span key={t.date}>{t.date}</span>
))}
</div>
</div>
);
};
const DonutChart: FC<{ dist: { name: string; value: number }[] }> = ({
dist,
}) => {
const total = dist.reduce((s, d) => s + d.value, 0);
const R = 62;
const C = 2 * Math.PI * R;
let offset = 0;
return (
<div className="donut-wrap">
<svg viewBox="0 0 180 180" className="donut-svg">
<g transform="rotate(-90 90 90)">
<circle cx="90" cy="90" r={R} fill="none" stroke="rgba(103,232,249,0.1)" strokeWidth="16" />
{dist.map((d, i) => {
const frac = total ? d.value / total : 0;
const len = frac * C;
const el = (
<circle
key={d.name}
cx="90"
cy="90"
r={R}
fill="none"
stroke={PALETTE[i]}
strokeWidth="16"
strokeDasharray={`${len} ${C - len}`}
strokeDashoffset={-offset}
/>
);
offset += len;
return el;
})}
</g>
<text x="90" y="84" textAnchor="middle" className="donut-total">
{total}
</text>
<text x="90" y="106" textAnchor="middle" className="donut-label">
设备总数
</text>
</svg>
<ul className="donut-legend">
{dist.map((d, i) => (
<li key={d.name}>
<span className="legend-dot" style={{ background: PALETTE[i] }} />
{d.name}
<strong>{d.value}</strong>
</li>
))}
</ul>
</div>
);
};
function buildLinePath(trend: { date: string; total: number }[]): {
line: string;
area: string;
} {
const W = 560;
const H = 220;
const padX = 30;
const padY = 24;
if (trend.length === 0) return { line: '', area: '' };
const max = Math.max(1, ...trend.map((t) => t.total));
const stepX = (W - padX * 2) / Math.max(1, trend.length - 1);
const pts = trend.map((t, i) => {
const x = padX + i * stepX;
const y = padY + (1 - t.total / max) * (H - padY * 2);
return [x, y] as const;
});
const line = pts
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`)
.join(' ');
const area = `${line} L${pts[pts.length - 1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`;
return { line, area };
}
export default Overview;
@@ -0,0 +1,14 @@
.content {
padding: 8px;
}
.avatarImg {
width: 100%;
}
.userInfoEditCard {
margin-bottom: 8px;
}
.resetBtn {
margin-left: 1rem;
}
+275
View File
@@ -0,0 +1,275 @@
import services from '@/services';
import type ServiceTypes from '@/services/serviceTypes';
import {
Button,
Card,
Form,
Input,
message,
Spin,
Upload,
type UploadProps,
} from 'antd';
import { useForm } from 'antd/es/form/Form';
import { observer } from 'mobx-react';
import { type FC, useEffect, useState } from 'react';
import Styles from './index.module.less';
import ImgCrop from 'antd-img-crop';
import { type UploadFile } from 'antd/es/upload';
import mobxStore from '@/mobxStore';
import constants from '@/constants';
import CryptoJS from 'crypto-js';
const getBase64 = async (file: File): Promise<string> => {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(String(reader.result));
};
reader.onerror = (error) => {
reject(error);
};
});
};
const UserInfo: FC = () => {
const [data, setData] =
useState<ServiceTypes['GET /api/user/getUserInfo']['res']['data']>();
const [fetchLoading, setFetchLoading] = useState(false);
type UserInfoForm = ServiceTypes['GET /api/user/getUserInfo']['res']['data'];
const [userInfoForm] = useForm<UserInfoForm>();
interface PasswordForm {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
const [passwordForm] = useForm<PasswordForm>();
const [imgFileList, setImgFileList] = useState<UploadFile[]>([]);
const fetchData = async () => {
try {
setFetchLoading(true);
const res = await services['GET /api/user/getUserInfo']();
setData(res.data);
userInfoForm.setFieldsValue(res.data);
setImgFileList([
{
name: res.data.username,
url: res.data.avatarURL,
uid: res.data.username,
},
]);
mobxStore.user.setUserInfo(res.data);
} catch (error) {
message.error(String(error));
console.error(error);
} finally {
setFetchLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleAvatarChange: UploadProps['onChange'] = async ({ fileList }) => {
setImgFileList(fileList);
};
const handleUserInfoSubmit = async (values: UserInfoForm) => {
try {
const avatarURL =
(imgFileList[0]?.originFileObj != null
? await getBase64(imgFileList[0].originFileObj)
: data?.avatarURL) ?? '';
await services['POST /api/user/updateUserInfo']({
...values,
avatarURL,
});
message.success('修改成功');
fetchData();
} catch (error) {
message.error(String(error));
console.error(error);
}
};
const handlePasswordSubmit = async (values: PasswordForm) => {
try {
await services['POST /api/user/updatePassword']({
oldPassword: CryptoJS.HmacSHA256(
values.oldPassword,
constants.SHA256KEY,
).toString(CryptoJS.enc.Base64),
newPassword: CryptoJS.HmacSHA256(
values.newPassword,
constants.SHA256KEY,
).toString(CryptoJS.enc.Base64),
});
message.success('修改成功');
passwordForm.resetFields();
} catch (error) {
message.error(String(error));
console.error(error);
}
};
return (
<Spin spinning={fetchLoading}>
<div className={Styles.content}>
<Card title="修改个人信息" className={Styles.userInfoEditCard}>
<Form
form={userInfoForm}
onFinish={handleUserInfoSubmit}
onReset={(e) => {
e.preventDefault();
fetchData();
}}
labelCol={{ span: 2 }}
// labelAlign="left"
wrapperCol={{ span: 8 }}
>
<Form.Item label="头像">
<ImgCrop rotationSlider showReset cropShape="round">
<Upload
accept="image/*"
listType="picture-circle"
showUploadList={{
showRemoveIcon: true,
showPreviewIcon: false,
}}
customRequest={({ onSuccess }) => {
setTimeout(() => {
onSuccess?.('ok');
}, 0);
}}
onRemove={() => {
setImgFileList([]);
}}
onChange={handleAvatarChange}
fileList={imgFileList}
maxCount={1}
>
<div>
<div style={{ fontSize: 'large' }}>+</div>
<div>上传</div>
</div>
</Upload>
</ImgCrop>
</Form.Item>
<Form.Item
label="账号"
name="username"
rules={[{ required: true }]}
>
<Input disabled />
</Form.Item>
<Form.Item
label="昵称"
name="nickname"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true },
{ type: 'email', message: '请输入正确的邮箱' },
]}
>
<Input type="email" />
</Form.Item>
<Form.Item
label="手机号"
name="tel"
rules={[
{ required: true },
{ pattern: /^1[3456789]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input type="tel" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 2 }}>
<Button type="primary" htmlType="submit">
保存
</Button>
<Button type="link" htmlType="reset" className={Styles.resetBtn}>
重置
</Button>
</Form.Item>
</Form>
</Card>
<Card title="修改密码" className={Styles.passwordEditCard}>
<Form
form={passwordForm}
onFinish={handlePasswordSubmit}
labelCol={{ span: 2 }}
// labelAlign="left"
wrapperCol={{ span: 8 }}
>
<Form.Item
label="原密码"
name="oldPassword"
rules={[{ required: true }]}
>
<Input.Password type="password" />
</Form.Item>
<Form.Item
label="新密码"
name="newPassword"
rules={[{ required: true }]}
>
<Input.Password type="password" />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={['newPassword']}
rules={[
{ required: true },
({ getFieldValue }) => ({
async validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
await Promise.resolve();
return;
}
return await Promise.reject(
new Error('两次输入的密码不一致'),
);
},
}),
]}
hasFeedback
>
<Input.Password type="password" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 2 }}>
<Button type="primary" htmlType="submit">
保存
</Button>
<Button type="link" htmlType="reset" className={Styles.resetBtn}>
重置
</Button>
</Form.Item>
</Form>
</Card>
</div>
</Spin>
);
};
export default observer(UserInfo);
+66
View File
@@ -0,0 +1,66 @@
import mobxStore from '@/mobxStore';
import type ServiceTypes from './serviceTypes';
import constants from '@/constants';
const fetchData = async (
api: string,
_data?: Record<string, any>,
): Promise<any> => {
const [method, url] = api.split(' ');
const Authorization = mobxStore.user.getToken();
let res: Response;
const data: Record<string, any> = {};
_data &&
Object.keys(_data).forEach((key) => {
if (_data[key] !== undefined && _data[key] !== null) {
data[key] = _data[key];
}
});
if (method === 'GET') {
res = await fetch(
`${constants.FETCH_ROOT}${url}${
data ? '?' + new URLSearchParams(data).toString() : ''
}`,
{
headers: { Authorization },
method,
},
);
} else {
res = await fetch(`${constants.FETCH_ROOT}${url}`, {
headers: {
Authorization,
'Content-Type': 'application/json; charset=utf-8',
},
method,
body: JSON.stringify(data),
});
}
if (res.status === 401) {
mobxStore.user.logoff();
throw new Error('请重新登录');
}
if (res.ok) {
const result = await res.json();
if (result.success) return result;
throw new Error(result.message || res.statusText);
} else {
throw new Error(res.statusText);
}
};
export default new Proxy(
{},
{
get: (_t, p) => {
return async (data: any) => await fetchData(p as string, data);
},
},
) as {
[api in keyof ServiceTypes]: (
data: ServiceTypes[api]['req'],
) => Promise<ServiceTypes[api]['res']>;
};
+3
View File
@@ -0,0 +1,3 @@
import { FetchTypes as ServiceTypes } from '../../../backend/src/types/fetchTypes';
export default ServiceTypes;
+452
View File
@@ -0,0 +1,452 @@
:root {
--panel-bg: rgba(8, 18, 38, 0.68);
--panel-bg-strong: rgba(8, 20, 44, 0.88);
--panel-border: rgba(103, 232, 249, 0.24);
--panel-border-strong: rgba(34, 211, 238, 0.72);
--panel-radius: 18px;
--panel-blur: 18px;
--panel-shadow: 0 18px 50px rgba(2, 8, 23, 0.48);
--glow-cyan: #22d3ee;
--glow-blue: #3b82f6;
--glow-violet: #8b5cf6;
--text-primary: #e6f7ff;
--text-secondary: rgba(226, 242, 255, 0.68);
--grid-line: rgba(56, 189, 248, 0.08);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(34, 211, 238, 0.2), transparent 30%),
radial-gradient(circle at 88% 2%, rgba(139, 92, 246, 0.2), transparent 32%),
linear-gradient(135deg, #020617, #071120 48%, #0f172a);
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
}
body::before {
z-index: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(to bottom, black, transparent 88%);
}
body::after {
z-index: 0;
background: radial-gradient(circle at 50% 78%, rgba(59, 130, 246, 0.14), transparent 36%);
animation: aurora 28s ease-in-out infinite alternate;
}
@keyframes aurora {
from { transform: translate3d(-1.5%, 0, 0) scale(1); }
to { transform: translate3d(1.5%, -2%, 1.04); }
}
.ant-layout,
.ant-layout-has-sider {
background: transparent !important;
}
.glass-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 20px;
background: rgba(4, 13, 30, 0.72) !important;
border-bottom: 1px solid rgba(103, 232, 249, 0.2);
box-shadow: 0 12px 34px rgba(2, 8, 23, 0.38);
backdrop-filter: blur(18px);
}
.brand-block {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-block img {
width: 40px;
height: 40px;
object-fit: contain;
filter: drop-shadow(0 0 12px rgba(34, 211, 238, 0.75));
}
.brand-title {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.04em;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 0 18px rgba(34, 211, 238, 0.38);
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border: 1px solid rgba(103, 232, 249, 0.22);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.user-chip:hover {
border-color: rgba(34, 211, 238, 0.7);
box-shadow: 0 0 22px rgba(34, 211, 238, 0.16);
}
.user-chip .ant-avatar {
border: 1px solid rgba(103, 232, 249, 0.45);
}
.user-name {
color: var(--text-primary);
font-size: 14px;
}
.glass-sider {
background: rgba(4, 13, 30, 0.58) !important;
border-right: 1px solid rgba(103, 232, 249, 0.16);
backdrop-filter: blur(16px);
}
.glass-sider .ant-menu {
padding: 10px 8px;
background: transparent !important;
border-inline-end: 0 !important;
}
.glass-sider .ant-menu-item,
.glass-sider .ant-menu-submenu-title {
height: 42px;
margin-inline: 0;
border-radius: 12px;
color: rgba(230, 247, 255, 0.76);
}
.glass-sider .ant-menu-item:hover,
.glass-sider .ant-menu-submenu-title:hover {
color: #a5f3fc !important;
background: rgba(34, 211, 238, 0.1) !important;
}
.glass-sider .ant-menu-item-selected {
color: #ecfeff !important;
background: linear-gradient(90deg, rgba(34, 211, 238, 0.28), rgba(139, 92, 246, 0.18)) !important;
box-shadow: inset 0 0 0 1px rgba(103, 232, 249, 0.35), 0 0 20px rgba(34, 211, 238, 0.14);
}
.glass-sider .ant-menu-submenu-selected > .ant-menu-submenu-title {
color: #a5f3fc;
}
.panel-content {
position: relative;
z-index: 1;
height: calc(100vh - 64px - 36px);
overflow: auto;
background: transparent;
}
.glass-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 36px;
padding: 0 16px;
color: var(--text-secondary);
background: rgba(4, 13, 30, 0.7);
border-top: 1px solid rgba(103, 232, 249, 0.18);
backdrop-filter: blur(16px);
}
.glass-footer a {
color: #67e8f9;
text-decoration: none;
}
.glass-footer a:hover {
color: #cffafe;
text-shadow: 0 0 12px rgba(34, 211, 238, 0.75);
}
.footer-group {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.ant-card {
color: var(--text-primary);
border: 1px solid var(--panel-border);
border-radius: var(--panel-radius);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.08), var(--panel-bg) 42%, rgba(255, 255, 255, 0.03));
box-shadow: var(--panel-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(var(--panel-blur));
overflow: hidden;
}
.ant-card-head {
min-height: 48px;
color: var(--text-primary);
background: rgba(34, 211, 238, 0.06);
border-bottom: 1px solid rgba(103, 232, 249, 0.16);
}
.ant-card-head-title,
.ant-card-extra {
color: var(--text-primary);
}
.ant-card-body {
position: relative;
z-index: 1;
}
.ant-table {
color: var(--text-primary);
background: transparent;
}
.ant-table-thead > tr > th {
color: #cffafe !important;
background: rgba(34, 211, 238, 0.1) !important;
border-bottom-color: rgba(103, 232, 249, 0.2) !important;
}
.ant-table-tbody > tr > td,
.ant-table-summary > tr > th,
.ant-table-summary > tr > td {
color: var(--text-primary) !important;
background: transparent !important;
border-bottom-color: rgba(103, 232, 249, 0.12) !important;
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(34, 211, 238, 0.08) !important;
}
.ant-pagination .ant-pagination-item,
.ant-pagination .ant-pagination-prev .ant-pagination-item-link,
.ant-pagination .ant-pagination-next .ant-pagination-item-link {
color: #a5f3fc;
border-color: rgba(103, 232, 249, 0.25);
background: rgba(255, 255, 255, 0.04);
}
.ant-pagination .ant-pagination-item-active {
border-color: var(--panel-border-strong);
box-shadow: 0 0 14px rgba(34, 211, 238, 0.16);
}
.ant-pagination .ant-pagination-item-ellipsis,
.ant-pagination .ant-pagination-jump-prev .ant-pagination-item-container .ant-pagination-item-link-icon,
.ant-pagination .ant-pagination-jump-next .ant-pagination-item-container .ant-pagination-item-link-icon {
color: #a5f3fc;
text-shadow: none;
}
.ant-pagination .ant-pagination-disabled .ant-pagination-item-link,
.ant-pagination .ant-pagination-disabled .ant-pagination-item-link .anticon {
color: rgba(165, 243, 252, 0.45);
}
.ant-descriptions .ant-descriptions-item-label,
.ant-descriptions .ant-descriptions-item-content {
color: var(--text-primary);
background: transparent;
}
.ant-descriptions .ant-descriptions-view,
.ant-descriptions .ant-descriptions-row > th,
.ant-descriptions .ant-descriptions-row > td {
border-color: rgba(103, 232, 249, 0.14);
}
.ant-btn-primary {
border: 0;
background: linear-gradient(135deg, #0891b2, #2563eb 52%, #7c3aed);
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.28);
}
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background: linear-gradient(135deg, #06b6d4, #3b82f6 52%, #8b5cf6) !important;
box-shadow: 0 0 26px rgba(34, 211, 238, 0.26) !important;
}
.ant-btn-default {
color: #a5f3fc;
border-color: rgba(103, 232, 249, 0.28);
background: rgba(255, 255, 255, 0.04);
}
.ant-btn-default:hover {
color: #cffafe !important;
border-color: rgba(34, 211, 238, 0.7) !important;
}
.ant-tag {
border-color: rgba(103, 232, 249, 0.28);
background: rgba(34, 211, 238, 0.1);
}
.ant-modal-content,
.ant-drawer-content {
color: var(--text-primary);
border: 1px solid var(--panel-border);
background: var(--panel-bg-strong);
backdrop-filter: blur(20px);
}
.ant-modal-header,
.ant-drawer-header,
.ant-modal-footer {
color: var(--text-primary);
background: transparent;
}
.ant-modal-title,
.ant-drawer-title,
.ant-drawer-header-title,
.ant-drawer-close,
.ant-modal-close {
color: var(--text-primary);
}
.ant-select-dropdown {
color: var(--text-primary);
border: 1px solid var(--panel-border);
background: var(--panel-bg-strong);
backdrop-filter: blur(18px);
}
.ant-select-item {
color: var(--text-primary);
}
.ant-select-item-option-active,
.ant-select-item-option-selected {
background: rgba(34, 211, 238, 0.16) !important;
}
.ant-spin-dot-item {
background-color: #67e8f9;
}
:focus-visible {
outline: 2px solid rgba(103, 232, 249, 0.85);
outline-offset: 2px;
}
@media (max-width: 768px) {
.glass-header {
padding: 0 12px;
}
.brand-title {
display: none;
}
.footer-group {
gap: 8px;
}
.footer-hide-sm {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
/* Dark-theme fallback: prevent component library default white surfaces from showing inside dark panels. */
#root .ant-card,
#root .ant-table,
#root .ant-descriptions-view,
#root .ant-modal-content,
#root .ant-drawer-content,
#root .ant-select-dropdown,
#root .ant-picker-dropdown .ant-picker-panel-container,
#root .ant-popover-inner,
#root .ant-collapse,
#root .ant-list,
#root .ant-pagination .ant-pagination-item,
#root .ant-pagination .ant-pagination-item-link {
color: #e6f7ff;
}
#root .ant-table,
#root .ant-descriptions-view,
#root .ant-modal-content,
#root .ant-drawer-content,
#root .ant-select-dropdown,
#root .ant-picker-dropdown .ant-picker-panel-container,
#root .ant-popover-inner,
#root .ant-collapse {
background-color: rgba(7, 18, 39, 0.9);
}
#root .ant-table-cell {
background-color: transparent;
color: #e6f7ff;
}
#root .ant-picker,
#root .ant-input,
#root .ant-input-affix-wrapper,
#root .ant-select-selector,
#root .ant-input-number,
#root .ant-btn-default {
color: #e6f7ff;
}
#root .ant-descriptions-item-label,
#root .ant-descriptions-item-content,
#root .ant-form-item,
#root .ant-list-item,
#root .ant-select-selection-item,
#root .ant-select-selection-placeholder,
#root .ant-picker-input > input {
color: inherit;
}
#root .ant-empty-description,
#root .ant-typography,
#root .ant-modal .ant-modal-title,
#root .ant-drawer .ant-drawer-title {
color: #e6f7ff;
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"noEmit": true,
"skipLibCheck": true
},
"extends": "./src/.umi/tsconfig.json"
}
+1
View File
@@ -0,0 +1 @@
import 'umi/typings';