feat: 添加请求参数验证中间件和设计令牌Hook
feat(validation): 为机房、机柜和设备路由添加Joi验证中间件 feat(hooks): 创建useDesignTokens Hook集中管理主题配置 feat(models): 为耗材模型添加乐观锁version字段和updatedAt索引 feat(3d): 增强3D场景和设备模型视觉效果与交互 refactor: 移除数据备份相关功能并优化代码结构 fix: 修复前端安全日志和密码加密工具 chore: 更新依赖并添加axios和joi库 docs: 更新注释和文档说明 style: 改进代码格式和命名一致性
This commit is contained in:
+4
-151
@@ -5,6 +5,7 @@ import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, us
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { ConfigProvider, useConfig } from './context/ConfigContext';
|
||||
import { Scene3DProvider } from './context/Scene3DContext';
|
||||
import { useDesignTokens } from './hooks/useDesignTokens';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -59,55 +60,6 @@ const AuthLoading = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
light: '#8b9ff0'
|
||||
},
|
||||
success: { main: '#10b981' },
|
||||
warning: { main: '#f59e0b' },
|
||||
error: { main: '#ef4444' },
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
inverse: '#ffffff'
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
dark: '#1e293b'
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0'
|
||||
},
|
||||
sidebar: {
|
||||
bg: '#ffffff',
|
||||
bgHover: 'rgba(102, 126, 234, 0.08)',
|
||||
bgActive: 'rgba(102, 126, 234, 0.15)',
|
||||
text: '#475569',
|
||||
textHover: '#667eea',
|
||||
textActive: '#667eea',
|
||||
border: '#e2e8f0'
|
||||
}
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px'
|
||||
},
|
||||
spacing: {
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px'
|
||||
}
|
||||
};
|
||||
|
||||
const PrivateRoute = ({ children }) => {
|
||||
const { token, initialized, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
@@ -133,9 +85,10 @@ const AppLayout = ({ children }) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState('dashboard');
|
||||
const { user, logout } = useAuth();
|
||||
const { config } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { config } = useConfig();
|
||||
const designTokens = useDesignTokens();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -155,56 +108,6 @@ const AppLayout = ({ children }) => {
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
// 动态设计令牌
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: config.primary_color || '#667eea',
|
||||
gradient: `linear-gradient(135deg, ${config.primary_color || '#667eea'} 0%, ${config.secondary_color || '#764ba2'} 100%)`,
|
||||
light: '#8b9ff0'
|
||||
},
|
||||
success: { main: '#10b981' },
|
||||
warning: { main: '#f59e0b' },
|
||||
error: { main: '#ef4444' },
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
inverse: '#ffffff'
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
dark: '#1e293b'
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0'
|
||||
},
|
||||
sidebar: {
|
||||
bg: '#ffffff',
|
||||
bgHover: 'rgba(102, 126, 234, 0.08)',
|
||||
bgActive: 'rgba(102, 126, 234, 0.15)',
|
||||
text: '#475569',
|
||||
textHover: config.primary_color || '#667eea',
|
||||
textActive: config.primary_color || '#667eea',
|
||||
border: '#e2e8f0'
|
||||
}
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px'
|
||||
},
|
||||
spacing: {
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px'
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
@@ -518,57 +421,7 @@ const AppLayout = ({ children }) => {
|
||||
};
|
||||
|
||||
const ThemeConfig = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
// 动态设计令牌
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: config.primary_color || '#667eea',
|
||||
gradient: `linear-gradient(135deg, ${config.primary_color || '#667eea'} 0%, ${config.secondary_color || '#764ba2'} 100%)`,
|
||||
light: '#8b9ff0'
|
||||
},
|
||||
success: { main: '#10b981' },
|
||||
warning: { main: '#f59e0b' },
|
||||
error: { main: '#ef4444' },
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
inverse: '#ffffff'
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
dark: '#1e293b'
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0'
|
||||
},
|
||||
sidebar: {
|
||||
bg: '#ffffff',
|
||||
bgHover: 'rgba(102, 126, 234, 0.08)',
|
||||
bgActive: 'rgba(102, 126, 234, 0.15)',
|
||||
text: '#475569',
|
||||
textHover: config.primary_color || '#667eea',
|
||||
textActive: config.primary_color || '#667eea',
|
||||
border: '#e2e8f0'
|
||||
}
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px'
|
||||
},
|
||||
spacing: {
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px'
|
||||
}
|
||||
};
|
||||
const designTokens = useDesignTokens();
|
||||
|
||||
return (
|
||||
<AntdConfigProvider theme={{ token: designTokens }}>
|
||||
|
||||
@@ -15,9 +15,20 @@ api.interceptors.request.use(
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('[API] No token found in localStorage');
|
||||
}
|
||||
|
||||
// 开发环境下安全日志:过滤敏感字段
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'confirmPassword'];
|
||||
const safeData = config.data ? { ...config.data } : null;
|
||||
if (safeData) {
|
||||
sensitiveFields.forEach(field => {
|
||||
if (safeData[field]) safeData[field] = '***';
|
||||
});
|
||||
}
|
||||
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, safeData || '');
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -179,7 +179,8 @@ const InstancedStatusLights = ({ count, positions, colors: statusColors, zOffset
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) {
|
||||
meshRef.current.dispose();
|
||||
meshRef.current.geometry?.dispose();
|
||||
meshRef.current.material?.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -219,8 +220,14 @@ const InstancedDriveBays = ({ count, positions, color, hasDetail = true }) => {
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (detailRef.current) detailRef.current.dispose();
|
||||
if (meshRef.current) {
|
||||
meshRef.current.geometry?.dispose();
|
||||
meshRef.current.material?.dispose();
|
||||
}
|
||||
if (detailRef.current) {
|
||||
detailRef.current.geometry?.dispose();
|
||||
detailRef.current.material?.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -265,8 +272,14 @@ const InstancedStorageBays = ({ count, positions, color }) => {
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (detailRef.current) detailRef.current.dispose();
|
||||
if (meshRef.current) {
|
||||
meshRef.current.geometry?.dispose();
|
||||
meshRef.current.material?.dispose();
|
||||
}
|
||||
if (detailRef.current) {
|
||||
detailRef.current.geometry?.dispose();
|
||||
detailRef.current.material?.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -335,10 +348,22 @@ const InstancedRJ45Ports = ({ count, positions, statuses, frontZ }) => {
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (innerRef.current) innerRef.current.dispose();
|
||||
if (tabRef.current) tabRef.current.dispose();
|
||||
if (ledRef.current) ledRef.current.dispose();
|
||||
if (meshRef.current) {
|
||||
meshRef.current.geometry?.dispose();
|
||||
meshRef.current.material?.dispose();
|
||||
}
|
||||
if (innerRef.current) {
|
||||
innerRef.current.geometry?.dispose();
|
||||
innerRef.current.material?.dispose();
|
||||
}
|
||||
if (tabRef.current) {
|
||||
tabRef.current.geometry?.dispose();
|
||||
tabRef.current.material?.dispose();
|
||||
}
|
||||
if (ledRef.current) {
|
||||
ledRef.current.geometry?.dispose();
|
||||
ledRef.current.material?.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -421,10 +446,22 @@ const InstancedSFPports = ({ count, positions, statuses, frontZ }) => {
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (innerRef.current) innerRef.current.dispose();
|
||||
if (connectorRef.current) connectorRef.current.dispose();
|
||||
if (ledRef.current) ledRef.current.dispose();
|
||||
if (meshRef.current) {
|
||||
meshRef.current.geometry?.dispose();
|
||||
meshRef.current.material?.dispose();
|
||||
}
|
||||
if (innerRef.current) {
|
||||
innerRef.current.geometry?.dispose();
|
||||
innerRef.current.material?.dispose();
|
||||
}
|
||||
if (connectorRef.current) {
|
||||
connectorRef.current.geometry?.dispose();
|
||||
connectorRef.current.material?.dispose();
|
||||
}
|
||||
if (ledRef.current) {
|
||||
ledRef.current.geometry?.dispose();
|
||||
ledRef.current.material?.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -443,10 +480,16 @@ const FirewallFace = ({ device, height, frontZ, isSelected }) => {
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* 防火墙左侧红色标识条 */}
|
||||
<mesh position={[-halfWidth + 0.02, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.01, height, 0.003]} />
|
||||
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||
<meshStandardMaterial color="#ef4444" emissive="#ef4444" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
{/* 防火墙主面板边框 */}
|
||||
<mesh position={[-halfWidth + 0.035, 0, frontZ + 0.005]}>
|
||||
<boxGeometry args={[0.005, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#dc2626" />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.46, height - 0.004, 0.002]} />
|
||||
@@ -631,10 +674,16 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* 服务器主面板 - 深蓝色 */}
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
|
||||
</mesh>
|
||||
{/* 服务器左侧标识条 - 亮蓝色 */}
|
||||
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||
<meshStandardMaterial color="#3b82f6" emissive="#3b82f6" emissiveIntensity={0.2} />
|
||||
</mesh>
|
||||
|
||||
<group position={[-0.18, 0, frontZ + 0.006]}>
|
||||
<mesh position={[-0.02, 0, 0]}>
|
||||
@@ -710,11 +759,17 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* 存储设备主面板 - 深紫色 */}
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#0f172a" roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* 存储设备左侧标识条 - 紫色 */}
|
||||
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||
<meshStandardMaterial color="#8b5cf6" emissive="#8b5cf6" emissiveIntensity={0.2} />
|
||||
</mesh>
|
||||
|
||||
<group position={[0, 0, frontZ + 0.008]}>
|
||||
<InstancedStorageBays
|
||||
count={bayPositions.length}
|
||||
@@ -769,9 +824,20 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* 交换机主面板 - 深绿色背景突出显示 */}
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#334155" roughness={0.6} metalness={0.4} />
|
||||
<meshStandardMaterial color="#064e3b" roughness={0.5} metalness={0.5} />
|
||||
</mesh>
|
||||
{/* 交换机左侧标识条 - 亮绿色 */}
|
||||
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||
<meshStandardMaterial color="#10b981" emissive="#10b981" emissiveIntensity={0.2} />
|
||||
</mesh>
|
||||
{/* 交换机类型标识 */}
|
||||
<mesh position={[-0.19, height/2 - 0.015, frontZ + 0.007]}>
|
||||
<boxGeometry args={[0.025, 0.012, 0.002]} />
|
||||
<meshStandardMaterial color="#065f46" />
|
||||
</mesh>
|
||||
|
||||
<group position={[-0.19, 0, frontZ + 0.006]}>
|
||||
@@ -969,13 +1035,18 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
return (
|
||||
<group>
|
||||
{/* 默认面板纹理 */}
|
||||
<mesh position={[0, 0, frontZ + 0.005]}>
|
||||
<boxGeometry args={[0.7, height - gap - 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" roughness={0.6} />
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#374151" roughness={0.6} metalness={0.4} />
|
||||
</mesh>
|
||||
{/* 左侧灰色标识条 */}
|
||||
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||
<meshStandardMaterial color="#6b7280" emissive="#6b7280" emissiveIntensity={0.1} />
|
||||
</mesh>
|
||||
{/* 装饰线 */}
|
||||
<mesh position={[0, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.6, 0.005, 0.001]} />
|
||||
<mesh position={[0, 0, frontZ + 0.007]}>
|
||||
<boxGeometry args={[0.4, 0.003, 0.001]} />
|
||||
<meshStandardMaterial color={deviceColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense, useMemo, useRef, useEffect } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import React, { Suspense, useMemo, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
|
||||
const envMapUrl = '/assets/3d/env.hdr';
|
||||
import RackModel from './RackModel';
|
||||
@@ -10,16 +10,50 @@ import * as THREE from 'three';
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
// 检测是否为小屏幕
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
// 移动端或小屏幕使用 dpr=1,桌面端使用 dpr=[1, 1.5]
|
||||
const deviceDpr = isMobile || isSmallScreen ? 1 : [1, 1.5];
|
||||
// 移动端或小屏幕使用 dpr=1,桌面端使用 dpr=[1, 2] 提升清晰度
|
||||
const deviceDpr = isMobile || isSmallScreen ? 1 : [1, 2];
|
||||
|
||||
// 创建全局 ref 用于外部访问 controls
|
||||
const controlsRefGlobal = { current: null };
|
||||
|
||||
// 内部组件用于处理 OrbitControls
|
||||
const Controls = ({ rack }) => {
|
||||
const Controls = ({ rack, onControlsReady }) => {
|
||||
const controlsRef = useRef();
|
||||
const { camera } = useThree();
|
||||
// 机柜中心点(中轴线)
|
||||
const targetY = (rack?.height || 45) * 0.04445 / 2 + 0.5;
|
||||
const rackHeight = rack?.height || 45;
|
||||
const targetY = rackHeight * 0.04445 / 2 + 0.5;
|
||||
const fixedTarget = useMemo(() => new THREE.Vector3(0, targetY, 0), [targetY]);
|
||||
|
||||
// 根据机柜高度计算合适的相机距离限制
|
||||
const minDistance = useMemo(() => Math.max(1.5, rackHeight * 0.04445 * 0.3), [rackHeight]);
|
||||
const maxDistance = useMemo(() => Math.max(8, rackHeight * 0.04445 * 1.5), [rackHeight]);
|
||||
|
||||
// 保存相机初始位置用于重置
|
||||
const initialCameraPosition = useMemo(() => {
|
||||
const rackHeightMeters = rackHeight * 0.04445;
|
||||
const baseHeight = 2;
|
||||
const heightFactor = rackHeightMeters * 0.6;
|
||||
const distance = Math.max(3, rackHeightMeters * 1.2);
|
||||
return new THREE.Vector3(distance * 0.7, baseHeight + heightFactor * 0.3, distance);
|
||||
}, [rackHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlsRef.current) {
|
||||
controlsRefGlobal.current = controlsRef.current;
|
||||
if (onControlsReady) {
|
||||
onControlsReady({
|
||||
reset: () => {
|
||||
// 重置相机位置
|
||||
camera.position.copy(initialCameraPosition);
|
||||
controlsRef.current.target.copy(fixedTarget);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [controlsRef.current, camera, initialCameraPosition, fixedTarget, onControlsReady]);
|
||||
|
||||
useFrame(() => {
|
||||
if (controlsRef.current) {
|
||||
// 强制保持 target 在机柜中轴线
|
||||
@@ -32,17 +66,19 @@ const Controls = ({ rack }) => {
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
makeDefault
|
||||
minPolarAngle={0}
|
||||
maxPolarAngle={Math.PI / 1.75}
|
||||
minPolarAngle={0.1}
|
||||
maxPolarAngle={Math.PI / 1.5}
|
||||
minAzimuthAngle={-Infinity}
|
||||
maxAzimuthAngle={Infinity}
|
||||
minDistance={minDistance}
|
||||
maxDistance={maxDistance}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
mouseButtons={{
|
||||
LEFT: 0, // 左键旋转
|
||||
MIDDLE: 0, // 中键禁用(避免平移改变旋转中心)
|
||||
RIGHT: 0 // 右键禁用
|
||||
MIDDLE: 1, // 中键平移
|
||||
RIGHT: 2 // 右键平移
|
||||
}}
|
||||
touches={{
|
||||
ONE: 1,
|
||||
@@ -52,7 +88,7 @@ const Controls = ({ rack }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }) => {
|
||||
const Scene = forwardRef(({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }, ref) => {
|
||||
// 从 Context 获取3D场景状态
|
||||
const {
|
||||
devices,
|
||||
@@ -60,6 +96,18 @@ const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeav
|
||||
deviceSlideEnabled
|
||||
} = useScene3D();
|
||||
|
||||
// 用于存储 controls API
|
||||
const controlsApiRef = useRef(null);
|
||||
|
||||
// 使用 useImperativeHandle 暴露重置方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetView: () => {
|
||||
if (controlsApiRef.current) {
|
||||
controlsApiRef.current.reset();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 使用 useMemo 稳定 props 引用
|
||||
const rackModelProps = useMemo(() => ({
|
||||
rack,
|
||||
@@ -72,47 +120,63 @@ const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeav
|
||||
deviceSlideEnabled
|
||||
}), [rack, devices, selectedDevice, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled]);
|
||||
|
||||
// 根据机柜高度动态计算相机初始位置
|
||||
const rackHeight = rack?.height || 45;
|
||||
const rackHeightMeters = rackHeight * 0.04445;
|
||||
// 相机位置:确保能完整看到机柜,高度随机柜高度调整
|
||||
const cameraPosition = useMemo(() => {
|
||||
const baseHeight = 2;
|
||||
const heightFactor = rackHeightMeters * 0.6;
|
||||
const distance = Math.max(3, rackHeightMeters * 1.2);
|
||||
return [distance * 0.7, baseHeight + heightFactor * 0.3, distance];
|
||||
}, [rackHeightMeters]);
|
||||
|
||||
// 相机目标点(机柜中心)
|
||||
const cameraTarget = useMemo(() => {
|
||||
return [0, rackHeightMeters / 2 + 0.5, 0];
|
||||
}, [rackHeightMeters]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: !isMobile, // 移动端关闭抗锯齿提升性能
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启alpha以支持透明背景
|
||||
powerPreference: 'high-performance'
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={[3, 2, 4]} fov={50} />
|
||||
|
||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-10}
|
||||
shadow-camera-right={10}
|
||||
shadow-camera-top={10}
|
||||
shadow-camera-bottom={-10}
|
||||
/>
|
||||
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
|
||||
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls rack={rack} />
|
||||
<Controls rack={rack} onControlsReady={(api) => { controlsApiRef.current = api; }} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Scene;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useConfig } from '../context/ConfigContext';
|
||||
|
||||
/**
|
||||
* 使用设计令牌 Hook
|
||||
* 集中管理主题配置,避免在多个组件中重复定义
|
||||
* @returns {Object} 设计令牌对象
|
||||
*/
|
||||
export const useDesignTokens = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
const designTokens = useMemo(() => {
|
||||
const primaryColor = config?.primary_color || '#667eea';
|
||||
const secondaryColor = config?.secondary_color || '#764ba2';
|
||||
|
||||
return {
|
||||
colors: {
|
||||
primary: {
|
||||
main: primaryColor,
|
||||
gradient: `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
|
||||
light: '#8b9ff0'
|
||||
},
|
||||
success: { main: '#10b981' },
|
||||
warning: { main: '#f59e0b' },
|
||||
error: { main: '#ef4444' },
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
inverse: '#ffffff'
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
dark: '#1e293b'
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0'
|
||||
},
|
||||
sidebar: {
|
||||
bg: '#ffffff',
|
||||
bgHover: `rgba(${hexToRgb(primaryColor)}, 0.08)`,
|
||||
bgActive: `rgba(${hexToRgb(primaryColor)}, 0.15)`,
|
||||
text: '#475569',
|
||||
textHover: primaryColor,
|
||||
textActive: primaryColor,
|
||||
border: '#e2e8f0'
|
||||
}
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px'
|
||||
},
|
||||
spacing: {
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px'
|
||||
}
|
||||
};
|
||||
}, [config?.primary_color, config?.secondary_color]);
|
||||
|
||||
return designTokens;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将十六进制颜色转换为RGB字符串
|
||||
* @param {string} hex - 十六进制颜色值
|
||||
* @returns {string} RGB字符串 (如: "102, 126, 234")
|
||||
*/
|
||||
function hexToRgb(hex) {
|
||||
// 移除 # 号
|
||||
const cleanHex = hex.replace('#', '');
|
||||
|
||||
// 处理简写格式 (如: #fff)
|
||||
const fullHex = cleanHex.length === 3
|
||||
? cleanHex.split('').map(c => c + c).join('')
|
||||
: cleanHex;
|
||||
|
||||
const r = parseInt(fullHex.substring(0, 2), 16);
|
||||
const g = parseInt(fullHex.substring(2, 4), 16);
|
||||
const b = parseInt(fullHex.substring(4, 6), 16);
|
||||
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
export default useDesignTokens;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Layout, Select, Card, Spin, message, Typography, Descriptions, Tag, Button, Space, Empty, Modal, Form, Input, InputNumber, DatePicker, Checkbox, Switch } from 'antd';
|
||||
import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||
import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -17,7 +17,10 @@ const { Option } = Select;
|
||||
|
||||
const Rack3DVisualization = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
// Scene 组件的 ref,用于调用重置视角方法
|
||||
const sceneRef = useRef(null);
|
||||
|
||||
// 使用 Scene3DContext 管理3D场景状态
|
||||
const {
|
||||
devices,
|
||||
@@ -465,16 +468,26 @@ const Rack3DVisualization = () => {
|
||||
<Option key={rack.rackId} value={rack.rackId}>{rack.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ReloadOutlined />}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { fetchRacks(); if(selectedRack) fetchDevices(selectedRack.rackId); }}
|
||||
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
|
||||
className="hover-bright"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => { if(sceneRef.current) sceneRef.current.resetView(); }}
|
||||
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
|
||||
className="hover-bright"
|
||||
>
|
||||
重置视角
|
||||
</Button>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -546,9 +559,10 @@ const Rack3DVisualization = () => {
|
||||
</div>
|
||||
) : selectedRack ? (
|
||||
<>
|
||||
<Scene
|
||||
rack={selectedRack}
|
||||
devices={devices}
|
||||
<Scene
|
||||
ref={sceneRef}
|
||||
rack={selectedRack}
|
||||
devices={devices}
|
||||
selectedDeviceId={selectedDevice?.deviceId || selectedDevice?.id}
|
||||
onDeviceClick={handleDeviceClick}
|
||||
onDeviceLeave={handleDeviceLeave}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, Form, Input, Switch, Select, Button, Card, Space, message, Modal, Table, Tag, Progress, Divider, Descriptions, Alert } from 'antd';
|
||||
import { SettingOutlined, GlobalOutlined, BgColorsOutlined, DatabaseOutlined, InfoCircleOutlined, CloudUploadOutlined, DeleteOutlined, ReloadOutlined, DownloadOutlined, SyncOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { Tabs, Form, Input, Switch, Select, Button, Card, Space, message, Modal, Tag, Divider, Descriptions, Alert } from 'antd';
|
||||
import { SettingOutlined, GlobalOutlined, BgColorsOutlined, InfoCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useConfig } from '../context/ConfigContext';
|
||||
|
||||
@@ -12,15 +12,12 @@ const SystemSettings = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settings, setSettings] = useState({});
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [backupList, setBackupList] = useState([]);
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const { reloadConfig } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchBackupList();
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
@@ -42,18 +39,6 @@ const SystemSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBackupList = async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/system-settings/backup/list');
|
||||
setBackupList(response.data.backups || []);
|
||||
} catch (error) {
|
||||
console.error('获取备份列表失败');
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/system-settings/system/info');
|
||||
@@ -85,66 +70,6 @@ const SystemSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认创建备份',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定要创建系统备份吗?这将导出所有设备、机柜、机房和耗材数据。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
message.loading('正在创建备份...', 0);
|
||||
const response = await axios.post('/api/system-settings/backup');
|
||||
message.destroy();
|
||||
message.success('备份创建成功');
|
||||
fetchBackupList();
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error('备份创建失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestoreBackup = (filename) => {
|
||||
Modal.confirm({
|
||||
title: '确认恢复备份',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定要恢复备份 "${filename}" 吗?当前数据将被覆盖,且此操作不可撤销。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
message.loading('正在恢复备份...', 0);
|
||||
await axios.post('/api/system-settings/backup/restore', { filename });
|
||||
message.destroy();
|
||||
message.success('恢复成功,请刷新页面查看最新数据');
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error('恢复备份失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBackup = (filename) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除备份',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定要删除备份 "${filename}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/system-settings/backup/${filename}`);
|
||||
message.success('删除成功');
|
||||
fetchBackupList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadBackup = (filename) => {
|
||||
window.open(`/api/system-settings/backup/download/${filename}`, '_blank');
|
||||
};
|
||||
|
||||
const handleResetSetting = (key) => {
|
||||
Modal.confirm({
|
||||
title: '确认重置',
|
||||
@@ -289,10 +214,6 @@ const SystemSettings = () => {
|
||||
{ value: 'false', label: '展开' },
|
||||
{ value: 'true', label: '折叠' }
|
||||
],
|
||||
auto_backup_enabled: [
|
||||
{ value: 'false', label: '关闭' },
|
||||
{ value: 'true', label: '开启' }
|
||||
]
|
||||
};
|
||||
return optionsMap[key] || [];
|
||||
};
|
||||
@@ -352,86 +273,7 @@ const SystemSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderBackupSettings = () => {
|
||||
const backupColumns = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
render: (text) => <code>{text}</code>
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
render: (size) => {
|
||||
const kb = size / 1024;
|
||||
return kb < 1024 ? `${kb.toFixed(2)} KB` : `${(kb / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date) => new Date(date).toLocaleString('zh-CN')
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => handleRestoreBackup(record.filename)}>恢复</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownloadBackup(record.filename)}>下载</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteBackup(record.filename)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 备份设置键列表
|
||||
const backupKeys = ['auto_backup_enabled', 'backup_interval', 'backup_retention', 'backup_path', 'last_backup_time', 'backup_count'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="自动备份设置" bordered={false} style={{ marginBottom: 16 }}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSaveSettings}>
|
||||
{backupKeys.map(key => {
|
||||
if (settings[key]) {
|
||||
return renderFormItem(key, settings[key]);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="手动备份管理" bordered={false}>
|
||||
<Alert
|
||||
message="数据安全提示"
|
||||
description="建议定期创建备份,并将备份文件保存到安全的位置。恢复备份前请确保已创建当前数据的备份。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<CloudUploadOutlined />} onClick={handleCreateBackup}>立即备份</Button>
|
||||
<Button icon={<SyncOutlined />} onClick={fetchBackupList}>刷新列表</Button>
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={backupList}
|
||||
columns={backupColumns}
|
||||
rowKey="filename"
|
||||
loading={backupLoading}
|
||||
pagination={{ pageSize: 5 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// 数据备份功能已移除
|
||||
|
||||
const renderAboutPage = () => {
|
||||
const aboutKeys = ['app_version', 'company_name', 'contact_email', 'contact_phone', 'company_address', 'system_description', 'privacy_policy', 'terms_of_service'];
|
||||
@@ -504,12 +346,6 @@ const SystemSettings = () => {
|
||||
>
|
||||
{renderAppearanceSettings()}
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><DatabaseOutlined /> 数据备份</span>}
|
||||
key="backup"
|
||||
>
|
||||
{renderBackupSettings()}
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><InfoCircleOutlined /> 关于</span>}
|
||||
key="about"
|
||||
|
||||
@@ -4,6 +4,17 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 安全地从 localStorage 获取用户信息
|
||||
const getUserFromStorage = () => {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user');
|
||||
return userStr ? JSON.parse(userStr) : {};
|
||||
} catch (e) {
|
||||
console.error('解析用户信息失败:', e);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
const { TextArea } = Input;
|
||||
@@ -412,7 +423,7 @@ function TicketManagement() {
|
||||
await axios.put(`/api/tickets/${editingTicket.ticketId}`, ticketData);
|
||||
message.success('工单更新成功');
|
||||
} else {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const user = getUserFromStorage();
|
||||
ticketData.reporterId = user.userId || localStorage.getItem('userId') || 'USER001';
|
||||
ticketData.reporterName = user.username || '系统用户';
|
||||
await axios.post('/api/tickets', ticketData);
|
||||
@@ -457,10 +468,11 @@ function TicketManagement() {
|
||||
|
||||
const handleProcessSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
const user = getUserFromStorage();
|
||||
await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, {
|
||||
...values,
|
||||
operatorId: localStorage.getItem('userId'),
|
||||
operatorName: JSON.parse(localStorage.getItem('user') || '{}').username
|
||||
operatorName: user.username
|
||||
});
|
||||
message.success('工单处理完成');
|
||||
setProcessingModalVisible(false);
|
||||
@@ -473,10 +485,11 @@ function TicketManagement() {
|
||||
|
||||
const handleStatusChange = useCallback(async (ticketId, newStatus) => {
|
||||
try {
|
||||
const user = getUserFromStorage();
|
||||
await axios.put(`/api/tickets/${ticketId}/status`, {
|
||||
status: newStatus,
|
||||
operatorId: localStorage.getItem('userId'),
|
||||
operatorName: JSON.parse(localStorage.getItem('user') || '{}').username
|
||||
operatorName: user.username
|
||||
});
|
||||
message.success('状态更新成功');
|
||||
fetchTickets();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 前端密码加密工具
|
||||
* 注意:这只是增加一层保护,真正的安全需要 HTTPS
|
||||
*/
|
||||
|
||||
/**
|
||||
* 使用 SHA-256 对密码进行哈希
|
||||
* @param {string} password - 明文密码
|
||||
* @returns {Promise<string>} - 返回十六进制哈希值
|
||||
*/
|
||||
export async function hashPassword(password) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 为登录请求准备密码(双重哈希:SHA-256 + 服务器 bcrypt)
|
||||
* @param {string} password - 明文密码
|
||||
* @returns {Promise<string>} - 哈希后的密码
|
||||
*/
|
||||
export async function preparePassword(password) {
|
||||
if (!password) return password;
|
||||
return await hashPassword(password);
|
||||
}
|
||||
Reference in New Issue
Block a user