Browse Source

feat: 添加用户登录日志功能,优化用户管理界面

norm
hyh 3 months ago
parent
commit
1a2c88c6d8
  1. 49
      src/CellularManagement.WebUI/package-lock.json
  2. 2
      src/CellularManagement.WebUI/package.json
  3. 1
      src/CellularManagement.WebUI/src/App.tsx
  4. 424
      src/CellularManagement.WebUI/src/components/auth/LoginForm.tsx
  5. 419
      src/CellularManagement.WebUI/src/components/auth/RegisterForm.tsx
  6. 52
      src/CellularManagement.WebUI/src/components/ui/AnimatedContainer.tsx
  7. 93
      src/CellularManagement.WebUI/src/components/ui/StatusSwitch.tsx
  8. 2
      src/CellularManagement.WebUI/src/components/ui/table.tsx
  9. 19
      src/CellularManagement.WebUI/src/constants/auth.ts
  10. 378
      src/CellularManagement.WebUI/src/pages/auth/ForgotPasswordPage.tsx
  11. 46
      src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx
  12. 45
      src/CellularManagement.WebUI/src/pages/auth/RegisterPage.tsx
  13. 55
      src/CellularManagement.WebUI/src/pages/dashboard/DashboardHome.tsx
  14. 5
      src/CellularManagement.WebUI/src/pages/dashboard/UserManagePage.tsx
  15. 82
      src/CellularManagement.WebUI/src/pages/users/UserTable.tsx
  16. 90
      src/CellularManagement.WebUI/src/pages/users/UsersView.tsx
  17. 33
      src/CellularManagement.WebUI/src/routes/AppRouter.tsx
  18. 54
      src/CellularManagement.WebUI/src/services/apiService.ts
  19. 44
      src/CellularManagement.WebUI/src/services/authService.ts
  20. 1
      src/CellularManagement.WebUI/src/services/userService.ts
  21. 2
      src/CellularManagement.WebUI/src/types/auth.ts

49
src/CellularManagement.WebUI/package-lock.json

@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
@ -37,6 +38,7 @@
"axios": "^1.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^12.12.1",
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -1490,6 +1492,14 @@
}
}
},
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
"integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
"peerDependencies": {
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@ -4109,6 +4119,32 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.12.1",
"resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.12.1.tgz",
"integrity": "sha512-PFw4/GCREHI2suK/NlPSUxd+x6Rkp80uQsfCRFSOQNrm5pZif7eGtmG1VaD/UF1fW9tRBy5AaS77StatB3OJDg==",
"dependencies": {
"motion-dom": "^12.12.1",
"motion-utils": "^12.12.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz",
@ -4873,6 +4909,19 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "12.12.1",
"resolved": "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.12.1.tgz",
"integrity": "sha512-GXq/uUbZBEiFFE+K1Z/sxdPdadMdfJ/jmBALDfIuHGi0NmtealLOfH9FqT+6aNPgVx8ilq0DtYmyQlo6Uj9LKQ==",
"dependencies": {
"motion-utils": "^12.12.1"
}
},
"node_modules/motion-utils": {
"version": "12.12.1",
"resolved": "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.12.1.tgz",
"integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",

2
src/CellularManagement.WebUI/package.json

@ -20,6 +20,7 @@
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
@ -39,6 +40,7 @@
"axios": "^1.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^12.12.1",
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

1
src/CellularManagement.WebUI/src/App.tsx

@ -6,6 +6,7 @@ import { ThemeContextProvider } from './contexts/ThemeContext';
import { ThemeProvider } from './contexts/ThemeProvider';
import { SettingsContextProvider } from './contexts/SettingsContext';
import { Toaster } from './components/ui/toaster';
import { ForgotPasswordPage } from '@/pages/auth/ForgotPasswordPage';
export function App() {
return (

424
src/CellularManagement.WebUI/src/components/auth/LoginForm.tsx

@ -1,80 +1,396 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { DEFAULT_CREDENTIALS } from '@/constants/auth';
import { useAuth } from '@/contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import { apiService } from '@/services/apiService';
import { toast } from '@/components/ui/use-toast';
interface LoginFormProps {
onSubmit: (username: string, password: string, rememberMe: boolean) => Promise<void>;
onSubmit: (username: string, password: string, rememberMe: boolean, loginType: 'account' | 'email', verificationCode?: string, captchaId?: string, captchaCode?: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [username, setUsername] = useState(DEFAULT_CREDENTIALS.username);
const [loginType, setLoginType] = useState<'account' | 'email'>('account');
const [account, setAccount] = useState(DEFAULT_CREDENTIALS.username);
const [email, setEmail] = useState('');
const [password, setPassword] = useState(DEFAULT_CREDENTIALS.password);
const [verificationCode, setVerificationCode] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const { isLoading, error } = useAuth();
const navigate = useNavigate();
const [captchaImage, setCaptchaImage] = useState<string>('');
const [captchaId, setCaptchaId] = useState<string>('');
const [captchaAvailable, setCaptchaAvailable] = useState(false);
const [captchaCode, setCaptchaCode] = useState('');
// 获取验证码
const fetchCaptcha = async () => {
try {
setCaptchaAvailable(false);
const result = await apiService.getCaptcha();
if (result.success && result.data) {
setCaptchaImage(result.data.imageBase64);
setCaptchaId(result.data.captchaId);
setCaptchaAvailable(true);
} else {
setCaptchaImage('');
setCaptchaId('');
setCaptchaAvailable(false);
toast({
title: '获取验证码失败',
description: result.message || '请点击验证码图片重试',
variant: 'destructive',
duration: 3000,
});
}
} catch (error) {
setCaptchaImage('');
setCaptchaId('');
setCaptchaAvailable(false);
toast({
title: '获取验证码失败',
description: '请点击验证码图片重试',
variant: 'destructive',
duration: 3000,
});
}
};
// 组件加载时获取验证码
useEffect(() => {
fetchCaptcha();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(username, password, rememberMe);
await onSubmit(
loginType === 'account' ? account : email,
password,
rememberMe,
loginType,
loginType === 'email' ? verificationCode : undefined,
captchaId,
captchaCode
);
};
const handleSendVerificationCode = async () => {
if (!captchaAvailable || !captchaCode) {
toast({
title: '请先完成验证码验证',
description: '请输入图片验证码',
variant: 'destructive',
duration: 3000,
});
return;
}
// TODO: 实现发送验证码的逻辑
console.log('发送验证码到邮箱:', email);
};
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium">
/
</label>
<input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="请输入用户名或邮箱"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="请输入密码"
disabled={isLoading}
/>
<div className="space-y-6">
{/* Tab 导航 */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button
type="button"
onClick={() => setLoginType('account')}
className={`
relative whitespace-nowrap py-4 px-1 text-sm font-medium transition-all duration-200
${loginType === 'account'
? 'text-primary'
: 'text-gray-500 hover:text-gray-700'
}
`}
>
{loginType === 'account' && (
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary transform transition-transform duration-200"></span>
)}
</button>
<button
type="button"
onClick={() => setLoginType('email')}
className={`
relative whitespace-nowrap py-4 px-1 text-sm font-medium transition-all duration-200
${loginType === 'email'
? 'text-primary'
: 'text-gray-500 hover:text-gray-700'
}
`}
>
{loginType === 'email' && (
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary transform transition-transform duration-200"></span>
)}
</button>
</nav>
</div>
<div className="flex items-center">
<input
id="rememberMe"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
disabled={isLoading}
/>
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-900">
</label>
</div>
{typeof error === 'string' && error && (
<div className="text-sm text-red-500">
{error}
{/* 表单内容 */}
<div className="pt-4">
<div className="space-y-5">
<div className="flex items-center group">
<label htmlFor="username" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</label>
<div className="flex-1 relative group/input">
{loginType === 'account' ? (
<input
id="username"
type="text"
required
value={account}
onChange={(e) => setAccount(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px] pr-20
group-focus-within/input:border-primary/50 group-focus-within/input:shadow-primary/5"
placeholder="请输入账号"
disabled={isLoading}
/>
) : (
<div className="relative">
<input
id="email"
type="text"
required
value={email}
onChange={(e) => {
// 只允许输入 @ 符号前的部分
const value = e.target.value.replace(/@.*$/, '');
setEmail(value);
}}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24
group-focus-within/input:border-primary/50 group-focus-within/input:shadow-primary/5"
placeholder="请输入QQ号"
disabled={isLoading}
/>
<div className="absolute right-0 inset-y-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 text-sm">@qq.com</span>
</div>
</div>
)}
{loginType === 'account' && (
<>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none
transition-opacity duration-200 group-focus-within/input:opacity-0">
<span className="text-gray-400 text-xs bg-gradient-to-r from-gray-400 to-gray-500 bg-clip-text text-transparent">
</span>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none
opacity-0 transition-opacity duration-200 group-focus-within/input:opacity-100">
<span className="text-primary/60 text-xs">
3-50
</span>
</div>
</>
)}
</div>
</div>
{loginType === 'account' ? (
<div className="flex items-center group">
<label htmlFor="password" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</label>
<div className="flex-1 relative group/input">
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px] pr-20
group-focus-within/input:border-primary/50 group-focus-within/input:shadow-primary/5"
placeholder="请输入密码"
disabled={isLoading}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none
transition-opacity duration-200 group-focus-within/input:opacity-0">
<span className="text-gray-400 text-xs bg-gradient-to-r from-gray-400 to-gray-500 bg-clip-text text-transparent">
</span>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none
opacity-0 transition-opacity duration-200 group-focus-within/input:opacity-100">
<span className="text-primary/60 text-xs">
6-20
</span>
</div>
</div>
</div>
) : (
<>
<div className="flex items-center group">
<label htmlFor="captcha" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</label>
<div className="flex-1">
<div className="flex space-x-2">
<input
id="captcha"
type="text"
required
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px]"
placeholder={captchaAvailable ? "请输入图片验证码" : "请等待验证码加载"}
disabled={isLoading || !captchaAvailable}
/>
<div>
{captchaImage ? (
<img
src={`data:image/png;base64,${captchaImage}`}
alt="验证码"
className="h-[38px] min-w-[80px] max-w-full cursor-pointer rounded-lg"
onClick={fetchCaptcha}
title="点击刷新验证码"
/>
) : (
<div className="flex items-center h-[38px] text-xs text-red-500">
<button
type="button"
className="ml-2 text-blue-500 underline"
onClick={fetchCaptcha}
>
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center group">
<label htmlFor="verificationCode" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</label>
<div className="flex-1">
<div className="relative">
<input
id="verificationCode"
type="text"
required
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
className="block w-full rounded-lg border-gray-300 transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24"
placeholder="请输入邮箱验证码"
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendVerificationCode}
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center
whitespace-nowrap px-2 text-sm font-medium text-primary hover:text-primary/80
transition-all duration-200 focus:outline-none disabled:opacity-50"
disabled={isLoading || !captchaAvailable || !captchaCode}
>
</button>
</div>
</div>
</div>
</>
)}
{loginType === 'account' && (
<div className="flex items-center">
<div className="w-20"></div>
<div className="flex-1 flex items-center justify-between">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm text-gray-600"></span>
</label>
<button
type="button"
onClick={() => navigate('/forgot-password')}
className="text-sm text-primary hover:text-primary/80 font-medium transition-all duration-200 hover:scale-105 active:scale-95"
>
</button>
</div>
</div>
)}
{typeof error === 'string' && error && (
<div className="flex items-center">
<div className="w-20"></div>
<div className="flex-1">
<div className="rounded-lg bg-red-50 p-4 border border-red-100">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
{error}
</h3>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
<button
type="submit"
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50"
disabled={isLoading}
className="w-full rounded-xl bg-gradient-to-r from-primary via-primary/95 to-primary/90
px-6 py-3 text-sm font-semibold text-white
shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30
hover:from-primary/95 hover:via-primary/90 hover:to-primary/85
active:from-primary/90 active:via-primary/85 active:to-primary/80
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-300 ease-in-out transform hover:-translate-y-0.5"
disabled={isLoading || (loginType === 'email' && !captchaAvailable)}
>
{isLoading ? '登录中...' : '登录'}
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</span>
) : (
<span className="flex items-center justify-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
</span>
)}
</button>
</form>
);

419
src/CellularManagement.WebUI/src/components/auth/RegisterForm.tsx

@ -6,9 +6,12 @@ import { apiService } from '@/services/apiService';
// 定义表单验证规则
const registerSchema = z.object({
username: z.string()
.min(3, '用户名长度不能少于3个字符')
.max(50, '用户名长度不能超过50个字符')
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
.min(3, '账号长度不能少于3个字符')
.max(50, '账号长度不能超过50个字符')
.regex(/^[a-zA-Z0-9_]+$/, '账号只能包含字母、数字和下划线'),
displayName: z.string()
.min(2, '用户名长度不能少于2个字符')
.max(20, '用户名长度不能超过20个字符'),
email: z.string()
.min(1, '邮箱不能为空')
.max(256, '邮箱长度不能超过256个字符')
@ -35,6 +38,7 @@ const registerSchema = z.object({
export interface RegisterRequest {
userName: string;
displayName: string;
email: string;
password: string;
confirmPassword: string;
@ -50,6 +54,7 @@ interface RegisterFormProps {
export function RegisterForm({ onSubmit }: RegisterFormProps) {
const [formData, setFormData] = useState({
username: '',
displayName: '',
email: '',
password: '',
confirmPassword: '',
@ -187,6 +192,7 @@ export function RegisterForm({ onSubmit }: RegisterFormProps) {
try {
const req: RegisterRequest = {
userName: formData.username,
displayName: formData.displayName,
email: formData.email,
password: formData.password,
confirmPassword: formData.confirmPassword,
@ -208,180 +214,291 @@ export function RegisterForm({ onSubmit }: RegisterFormProps) {
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium">
<div className="flex items-center group">
<label htmlFor="username" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</label>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.username ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder="请输入用户名"
disabled={isLoading}
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">{errors.username}</p>
)}
<div className="flex-1">
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={handleChange}
className={`block w-full rounded-lg border ${
errors.username ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder="请输入账号"
disabled={isLoading}
/>
{errors.username && (
<p className="mt-1.5 text-sm text-red-500">{errors.username}</p>
)}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
<div className="flex items-center group">
<label htmlFor="displayName" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.email ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder="请输入邮箱"
disabled={isLoading}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
)}
<div className="flex-1">
<input
id="displayName"
name="displayName"
type="text"
required
value={formData.displayName}
onChange={handleChange}
className={`block w-full rounded-lg border ${
errors.displayName ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder="请输入用户名"
disabled={isLoading}
/>
{errors.displayName && (
<p className="mt-1.5 text-sm text-red-500">{errors.displayName}</p>
)}
</div>
</div>
<div>
<label htmlFor="phoneNumber" className="block text-sm font-medium">
<div className="flex items-center group">
<label htmlFor="email" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</label>
<input
id="phoneNumber"
name="phoneNumber"
type="tel"
value={formData.phoneNumber}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.phoneNumber ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder="请输入手机号码"
disabled={isLoading}
/>
{errors.phoneNumber && (
<p className="mt-1 text-sm text-red-500">{errors.phoneNumber}</p>
)}
<div className="flex-1">
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className={`block w-full rounded-lg border ${
errors.email ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder="请输入邮箱"
disabled={isLoading}
/>
{errors.email && (
<p className="mt-1.5 text-sm text-red-500">{errors.email}</p>
)}
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
<div className="flex items-center group">
<label htmlFor="phoneNumber" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</label>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.password ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder="请输入密码"
disabled={isLoading}
/>
{formData.password && (
<div className="mt-2">
<div className="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${getPasswordStrengthColor(passwordStrength)}`}
style={{ width: `${(passwordStrength / 5) * 100}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
: {getPasswordStrengthText(passwordStrength)}
</p>
</div>
)}
{errors.password && (
<p className="mt-1 text-sm text-red-500">{errors.password}</p>
)}
<div className="flex-1">
<input
id="phoneNumber"
name="phoneNumber"
type="tel"
value={formData.phoneNumber}
onChange={handleChange}
className={`block w-full rounded-lg border ${
errors.phoneNumber ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder="请输入手机号码(选填)"
disabled={isLoading}
/>
{errors.phoneNumber && (
<p className="mt-1.5 text-sm text-red-500">{errors.phoneNumber}</p>
)}
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
<div className="flex items-center group">
<label htmlFor="password" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={formData.confirmPassword}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.confirmPassword ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder="请再次输入密码"
disabled={isLoading}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
)}
<div className="flex-1">
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
className={`block w-full rounded-lg border ${
errors.password ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder="请输入密码"
disabled={isLoading}
/>
{formData.password && (
<div className="mt-2">
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${getPasswordStrengthColor(passwordStrength)} transition-all duration-300`}
style={{ width: `${(passwordStrength / 5) * 100}%` }}
/>
</div>
<p className="mt-1.5 text-xs text-gray-500">
: {getPasswordStrengthText(passwordStrength)}
</p>
</div>
)}
{errors.password && (
<p className="mt-1.5 text-sm text-red-500">{errors.password}</p>
)}
</div>
</div>
<div>
<label htmlFor="captcha" className="block text-sm font-medium">
<div className="flex items-center group">
<label htmlFor="confirmPassword" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
</label>
<div className="flex space-x-2">
<div className="flex-1">
<input
id="captcha"
name="captcha"
type="text"
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={formData.captcha}
value={formData.confirmPassword}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.captcha ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder={captchaAvailable ? "请输入验证码" : "请等待验证码加载"}
disabled={isLoading || !captchaAvailable}
className={`block w-full rounded-lg border ${
errors.confirmPassword ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder="请再次输入密码"
disabled={isLoading}
/>
<div className="mt-1">
{captchaImage ? (
<img
src={`data:image/png;base64,${captchaImage}`}
alt="验证码"
className="h-10 min-w-[80px] max-w-full cursor-pointer"
onClick={fetchCaptcha}
title="点击刷新验证码"
/>
) : (
<div className="flex items-center h-10 text-xs text-red-500">
<button
type="button"
className="ml-2 text-blue-500 underline"
onClick={fetchCaptcha}
>
</button>
</div>
)}
{errors.confirmPassword && (
<p className="mt-1.5 text-sm text-red-500">{errors.confirmPassword}</p>
)}
</div>
</div>
<div className="flex items-center group">
<label htmlFor="captcha" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</label>
<div className="flex-1">
<div className="flex space-x-2">
<input
id="captcha"
name="captcha"
type="text"
required
value={formData.captcha}
onChange={handleChange}
className={`block w-full rounded-lg border ${
errors.captcha ? 'border-red-500' : 'border-gray-300'
} bg-white px-4 py-2.5 text-sm shadow-sm transition-all duration-200
focus:border-primary focus:ring-2 focus:ring-primary/10
placeholder:text-gray-400 disabled:bg-gray-50 disabled:text-gray-500`}
placeholder={captchaAvailable ? "请输入验证码" : "请等待验证码加载"}
disabled={isLoading || !captchaAvailable}
/>
<div className="relative">
{captchaImage ? (
<div className="relative group">
<img
src={`data:image/png;base64,${captchaImage}`}
alt="验证码"
className="h-[42px] min-w-[100px] max-w-full cursor-pointer rounded-lg shadow-sm hover:shadow transition-all duration-200"
onClick={fetchCaptcha}
title="点击刷新验证码"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/5 transition-colors duration-200 rounded-lg">
<svg className="w-5 h-5 text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
) : (
<div className="flex items-center h-[42px] text-xs text-red-500">
<button
type="button"
className="ml-2 text-primary hover:text-primary/80 underline transition-colors duration-200"
onClick={fetchCaptcha}
>
</button>
</div>
)}
</div>
</div>
{errors.captcha && (
<p className="mt-1.5 text-sm text-red-500">{errors.captcha}</p>
)}
</div>
{errors.captcha && (
<p className="mt-1 text-sm text-red-500">{errors.captcha}</p>
)}
</div>
{error && (
<div className="text-sm text-red-500">
{error}
<div className="rounded-lg bg-red-50 p-4 border border-red-100">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
{error}
</h3>
</div>
</div>
</div>
)}
</div>
<button
type="submit"
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50"
className="w-full rounded-xl bg-gradient-to-r from-primary via-primary/95 to-primary/90
px-6 py-3 text-sm font-semibold text-white
shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30
hover:from-primary/95 hover:via-primary/90 hover:to-primary/85
active:from-primary/90 active:via-primary/85 active:to-primary/80
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-300 ease-in-out transform hover:-translate-y-0.5"
disabled={isLoading || !captchaAvailable}
>
{isLoading ? '注册中...' : !captchaAvailable ? '等待验证码...' : '注册'}
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</span>
) : !captchaAvailable ? (
'等待验证码...'
) : (
<span className="flex items-center justify-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
</svg>
</span>
)}
</button>
</form>
);

52
src/CellularManagement.WebUI/src/components/ui/AnimatedContainer.tsx

@ -0,0 +1,52 @@
import { motion, HTMLMotionProps } from 'framer-motion';
import { ReactNode } from 'react';
interface AnimatedContainerProps extends HTMLMotionProps<"div"> {
children: ReactNode;
delay?: number;
className?: string;
animationType?: 'fadeIn' | 'slideUp' | 'scale';
}
const animations = {
fadeIn: {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.5 }
},
slideUp: {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5 }
},
scale: {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 0.5 }
}
};
export function AnimatedContainer({
children,
delay = 0,
className = '',
animationType = 'slideUp',
...props
}: AnimatedContainerProps) {
const selectedAnimation = animations[animationType];
return (
<motion.div
className={className}
initial={selectedAnimation.initial}
animate={selectedAnimation.animate}
transition={{
...selectedAnimation.transition,
delay
}}
{...props}
>
{children}
</motion.div>
);
}

93
src/CellularManagement.WebUI/src/components/ui/StatusSwitch.tsx

@ -0,0 +1,93 @@
import React from 'react';
interface StatusSwitchProps {
checked: boolean;
onChange: () => void;
activeText?: string;
inactiveText?: string;
disabled?: boolean;
}
const SWITCH_WIDTH = 72;
const SWITCH_HEIGHT = 28;
const THUMB_SIZE = 22;
const StatusSwitch: React.FC<StatusSwitchProps> = ({
checked,
onChange,
activeText = '正常',
inactiveText = '禁用',
disabled = false,
}) => {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
e.preventDefault();
return;
}
console.log('switch click');
onChange();
};
return (
<div
style={{
width: SWITCH_WIDTH,
height: SWITCH_HEIGHT,
borderRadius: SWITCH_HEIGHT / 2,
background: checked ? '#409eff' : '#d9d9d9',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
userSelect: 'none',
transition: 'background 0.2s',
border: checked ? '1px solid #409eff' : '1px solid #d9d9d9',
position: 'relative',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box',
fontWeight: 500,
padding: 0,
}}
onClick={handleClick}
>
{/* 文字 */}
<span
style={{
color: checked ? '#fff' : '#888',
fontSize: 13,
letterSpacing: 1,
position: 'absolute',
left: checked ? 12 : undefined,
right: !checked ? 12 : undefined,
transition: 'left 0.2s, right 0.2s, color 0.2s',
minWidth: 28,
textAlign: checked ? 'left' : 'right',
pointerEvents: 'none',
whiteSpace: 'nowrap',
zIndex: 2,
}}
>
{checked ? activeText : inactiveText}
</span>
{/* 圆点 */}
<div
style={{
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: '50%',
background: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.10)',
position: 'absolute',
top: (SWITCH_HEIGHT - THUMB_SIZE) / 2-1,
left: checked
? SWITCH_WIDTH - THUMB_SIZE - 4
: 4,
transition: 'left 0.2s cubic-bezier(.4,0,.2,1)',
border: '1px solid #f0f0f0',
zIndex: 3,
}}
/>
</div>
);
};
export default StatusSwitch;

2
src/CellularManagement.WebUI/src/components/ui/table.tsx

@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
'h-12 px-4 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}

19
src/CellularManagement.WebUI/src/constants/auth.ts

@ -31,16 +31,17 @@ export const AUTH_CONSTANTS = {
REGISTER_SUCCESS: '注册成功',
REGISTER_FAILED: '注册失败,请稍后重试',
LOGOUT_SUCCESS: '已成功退出登录',
LOGOUT_FAILED: '退出登录失败',
TOKEN_REFRESHED: '令牌已刷新',
TOKEN_REFRESH_FAILED: '令牌刷新失败,请重新登录',
USER_FETCHED: '已获取用户信息',
USER_FETCH_FAILED: '获取用户信息失败',
INVALID_CREDENTIALS: '用户名或密码错误',
ACCOUNT_LOCKED: '账户已被锁定,请稍后再试',
LOGOUT_FAILED: '退出登录失败,请稍后重试',
TOKEN_EXPIRED: '登录已过期,请重新登录',
NETWORK_ERROR: '网络错误,请检查网络连接',
UNKNOWN_ERROR: '发生未知错误,请稍后重试'
TOKEN_INVALID: '登录状态无效,请重新登录',
TOKEN_REFRESH_SUCCESS: '登录状态已更新',
TOKEN_REFRESH_FAILED: '登录状态更新失败,请重新登录',
USER_INFO_FAILED: '获取用户信息失败,请重新登录',
UNKNOWN_ERROR: '发生未知错误,请稍后重试',
RESET_PASSWORD_EMAIL_SENT: '验证码已发送到您的邮箱',
RESET_PASSWORD_EMAIL_FAILED: '验证码发送失败,请稍后重试',
PASSWORD_RESET_SUCCESS: '密码重置成功',
PASSWORD_RESET_FAILED: '密码重置失败,请稍后重试'
}
} as const;

378
src/CellularManagement.WebUI/src/pages/auth/ForgotPasswordPage.tsx

@ -0,0 +1,378 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiService } from '@/services/apiService';
import { toast } from '@/components/ui/use-toast';
export function ForgotPasswordPage() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [captchaImage, setCaptchaImage] = useState<string>('');
const [captchaId, setCaptchaId] = useState<string>('');
const [captchaCode, setCaptchaCode] = useState('');
const [captchaAvailable, setCaptchaAvailable] = useState(false);
// 获取验证码
const fetchCaptcha = async () => {
try {
setCaptchaAvailable(false);
const result = await apiService.getCaptcha();
if (result.success && result.data) {
setCaptchaImage(result.data.imageBase64);
setCaptchaId(result.data.captchaId);
setCaptchaAvailable(true);
} else {
setCaptchaImage('');
setCaptchaId('');
setCaptchaAvailable(false);
toast({
title: '获取验证码失败',
description: result.message || '请点击验证码图片重试',
variant: 'destructive',
duration: 3000,
});
}
} catch (error) {
setCaptchaImage('');
setCaptchaId('');
setCaptchaAvailable(false);
toast({
title: '获取验证码失败',
description: '请点击验证码图片重试',
variant: 'destructive',
duration: 3000,
});
}
};
// 组件加载时获取验证码
useEffect(() => {
fetchCaptcha();
}, []);
const handleSendVerificationCode = async () => {
if (!captchaAvailable || !captchaCode) {
toast({
title: '请先完成验证码验证',
description: '请输入图片验证码',
variant: 'destructive',
duration: 3000,
});
return;
}
if (!email) {
toast({
title: '请输入邮箱',
description: '请输入您的QQ邮箱',
variant: 'destructive',
duration: 3000,
});
return;
}
try {
setIsLoading(true);
const result = await apiService.sendResetPasswordEmail(email, captchaId, captchaCode);
if (result.success) {
toast({
title: '验证码已发送',
description: '请查收您的邮箱',
duration: 3000,
});
} else {
toast({
title: '发送失败',
description: result.message || '请稍后重试',
variant: 'destructive',
duration: 3000,
});
}
} catch (error) {
toast({
title: '发送失败',
description: '请稍后重试',
variant: 'destructive',
duration: 3000,
});
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !verificationCode || !newPassword || !confirmPassword) {
toast({
title: '请填写完整信息',
description: '请填写所有必填项',
variant: 'destructive',
duration: 3000,
});
return;
}
if (newPassword !== confirmPassword) {
toast({
title: '密码不一致',
description: '两次输入的密码不一致',
variant: 'destructive',
duration: 3000,
});
return;
}
try {
setIsLoading(true);
const result = await apiService.resetPassword(email, verificationCode, newPassword);
if (result.success) {
toast({
title: '密码重置成功',
description: '请使用新密码登录',
duration: 3000,
});
navigate('/login');
} else {
toast({
title: '重置失败',
description: result.message || '请稍后重试',
variant: 'destructive',
duration: 3000,
});
}
} catch (error) {
toast({
title: '重置失败',
description: '请稍后重试',
variant: 'destructive',
duration: 3000,
});
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 flex items-start justify-center pt-20 sm:pt-32 p-4 sm:p-8 relative overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-primary/5 rounded-full blur-3xl" />
</div>
<div className="w-full max-w-xl mx-auto relative z-10">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold text-gray-900 mb-3 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-700"></h1>
<p className="text-lg text-gray-600">QQ邮箱</p>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl p-8 sm:p-10 space-y-8 border border-gray-100">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-5">
{/* 邮箱输入 */}
<div className="flex items-center group">
<label htmlFor="email" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</label>
<div className="flex-1 relative">
<input
id="email"
type="text"
required
value={email}
onChange={(e) => {
const value = e.target.value.replace(/@.*$/, '');
setEmail(value);
}}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24"
placeholder="请输入QQ号"
disabled={isLoading}
/>
<div className="absolute right-0 inset-y-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 text-sm">@qq.com</span>
</div>
</div>
</div>
{/* 图片验证码 */}
<div className="flex items-center group">
<label htmlFor="captcha" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</label>
<div className="flex-1">
<div className="flex space-x-2">
<input
id="captcha"
type="text"
required
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px]"
placeholder={captchaAvailable ? "请输入图片验证码" : "请等待验证码加载"}
disabled={isLoading || !captchaAvailable}
/>
<div>
{captchaImage ? (
<img
src={`data:image/png;base64,${captchaImage}`}
alt="验证码"
className="h-[38px] min-w-[80px] max-w-full cursor-pointer rounded-lg"
onClick={fetchCaptcha}
title="点击刷新验证码"
/>
) : (
<div className="flex items-center h-[38px] text-xs text-red-500">
<button
type="button"
className="ml-2 text-blue-500 underline"
onClick={fetchCaptcha}
>
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* 邮箱验证码 */}
<div className="flex items-center group">
<label htmlFor="verificationCode" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</label>
<div className="flex-1">
<div className="relative">
<input
id="verificationCode"
type="text"
required
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
className="block w-full rounded-lg border-gray-300 transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24"
placeholder="请输入邮箱验证码"
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendVerificationCode}
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center
whitespace-nowrap px-2 text-sm font-medium text-primary hover:text-primary/80
transition-all duration-200 focus:outline-none disabled:opacity-50"
disabled={isLoading || !captchaAvailable || !captchaCode}
>
</button>
</div>
</div>
</div>
{/* 新密码 */}
<div className="flex items-center group">
<label htmlFor="newPassword" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</label>
<div className="flex-1 relative">
<input
id="newPassword"
type="password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px]"
placeholder="请输入新密码"
disabled={isLoading}
/>
</div>
</div>
{/* 确认密码 */}
<div className="flex items-center group">
<label htmlFor="confirmPassword" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</label>
<div className="flex-1 relative">
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200
focus:border-primary focus:ring-1 focus:ring-primary/10
placeholder:text-gray-400 sm:text-sm h-[38px]"
placeholder="请确认新密码"
disabled={isLoading}
/>
</div>
</div>
</div>
<button
type="submit"
className="w-full rounded-xl bg-gradient-to-r from-primary via-primary/95 to-primary/90
px-6 py-3 text-sm font-semibold text-white
shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30
hover:from-primary/95 hover:via-primary/90 hover:to-primary/85
active:from-primary/90 active:via-primary/85 active:to-primary/80
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-300 ease-in-out transform hover:-translate-y-0.5"
disabled={isLoading}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</span>
) : (
<span className="flex items-center justify-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</span>
)}
</button>
</form>
<div className="text-center text-base text-gray-600">
<span></span>
<button
onClick={() => navigate('/login')}
className="ml-2 text-primary hover:text-primary/80 font-medium transition-all duration-200 hover:scale-105 active:scale-95"
>
</button>
</div>
</div>
</div>
</div>
);
}

46
src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx

@ -5,11 +5,17 @@ import { useAuth } from '@/contexts/AuthContext';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login, error } = useAuth();
const { login } = useAuth();
const handleLogin = async (username: string, password: string, rememberMe: boolean) => {
const handleLogin = async (
username: string,
password: string,
rememberMe: boolean,
loginType: 'account' | 'email',
verificationCode?: string
) => {
try {
await login({ username, password, rememberMe });
await login({ username, password, rememberMe, loginType, verificationCode });
const from = (location.state as any)?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
} catch (error) {
@ -18,25 +24,25 @@ export function LoginPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg">
<div className="text-center">
<h2 className="text-2xl font-bold"></h2>
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-start justify-center pt-20 sm:pt-32 p-4 sm:p-8">
<div className="w-full max-w-xl mx-auto">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold text-gray-900 mb-3"></h1>
<p className="text-lg text-gray-600"></p>
</div>
<LoginForm onSubmit={handleLogin} />
{typeof error === 'string' && error && (
<div className="text-sm text-red-500 text-center">
{error}
<div className="bg-white rounded-2xl shadow-xl p-8 sm:p-10 space-y-8">
<LoginForm onSubmit={handleLogin} />
<div className="text-center text-base text-gray-600">
<span></span>
<button
onClick={() => navigate('/register')}
className="ml-2 text-primary hover:text-primary/80 font-medium transition-all duration-200 hover:scale-105 active:scale-95"
>
</button>
</div>
)}
<div className="text-center text-sm">
<span className="text-muted-foreground"></span>
<button
onClick={() => navigate('/register')}
className="ml-1 text-primary hover:underline"
>
</button>
</div>
</div>
</div>

45
src/CellularManagement.WebUI/src/pages/auth/RegisterPage.tsx

@ -29,28 +29,33 @@ export function RegisterPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg">
<div className="text-center">
<h2 className="text-2xl font-bold"></h2>
<p className="mt-2 text-sm text-muted-foreground">
访
</p>
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 flex items-start justify-center pt-20 sm:pt-32 p-4 sm:p-8 relative overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-primary/5 rounded-full blur-3xl" />
</div>
<div className="w-full max-w-xl mx-auto relative z-10">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold text-gray-900 mb-3 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-700">
</h1>
<p className="text-lg text-gray-600"></p>
</div>
<RegisterForm onSubmit={handleRegister} />
{typeof error === 'string' && error && (
<div className="text-sm text-red-500 text-center">
{error}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl p-8 sm:p-10 space-y-8 border border-gray-100">
<RegisterForm onSubmit={handleRegister} />
<div className="text-center text-base text-gray-600">
<span></span>
<button
onClick={() => navigate('/login')}
className="ml-2 text-primary hover:text-primary/80 font-medium transition-all duration-200 hover:scale-105 active:scale-95"
>
</button>
</div>
)}
<div className="text-center text-sm">
<span className="text-muted-foreground"></span>
<button
onClick={() => navigate('/login')}
className="ml-1 text-primary hover:underline"
>
</button>
</div>
</div>
</div>

55
src/CellularManagement.WebUI/src/pages/dashboard/DashboardHome.tsx

@ -1,14 +1,21 @@
import { AnimatedContainer } from '@/components/ui/AnimatedContainer';
export function DashboardHome() {
return (
<div className="w-full p-4 sm:p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold"></h1>
<AnimatedContainer className="w-full p-4 sm:p-6 space-y-6">
<AnimatedContainer delay={0.2}>
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-700"></h1>
<p className="text-muted-foreground">
使
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border bg-card p-6">
</AnimatedContainer>
<AnimatedContainer delay={0.4} className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<AnimatedContainer
className="rounded-lg border bg-card p-6 hover:shadow-lg transition-all duration-300"
animationType="scale"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
@ -34,8 +41,14 @@ export function DashboardHome() {
</svg>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-6">
</AnimatedContainer>
<AnimatedContainer
className="rounded-lg border bg-card p-6 hover:shadow-lg transition-all duration-300"
animationType="scale"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
@ -58,8 +71,14 @@ export function DashboardHome() {
</svg>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-6">
</AnimatedContainer>
<AnimatedContainer
className="rounded-lg border bg-card p-6 hover:shadow-lg transition-all duration-300"
animationType="scale"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
@ -83,8 +102,14 @@ export function DashboardHome() {
</svg>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-6">
</AnimatedContainer>
<AnimatedContainer
className="rounded-lg border bg-card p-6 hover:shadow-lg transition-all duration-300"
animationType="scale"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
@ -107,8 +132,8 @@ export function DashboardHome() {
</svg>
</div>
</div>
</div>
</div>
</div>
</AnimatedContainer>
</AnimatedContainer>
</AnimatedContainer>
);
}

5
src/CellularManagement.WebUI/src/pages/dashboard/UserManagePage.tsx

@ -1,5 +0,0 @@
import { Outlet } from 'react-router-dom';
export default function UserManagePage() {
return <Outlet />;
}

82
src/CellularManagement.WebUI/src/pages/users/UserTable.tsx

@ -11,8 +11,9 @@ import {
import { User } from '@/services/userService';
import { formatToBeijingTime } from '@/lib/utils';
import { DensityType } from '@/components/ui/TableToolbar';
import * as Switch from '@radix-ui/react-switch';
import { userService } from '@/services/userService';
import { cn } from '@/lib/utils';
import StatusSwitch from '@/components/ui/StatusSwitch';
interface ColumnConfig {
key: string;
@ -34,6 +35,7 @@ interface UserTableProps {
hideCard?: boolean;
density?: DensityType;
columns?: ColumnConfig[];
onRefresh?: () => void;
}
export default function UserTable({
@ -49,6 +51,7 @@ export default function UserTable({
hideCard = false,
density = 'default',
columns = [],
onRefresh,
}: UserTableProps) {
const totalPages = Math.ceil(total / pageSize);
const Wrapper = hideCard ? React.Fragment : 'div';
@ -75,7 +78,7 @@ export default function UserTable({
{visibleColumns.map(col => (
<TableHead
key={col.key}
className={`text-foreground ${col.key === 'actions' ? 'text-right' : ''} ${cellPadding}`}
className={`text-foreground text-center ${col.key === 'actions' ? 'text-right' : ''} ${cellPadding}`}
>
{col.title}
</TableHead>
@ -101,77 +104,30 @@ export default function UserTable({
{visibleColumns.map(col => {
switch (col.key) {
case 'userName':
return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{user.userName}</TableCell>;
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{user.userName}</TableCell>;
case 'email':
return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{user.email}</TableCell>;
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{user.email}</TableCell>;
case 'phoneNumber':
return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>{user.phoneNumber}</TableCell>;
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{user.phoneNumber}</TableCell>;
case 'isActive':
return (
<TableCell key={col.key} className={`text-foreground ${cellPadding}`}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch.Root
<TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<StatusSwitch
checked={!!user.isActive}
style={{
width: 44,
height: 24,
background: user.isActive
? 'linear-gradient(90deg, var(--primary, #b620e0), var(--primary, #b620e0))'
: '#e5e7eb',
borderRadius: 999,
border: user.isActive ? '1.5px solid var(--primary, #b620e0)' : '1.5px solid #e5e7eb',
position: 'relative',
transition: 'background 0.2s, border 0.2s',
boxShadow: user.isActive
? '0 0 0 2px var(--primary, #b620e0), 0 2px 8px rgba(0,0,0,0.04)'
: '0 1px 4px rgba(0,0,0,0.04)',
cursor: 'pointer',
onChange={async () => {
await userService.updateUser(user.id, { ...user, isActive: !user.isActive });
if (onRefresh) onRefresh();
}}
onCheckedChange={async (checked) => {
await userService.updateUser(user.id, { ...user, isActive: checked });
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('refresh-users'));
}
}}
>
<Switch.Thumb
style={{
display: 'block',
width: 20,
height: 20,
background: '#fff',
borderRadius: '50%',
boxShadow: '0 2px 8px rgba(0,0,0,0.10)',
transition: 'transform 0.2s, background 0.2s',
transform: user.isActive ? 'translateX(20px)' : 'translateX(2px)',
border: user.isActive ? '1.5px solid var(--primary, #b620e0)' : '1.5px solid #e5e7eb',
}}
/>
</Switch.Root>
<span
style={{
color: user.isActive ? 'var(--primary, #b620e0)' : '#bdbdbd',
fontWeight: 600,
fontSize: 14,
minWidth: 32,
textAlign: 'center',
letterSpacing: 2,
transition: 'color 0.2s',
}}
>
{user.isActive ? '正常' : '禁用'}
</span>
disabled={loading}
/>
</div>
</TableCell>
);
case 'roles':
return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>
{Array.isArray(user.roles) && user.roles.length > 0 ? user.roles.join(', ') : '-'}
</TableCell>;
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{Array.isArray(user.roles) && user.roles.length > 0 ? user.roles.join(', ') : '-'}</TableCell>;
case 'createdAt':
return <TableCell key={col.key} className={`text-foreground ${cellPadding}`}>
{user.createdAt ? formatToBeijingTime(user.createdAt) : '-'}
</TableCell>;
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{user.createdAt ? formatToBeijingTime(user.createdAt) : '-'}</TableCell>;
case 'actions':
return (
<TableCell key={col.key} className={`text-right ${cellPadding}`}>
@ -212,4 +168,4 @@ export default function UserTable({
</Table>
</Wrapper>
);
}
}

90
src/CellularManagement.WebUI/src/pages/users/UsersView.tsx

@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import PaginationBar from '@/components/ui/PaginationBar';
import TableToolbar, { DensityType } from '@/components/ui/TableToolbar';
import UserRolesForm from './UserRolesForm';
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
const defaultColumns = [
{ key: 'userName', title: '用户名', visible: true },
@ -20,6 +21,32 @@ const defaultColumns = [
{ key: 'actions', title: '操作', visible: true }
];
// 字段类型声明
type SearchField =
| { key: string; label: string; type: 'input'; placeholder: string }
| { key: string; label: string; type: 'select'; options: { value: string; label: string }[] };
// 第一行字段(收起时只显示这3个)
const firstRowFields: SearchField[] = [
{ key: 'name', label: '姓名', type: 'input', placeholder: '请输入' },
{ key: 'phone', label: '联系电话', type: 'input', placeholder: '请输入' },
{ key: 'role', label: '角色', type: 'select', options: [
{ value: '', label: '请选择' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '普通用户' },
] },
];
// 高级字段(展开时才显示)
const advancedFields: SearchField[] = [
{ key: 'account', label: '登录账号', type: 'input', placeholder: '请输入' },
{ key: 'status', label: '状态', type: 'select', options: [
{ value: '', label: '请选择' },
{ value: 'active', label: '正常' },
{ value: 'inactive', label: '禁用' },
] },
];
export default function UsersView() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
@ -33,6 +60,7 @@ export default function UsersView() {
const [pageSize, setPageSize] = useState(10);
const [density, setDensity] = useState<DensityType>('default');
const [columns, setColumns] = useState(defaultColumns);
const [showAdvanced, setShowAdvanced] = useState(false);
const fetchUsers = async (params = {}) => {
setLoading(true);
@ -108,19 +136,55 @@ export default function UsersView() {
return (
<main className="flex-1 p-4 transition-all duration-300 ease-in-out sm:p-6">
<div className="w-full space-y-4">
{/* 顶部搜索栏 */}
<div className="flex items-center bg-background p-4 rounded-md border mb-2 gap-4">
<Input
placeholder="请输入用户名"
value={userName}
onChange={e => setUserName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleQuery(); }}
className="w-64 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all mx-2"
/>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={handleReset}></Button>
<Button onClick={handleQuery}></Button>
</div>
{/* 丰富美化后的搜索栏 */}
<div className="flex flex-col bg-white p-4 rounded-md border mb-2">
<form
className={`grid gap-x-8 gap-y-4 items-center ${showAdvanced ? 'md:grid-cols-3' : 'md:grid-cols-4'} grid-cols-1`}
onSubmit={e => {
e.preventDefault();
handleQuery();
}}
>
{(showAdvanced ? [...firstRowFields, ...advancedFields] : firstRowFields).map(field => (
<div className="flex flex-row items-center min-w-[200px] flex-1" key={field.key}>
<label
className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right"
style={{ width: 80, minWidth: 80 }}
>
{field.label}
</label>
{field.type === 'input' && (
<Input
className="input flex-1"
placeholder={field.placeholder}
/>
)}
{field.type === 'select' && (
<select className="input h-10 rounded border border-border bg-background px-3 text-sm flex-1">
{field.options.map(opt => (
<option value={opt.value} key={opt.value}>{opt.label}</option>
))}
</select>
)}
</div>
))}
{/* 按钮组直接作为表单项之一,紧跟在最后一个表单项后面 */}
<div className="flex flex-row items-center min-w-[200px] flex-1 justify-end gap-2">
<Button type="button" variant="outline" onClick={handleReset}></Button>
<Button type="submit"></Button>
<Button type="button" variant="ghost" onClick={() => setShowAdvanced(v => !v)}>
{showAdvanced ? (
<>
<ChevronUpIcon className="inline-block ml-1 w-4 h-4 align-middle" />
</>
) : (
<>
<ChevronDownIcon className="inline-block ml-1 w-4 h-4 align-middle" />
</>
)}
</Button>
</div>
</form>
</div>
{/* 表格整体卡片区域,包括添加按钮、表格、分页 */}
<div className="rounded-md border bg-background p-4">

33
src/CellularManagement.WebUI/src/routes/AppRouter.tsx

@ -2,13 +2,14 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { Suspense, lazy } from 'react';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { AnimatedContainer } from '@/components/ui/AnimatedContainer';
// 使用 lazy 加载组件
const LoginPage = lazy(() => import('@/pages/auth/LoginPage').then(module => ({ default: module.LoginPage })));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage').then(module => ({ default: module.ForgotPasswordPage })));
const RegisterPage = lazy(() => import('@/pages/auth/RegisterPage').then(module => ({ default: module.RegisterPage })));
const DashboardHome = lazy(() => import('@/pages/dashboard/DashboardHome').then(module => ({ default: module.DashboardHome })));
const ForbiddenPage = lazy(() => import('@/pages/auth/ForbiddenPage'));
const UserManagePage = lazy(() => import('@/pages/dashboard/UserManagePage'));
const RolesView = lazy(() => import('@/pages/roles/RolesView'));
const UsersView = lazy(() => import('@/pages/users/UsersView'));
@ -27,7 +28,19 @@ export function AppRouter() {
path="/login"
element={
<Suspense fallback={<LoadingFallback />}>
<LoginPage />
<AnimatedContainer>
<LoginPage />
</AnimatedContainer>
</Suspense>
}
/>
<Route
path="/forgot-password"
element={
<Suspense fallback={<LoadingFallback />}>
<AnimatedContainer>
<ForgotPasswordPage />
</AnimatedContainer>
</Suspense>
}
/>
@ -35,13 +48,17 @@ export function AppRouter() {
path="/register"
element={
<Suspense fallback={<LoadingFallback />}>
<RegisterPage />
<AnimatedContainer>
<RegisterPage />
</AnimatedContainer>
</Suspense>
}
/>
<Route path="/403" element={
<Suspense fallback={<LoadingFallback />}>
<ForbiddenPage />
<AnimatedContainer>
<ForbiddenPage />
</AnimatedContainer>
</Suspense>
} />
<Route
@ -56,12 +73,16 @@ export function AppRouter() {
<Route index element={<Navigate to="list" replace />} />
<Route path="list" element={
<ProtectedRoute requiredPermission="users.view">
<UsersView />
<AnimatedContainer>
<UsersView />
</AnimatedContainer>
</ProtectedRoute>
} />
<Route path="roles" element={
<ProtectedRoute requiredPermission="roles.view">
<RolesView />
<AnimatedContainer>
<RolesView />
</AnimatedContainer>
</ProtectedRoute>
} />
</Route>

54
src/CellularManagement.WebUI/src/services/apiService.ts

@ -21,6 +21,8 @@ export interface ApiService {
logout: () => Promise<OperationResult<void>>;
getCurrentUser: () => Promise<OperationResult<User>>;
getCaptcha: () => Promise<OperationResult<CaptchaResponse>>;
sendResetPasswordEmail: (email: string, captchaId: string, captchaCode: string) => Promise<OperationResult<void>>;
resetPassword: (email: string, verificationCode: string, newPassword: string) => Promise<OperationResult<void>>;
}
class ApiError extends Error {
@ -165,5 +167,57 @@ export const apiService: ApiService = {
message: error.response?.data?.message || '验证码获取失败'
};
}
},
sendResetPasswordEmail: async (email: string, captchaId: string, captchaCode: string): Promise<OperationResult<void>> => {
try {
const response = await httpClient.post<ApiResponse<void>>('/api/auth/reset-password/email', {
email: `${email}@qq.com`,
captchaId,
captchaCode
});
if (response.isSuccess) {
return {
success: true,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.RESET_PASSWORD_EMAIL_SENT
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.RESET_PASSWORD_EMAIL_FAILED
};
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.RESET_PASSWORD_EMAIL_FAILED
};
}
},
resetPassword: async (email: string, verificationCode: string, newPassword: string): Promise<OperationResult<void>> => {
try {
const response = await httpClient.post<ApiResponse<void>>('/api/auth/reset-password', {
email: `${email}@qq.com`,
verificationCode,
newPassword
});
if (response.isSuccess) {
return {
success: true,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.PASSWORD_RESET_SUCCESS
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.PASSWORD_RESET_FAILED
};
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.PASSWORD_RESET_FAILED
};
}
}
};

44
src/CellularManagement.WebUI/src/services/authService.ts

@ -173,18 +173,22 @@ export const authService: AuthService = {
isExpired: authService.isTokenExpired()
});
if (!accessToken || authService.isTokenExpired()) {
console.log('[authService] token 缺失或过期,尝试刷新');
if (!refreshToken) {
console.log('[authService] 没有刷新令牌,需要重新登录');
dispatch({
type: 'LOGIN_FAILURE',
payload: { error: AUTH_CONSTANTS.MESSAGES.TOKEN_EXPIRED }
});
if (!accessToken || !refreshToken) {
console.log('[authService] 没有 token,返回初始状态');
dispatch({ type: 'LOGOUT' });
return;
}
if (authService.isTokenExpired()) {
console.log('[authService] token 过期,尝试刷新');
try {
await authService.handleRefreshToken(dispatch);
return;
} catch (error) {
console.log('[authService] token 刷新失败,返回初始状态');
dispatch({ type: 'LOGOUT' });
return;
}
await authService.handleRefreshToken(dispatch);
return;
}
console.log('[authService] 开始获取当前用户信息');
@ -196,14 +200,10 @@ export const authService: AuthService = {
data: result.data
});
if (!result.success) {
console.error('[authService] 获取用户信息失败', result.message);
throw new AuthError(result.message || AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED);
}
if (!result.data) {
console.error('[authService] 用户数据为空');
throw new AuthError(AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED);
if (!result.success || !result.data) {
console.log('[authService] 获取用户信息失败,返回初始状态');
dispatch({ type: 'LOGOUT' });
return;
}
console.log('[authService] 设置用户信息到状态');
@ -216,13 +216,9 @@ export const authService: AuthService = {
rememberMe: storageService.getRememberMe()
}
});
} catch (error: any) {
} catch (error) {
console.error('[authService] initializeAuth 错误', error);
dispatch({
type: 'LOGIN_FAILURE',
payload: { error: error.message || AUTH_CONSTANTS.MESSAGES.UNKNOWN_ERROR }
});
throw error;
dispatch({ type: 'LOGOUT' });
}
}
};

1
src/CellularManagement.WebUI/src/services/userService.ts

@ -35,6 +35,7 @@ export interface UpdateUserRequest {
email: string;
phoneNumber?: string;
roles?: string[];
isActive?: boolean;
}
export interface UserService {

2
src/CellularManagement.WebUI/src/types/auth.ts

@ -11,6 +11,8 @@ export interface LoginRequest {
username: string;
password: string;
rememberMe: boolean;
loginType: 'account' | 'email';
verificationCode?: string;
}
export interface LoginResponse {

Loading…
Cancel
Save