feat: 完善智慧停车综合管理平台源码
This commit is contained in:
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
title: 'Smart Parking Management Platform',
|
||||
helloWorld: 'Hello World',
|
||||
switchLang: 'Switch language',
|
||||
goLogin: 'Go to login',
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
title: '智慧停车管理平台',
|
||||
'/campusState': '园区态势',
|
||||
'/login': '登录',
|
||||
'/monitScreen': '监控大屏',
|
||||
'/alarms': '异常报警',
|
||||
'/ai-chat': 'AI对话',
|
||||
'/userInfo': '个人信息维护',
|
||||
'/admin/mapManage': '园区地图管理',
|
||||
'/admin/camerasManage': '摄像头管理',
|
||||
'/admin/alarmRulesManage': '报警规则管理',
|
||||
'/admin/usersManage': '系统用户管理',
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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']>;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FetchTypes as ServiceTypes } from '../../../backend/src/types/fetchTypes';
|
||||
|
||||
export default ServiceTypes;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user