52 changed files with 8636 additions and 0 deletions
@ -0,0 +1,18 @@ |
|||
module.exports = { |
|||
root: true, |
|||
env: { browser: true, es2020: true }, |
|||
extends: [ |
|||
'eslint:recommended', |
|||
'plugin:@typescript-eslint/recommended', |
|||
'plugin:react-hooks/recommended', |
|||
], |
|||
ignorePatterns: ['dist', '.eslintrc.cjs'], |
|||
parser: '@typescript-eslint/parser', |
|||
plugins: ['react-refresh'], |
|||
rules: { |
|||
'react-refresh/only-export-components': [ |
|||
'warn', |
|||
{ allowConstantExport: true }, |
|||
], |
|||
}, |
|||
} |
@ -0,0 +1,24 @@ |
|||
# Logs |
|||
logs |
|||
*.log |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
pnpm-debug.log* |
|||
lerna-debug.log* |
|||
|
|||
node_modules |
|||
dist |
|||
dist-ssr |
|||
*.local |
|||
|
|||
# Editor directories and files |
|||
.vscode/* |
|||
!.vscode/extensions.json |
|||
.idea |
|||
.DS_Store |
|||
*.suo |
|||
*.ntvs* |
|||
*.njsproj |
|||
*.sln |
|||
*.sw? |
@ -0,0 +1,6 @@ |
|||
{ |
|||
"semi": false, |
|||
"singleQuote": true, |
|||
"tabWidth": 2, |
|||
"trailingComma": "es5" |
|||
} |
@ -0,0 +1,54 @@ |
|||
# React + TypeScript + Vite |
|||
|
|||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. |
|||
|
|||
Currently, two official plugins are available: |
|||
|
|||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh |
|||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh |
|||
|
|||
## Expanding the ESLint configuration |
|||
|
|||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: |
|||
|
|||
```js |
|||
export default tseslint.config({ |
|||
extends: [ |
|||
// Remove ...tseslint.configs.recommended and replace with this |
|||
...tseslint.configs.recommendedTypeChecked, |
|||
// Alternatively, use this for stricter rules |
|||
...tseslint.configs.strictTypeChecked, |
|||
// Optionally, add this for stylistic rules |
|||
...tseslint.configs.stylisticTypeChecked, |
|||
], |
|||
languageOptions: { |
|||
// other options... |
|||
parserOptions: { |
|||
project: ['./tsconfig.node.json', './tsconfig.app.json'], |
|||
tsconfigRootDir: import.meta.dirname, |
|||
}, |
|||
}, |
|||
}) |
|||
``` |
|||
|
|||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: |
|||
|
|||
```js |
|||
// eslint.config.js |
|||
import reactX from 'eslint-plugin-react-x' |
|||
import reactDom from 'eslint-plugin-react-dom' |
|||
|
|||
export default tseslint.config({ |
|||
plugins: { |
|||
// Add the react-x and react-dom plugins |
|||
'react-x': reactX, |
|||
'react-dom': reactDom, |
|||
}, |
|||
rules: { |
|||
// other rules... |
|||
// Enable its recommended typescript rules |
|||
...reactX.configs['recommended-typescript'].rules, |
|||
...reactDom.configs.recommended.rules, |
|||
}, |
|||
}) |
|||
``` |
@ -0,0 +1,28 @@ |
|||
import js from '@eslint/js' |
|||
import globals from 'globals' |
|||
import reactHooks from 'eslint-plugin-react-hooks' |
|||
import reactRefresh from 'eslint-plugin-react-refresh' |
|||
import tseslint from 'typescript-eslint' |
|||
|
|||
export default tseslint.config( |
|||
{ ignores: ['dist'] }, |
|||
{ |
|||
extends: [js.configs.recommended, ...tseslint.configs.recommended], |
|||
files: ['**/*.{ts,tsx}'], |
|||
languageOptions: { |
|||
ecmaVersion: 2020, |
|||
globals: globals.browser, |
|||
}, |
|||
plugins: { |
|||
'react-hooks': reactHooks, |
|||
'react-refresh': reactRefresh, |
|||
}, |
|||
rules: { |
|||
...reactHooks.configs.recommended.rules, |
|||
'react-refresh/only-export-components': [ |
|||
'warn', |
|||
{ allowConstantExport: true }, |
|||
], |
|||
}, |
|||
}, |
|||
) |
@ -0,0 +1,13 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Vite + React + TS</title> |
|||
</head> |
|||
<body> |
|||
<div id="root"></div> |
|||
<script type="module" src="/src/main.tsx"></script> |
|||
</body> |
|||
</html> |
File diff suppressed because it is too large
@ -0,0 +1,56 @@ |
|||
{ |
|||
"name": "cellularmanagement-webui", |
|||
"private": true, |
|||
"version": "0.0.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"build": "tsc && vite build", |
|||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", |
|||
"preview": "vite preview", |
|||
"format": "prettier --write \"src/**/*.{ts,tsx}\"" |
|||
}, |
|||
"dependencies": { |
|||
"@hookform/resolvers": "^3.3.4", |
|||
"@radix-ui/react-checkbox": "^1.3.1", |
|||
"@radix-ui/react-icons": "^1.3.0", |
|||
"@radix-ui/react-select": "^2.2.4", |
|||
"@radix-ui/react-slot": "^1.0.2", |
|||
"axios": "^1.9.0", |
|||
"class-variance-authority": "^0.7.0", |
|||
"clsx": "^2.1.0", |
|||
"i18next": "^25.1.1", |
|||
"lucide-react": "^0.344.0", |
|||
"react": "^18.2.0", |
|||
"react-dom": "^18.2.0", |
|||
"react-hook-form": "^7.51.0", |
|||
"react-hot-toast": "^2.5.2", |
|||
"react-i18next": "^15.5.1", |
|||
"react-router-dom": "^7.0.0", |
|||
"sonner": "^1.4.3", |
|||
"tailwind-merge": "^2.2.1", |
|||
"zod": "^3.22.4", |
|||
"zustand": "^4.5.2" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/aspect-ratio": "^0.4.2", |
|||
"@tailwindcss/container-queries": "^0.1.1", |
|||
"@tailwindcss/forms": "^0.5.7", |
|||
"@tailwindcss/typography": "^0.5.10", |
|||
"@types/node": "^20.11.24", |
|||
"@types/react": "^18.2.56", |
|||
"@types/react-dom": "^18.2.19", |
|||
"@typescript-eslint/eslint-plugin": "^7.0.2", |
|||
"@typescript-eslint/parser": "^7.0.2", |
|||
"@vitejs/plugin-react": "^4.2.1", |
|||
"autoprefixer": "^10.4.18", |
|||
"eslint": "^8.56.0", |
|||
"eslint-plugin-react-hooks": "^4.6.0", |
|||
"eslint-plugin-react-refresh": "^0.4.5", |
|||
"postcss": "^8.4.35", |
|||
"prettier": "^3.2.5", |
|||
"tailwindcss": "^3.4.1", |
|||
"typescript": "^5.2.2", |
|||
"vite": "^5.1.4" |
|||
} |
|||
} |
@ -0,0 +1,6 @@ |
|||
export default { |
|||
plugins: { |
|||
tailwindcss: {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
} |
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,42 @@ |
|||
#root { |
|||
max-width: 1280px; |
|||
margin: 0 auto; |
|||
padding: 2rem; |
|||
text-align: center; |
|||
} |
|||
|
|||
.logo { |
|||
height: 6em; |
|||
padding: 1.5em; |
|||
will-change: filter; |
|||
transition: filter 300ms; |
|||
} |
|||
.logo:hover { |
|||
filter: drop-shadow(0 0 2em #646cffaa); |
|||
} |
|||
.logo.react:hover { |
|||
filter: drop-shadow(0 0 2em #61dafbaa); |
|||
} |
|||
|
|||
@keyframes logo-spin { |
|||
from { |
|||
transform: rotate(0deg); |
|||
} |
|||
to { |
|||
transform: rotate(360deg); |
|||
} |
|||
} |
|||
|
|||
@media (prefers-reduced-motion: no-preference) { |
|||
a:nth-of-type(2) .logo { |
|||
animation: logo-spin infinite 20s linear; |
|||
} |
|||
} |
|||
|
|||
.card { |
|||
padding: 2em; |
|||
} |
|||
|
|||
.read-the-docs { |
|||
color: #888; |
|||
} |
@ -0,0 +1,15 @@ |
|||
import { RouterProvider } from 'react-router-dom' |
|||
import { Toaster } from 'sonner' |
|||
import { ThemeProvider } from '@/components/theme-provider' |
|||
import { router } from '@/routes' |
|||
|
|||
function App() { |
|||
return ( |
|||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> |
|||
<RouterProvider router={router} /> |
|||
<Toaster /> |
|||
</ThemeProvider> |
|||
) |
|||
} |
|||
|
|||
export default App |
After Width: | Height: | Size: 4.0 KiB |
@ -0,0 +1,17 @@ |
|||
import { Navigate, useLocation } from 'react-router-dom' |
|||
import { useAuthStore } from '@/store/auth' |
|||
|
|||
interface ProtectedRouteProps { |
|||
children: React.ReactNode |
|||
} |
|||
|
|||
export function ProtectedRoute({ children }: ProtectedRouteProps) { |
|||
const { isAuthenticated } = useAuthStore() |
|||
const location = useLocation() |
|||
|
|||
if (!isAuthenticated) { |
|||
return <Navigate to="/login" state={{ from: location }} replace /> |
|||
} |
|||
|
|||
return <>{children}</> |
|||
} |
@ -0,0 +1,90 @@ |
|||
import { Outlet, Link, useNavigate } from 'react-router-dom' |
|||
import { Moon, Sun, LogOut, Settings } from 'lucide-react' |
|||
import { useTranslation } from 'react-i18next' |
|||
import { Button } from '@/components/ui/button' |
|||
import { useAuthStore } from '@/store/auth' |
|||
import { useTheme } from '@/hooks/use-theme' |
|||
|
|||
export function Layout() { |
|||
const { t } = useTranslation() |
|||
const navigate = useNavigate() |
|||
const { isAuthenticated, logout } = useAuthStore() |
|||
const { theme, setTheme } = useTheme() |
|||
|
|||
const handleLogout = () => { |
|||
logout() |
|||
navigate('/login') |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-h-screen bg-background"> |
|||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> |
|||
<div className="container flex h-16 items-center"> |
|||
<Link to="/" className="mr-6 flex items-center space-x-2"> |
|||
<span className="font-bold">Cellular Management</span> |
|||
</Link> |
|||
<nav className="flex flex-1 items-center justify-between"> |
|||
<div className="flex items-center space-x-4"> |
|||
{isAuthenticated && ( |
|||
<> |
|||
<Link |
|||
to="/" |
|||
className="text-sm font-medium transition-colors hover:text-primary" |
|||
> |
|||
{t('navigation.home')} |
|||
</Link> |
|||
<Link |
|||
to="/profile" |
|||
className="text-sm font-medium transition-colors hover:text-primary" |
|||
> |
|||
{t('navigation.profile')} |
|||
</Link> |
|||
<Link |
|||
to="/settings" |
|||
className="text-sm font-medium transition-colors hover:text-primary" |
|||
> |
|||
{t('navigation.settings')} |
|||
</Link> |
|||
</> |
|||
)} |
|||
</div> |
|||
<div className="flex items-center space-x-4"> |
|||
<Button |
|||
variant="ghost" |
|||
size="icon" |
|||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')} |
|||
> |
|||
{theme === 'light' ? ( |
|||
<Moon className="h-5 w-5" /> |
|||
) : ( |
|||
<Sun className="h-5 w-5" /> |
|||
)} |
|||
</Button> |
|||
{isAuthenticated ? ( |
|||
<Button |
|||
variant="ghost" |
|||
size="icon" |
|||
onClick={handleLogout} |
|||
> |
|||
<LogOut className="h-5 w-5" /> |
|||
</Button> |
|||
) : ( |
|||
<> |
|||
<Link to="/login"> |
|||
<Button variant="ghost">{t('auth.login')}</Button> |
|||
</Link> |
|||
<Link to="/register"> |
|||
<Button>{t('auth.register')}</Button> |
|||
</Link> |
|||
</> |
|||
)} |
|||
</div> |
|||
</nav> |
|||
</div> |
|||
</header> |
|||
<main> |
|||
<Outlet /> |
|||
</main> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,36 @@ |
|||
import { Navigate, useLocation } from 'react-router-dom' |
|||
import { useAuthStore } from '@/store/auth' |
|||
import { usePermissions } from '@/hooks/use-permissions' |
|||
import { Permission } from '@/types/auth' |
|||
|
|||
interface ProtectedRouteProps { |
|||
children: React.ReactNode |
|||
requiredPermissions?: Permission[] |
|||
requireAllPermissions?: boolean |
|||
} |
|||
|
|||
export function ProtectedRoute({ |
|||
children, |
|||
requiredPermissions = [], |
|||
requireAllPermissions = false, |
|||
}: ProtectedRouteProps) { |
|||
const location = useLocation() |
|||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) |
|||
const { hasAnyPermission, hasAllPermissions } = usePermissions() |
|||
|
|||
if (!isAuthenticated) { |
|||
return <Navigate to="/login" state={{ from: location }} replace /> |
|||
} |
|||
|
|||
if (requiredPermissions.length > 0) { |
|||
const hasRequiredPermissions = requireAllPermissions |
|||
? hasAllPermissions(requiredPermissions) |
|||
: hasAnyPermission(requiredPermissions) |
|||
|
|||
if (!hasRequiredPermissions) { |
|||
return <Navigate to="/" replace /> |
|||
} |
|||
} |
|||
|
|||
return <>{children}</> |
|||
} |
@ -0,0 +1,44 @@ |
|||
import { useEffect } from 'react' |
|||
import { useAuthStore } from '@/store/auth' |
|||
|
|||
const ACTIVITY_EVENTS = [ |
|||
'mousedown', |
|||
'mousemove', |
|||
'keypress', |
|||
'scroll', |
|||
'touchstart', |
|||
'click', |
|||
'keydown', |
|||
] as const |
|||
|
|||
export const SessionManager = () => { |
|||
const checkSession = useAuthStore((state) => state.checkSession) |
|||
const updateLastActivity = useAuthStore((state) => state.updateLastActivity) |
|||
|
|||
useEffect(() => { |
|||
// 监听用户活动
|
|||
const handleUserActivity = () => { |
|||
updateLastActivity() |
|||
} |
|||
|
|||
// 添加事件监听器
|
|||
ACTIVITY_EVENTS.forEach((event) => { |
|||
window.addEventListener(event, handleUserActivity) |
|||
}) |
|||
|
|||
// 每分钟检查一次会话状态
|
|||
const interval = setInterval(() => { |
|||
checkSession() |
|||
}, 60000) |
|||
|
|||
return () => { |
|||
// 清理事件监听器
|
|||
ACTIVITY_EVENTS.forEach((event) => { |
|||
window.removeEventListener(event, handleUserActivity) |
|||
}) |
|||
clearInterval(interval) |
|||
} |
|||
}, [checkSession, updateLastActivity]) |
|||
|
|||
return null |
|||
} |
@ -0,0 +1,73 @@ |
|||
import { createContext, useContext, useEffect, useState } from "react" |
|||
|
|||
type Theme = "dark" | "light" | "system" |
|||
|
|||
type ThemeProviderProps = { |
|||
children: React.ReactNode |
|||
defaultTheme?: Theme |
|||
storageKey?: string |
|||
} |
|||
|
|||
type ThemeProviderState = { |
|||
theme: Theme |
|||
setTheme: (theme: Theme) => void |
|||
} |
|||
|
|||
const initialState: ThemeProviderState = { |
|||
theme: "system", |
|||
setTheme: () => null, |
|||
} |
|||
|
|||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState) |
|||
|
|||
export function ThemeProvider({ |
|||
children, |
|||
defaultTheme = "system", |
|||
storageKey = "vite-ui-theme", |
|||
...props |
|||
}: ThemeProviderProps) { |
|||
const [theme, setTheme] = useState<Theme>( |
|||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme |
|||
) |
|||
|
|||
useEffect(() => { |
|||
const root = window.document.documentElement |
|||
|
|||
root.classList.remove("light", "dark") |
|||
|
|||
if (theme === "system") { |
|||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") |
|||
.matches |
|||
? "dark" |
|||
: "light" |
|||
|
|||
root.classList.add(systemTheme) |
|||
return |
|||
} |
|||
|
|||
root.classList.add(theme) |
|||
}, [theme]) |
|||
|
|||
const value = { |
|||
theme, |
|||
setTheme: (theme: Theme) => { |
|||
localStorage.setItem(storageKey, theme) |
|||
setTheme(theme) |
|||
}, |
|||
} |
|||
|
|||
return ( |
|||
<ThemeProviderContext.Provider {...props} value={value}> |
|||
{children} |
|||
</ThemeProviderContext.Provider> |
|||
) |
|||
} |
|||
|
|||
export const useTheme = () => { |
|||
const context = useContext(ThemeProviderContext) |
|||
|
|||
if (context === undefined) |
|||
throw new Error("useTheme must be used within a ThemeProvider") |
|||
|
|||
return context |
|||
} |
@ -0,0 +1,56 @@ |
|||
import * as React from "react" |
|||
import { Slot } from "@radix-ui/react-slot" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const buttonVariants = cva( |
|||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: |
|||
"bg-primary text-primary-foreground shadow hover:bg-primary/90", |
|||
destructive: |
|||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", |
|||
outline: |
|||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", |
|||
secondary: |
|||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", |
|||
ghost: "hover:bg-accent hover:text-accent-foreground", |
|||
link: "text-primary underline-offset-4 hover:underline", |
|||
}, |
|||
size: { |
|||
default: "h-9 px-4 py-2", |
|||
sm: "h-8 rounded-md px-3 text-xs", |
|||
lg: "h-10 rounded-md px-8", |
|||
icon: "h-9 w-9", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
size: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
export interface ButtonProps |
|||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, |
|||
VariantProps<typeof buttonVariants> { |
|||
asChild?: boolean |
|||
} |
|||
|
|||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( |
|||
({ className, variant, size, asChild = false, ...props }, ref) => { |
|||
const Comp = asChild ? Slot : "button" |
|||
return ( |
|||
<Comp |
|||
className={cn(buttonVariants({ variant, size, className }))} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Button.displayName = "Button" |
|||
|
|||
export { Button, buttonVariants } |
@ -0,0 +1,28 @@ |
|||
import * as React from 'react' |
|||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox' |
|||
import { Check } from 'lucide-react' |
|||
|
|||
import { cn } from '@/lib/utils' |
|||
|
|||
const Checkbox = React.forwardRef< |
|||
React.ElementRef<typeof CheckboxPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<CheckboxPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<CheckboxPrimitive.Indicator |
|||
className={cn('flex items-center justify-center text-current')} |
|||
> |
|||
<Check className="h-4 w-4" /> |
|||
</CheckboxPrimitive.Indicator> |
|||
</CheckboxPrimitive.Root> |
|||
)) |
|||
Checkbox.displayName = CheckboxPrimitive.Root.displayName |
|||
|
|||
export { Checkbox } |
@ -0,0 +1,25 @@ |
|||
import * as React from 'react' |
|||
|
|||
import { cn } from '@/lib/utils' |
|||
|
|||
export interface InputProps |
|||
extends React.InputHTMLAttributes<HTMLInputElement> {} |
|||
|
|||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
|||
({ className, type, ...props }, ref) => { |
|||
return ( |
|||
<input |
|||
type={type} |
|||
className={cn( |
|||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', |
|||
className |
|||
)} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Input.displayName = 'Input' |
|||
|
|||
export { Input } |
@ -0,0 +1,54 @@ |
|||
import * as React from 'react' |
|||
import * as SelectPrimitive from '@radix-ui/react-select' |
|||
import { Check, ChevronDown } from 'lucide-react' |
|||
|
|||
import { cn } from '@/lib/utils' |
|||
|
|||
interface SelectProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root> { |
|||
options: { value: string; label: string }[] |
|||
} |
|||
|
|||
const Select = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Root>, |
|||
SelectProps |
|||
>(({ className, options, ...props }, ref) => ( |
|||
<SelectPrimitive.Root {...props}> |
|||
<SelectPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn( |
|||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50', |
|||
className |
|||
)} |
|||
> |
|||
<SelectPrimitive.Value /> |
|||
<SelectPrimitive.Icon asChild> |
|||
<ChevronDown className="h-4 w-4 opacity-50" /> |
|||
</SelectPrimitive.Icon> |
|||
</SelectPrimitive.Trigger> |
|||
<SelectPrimitive.Portal> |
|||
<SelectPrimitive.Content |
|||
className="relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2" |
|||
> |
|||
<SelectPrimitive.Viewport className="p-1"> |
|||
{options.map((option) => ( |
|||
<SelectPrimitive.Item |
|||
key={option.value} |
|||
value={option.value} |
|||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50" |
|||
> |
|||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> |
|||
<SelectPrimitive.ItemIndicator> |
|||
<Check className="h-4 w-4" /> |
|||
</SelectPrimitive.ItemIndicator> |
|||
</span> |
|||
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText> |
|||
</SelectPrimitive.Item> |
|||
))} |
|||
</SelectPrimitive.Viewport> |
|||
</SelectPrimitive.Content> |
|||
</SelectPrimitive.Portal> |
|||
</SelectPrimitive.Root> |
|||
)) |
|||
Select.displayName = SelectPrimitive.Root.displayName |
|||
|
|||
export { Select } |
@ -0,0 +1,27 @@ |
|||
import { useAuthStore } from '@/store/auth' |
|||
import { Permission } from '@/types/auth' |
|||
|
|||
export function usePermissions() { |
|||
const user = useAuthStore((state) => state.user) |
|||
|
|||
const hasPermission = (permission: Permission): boolean => { |
|||
if (!user) return false |
|||
return user.permissions.includes(permission) |
|||
} |
|||
|
|||
const hasAnyPermission = (permissions: Permission[]): boolean => { |
|||
if (!user) return false |
|||
return permissions.some((permission) => user.permissions.includes(permission)) |
|||
} |
|||
|
|||
const hasAllPermissions = (permissions: Permission[]): boolean => { |
|||
if (!user) return false |
|||
return permissions.every((permission) => user.permissions.includes(permission)) |
|||
} |
|||
|
|||
return { |
|||
hasPermission, |
|||
hasAnyPermission, |
|||
hasAllPermissions, |
|||
} |
|||
} |
@ -0,0 +1,37 @@ |
|||
import { useEffect, useState } from 'react' |
|||
|
|||
type Theme = 'light' | 'dark' | 'system' |
|||
|
|||
export function useTheme() { |
|||
const [theme, setTheme] = useState<Theme>(() => { |
|||
if (typeof window !== 'undefined') { |
|||
return (localStorage.getItem('theme') as Theme) || 'system' |
|||
} |
|||
return 'system' |
|||
}) |
|||
|
|||
useEffect(() => { |
|||
const root = window.document.documentElement |
|||
root.classList.remove('light', 'dark') |
|||
|
|||
if (theme === 'system') { |
|||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches |
|||
? 'dark' |
|||
: 'light' |
|||
root.classList.add(systemTheme) |
|||
return |
|||
} |
|||
|
|||
root.classList.add(theme) |
|||
}, [theme]) |
|||
|
|||
const updateTheme = (newTheme: Theme) => { |
|||
setTheme(newTheme) |
|||
localStorage.setItem('theme', newTheme) |
|||
} |
|||
|
|||
return { |
|||
theme, |
|||
setTheme: updateTheme, |
|||
} |
|||
} |
@ -0,0 +1,35 @@ |
|||
import i18n from 'i18next' |
|||
import { initReactI18next } from 'react-i18next' |
|||
import { zhCN } from './translations/zh-CN' |
|||
import { enUS } from './translations/en-US' |
|||
|
|||
const resources = { |
|||
'zh-CN': { |
|||
translation: zhCN, |
|||
}, |
|||
'en-US': { |
|||
translation: enUS, |
|||
}, |
|||
} |
|||
|
|||
i18n |
|||
.use(initReactI18next) |
|||
.init({ |
|||
resources, |
|||
lng: localStorage.getItem('language') || 'zh-CN', |
|||
fallbackLng: 'zh-CN', |
|||
interpolation: { |
|||
escapeValue: false, |
|||
}, |
|||
}) |
|||
|
|||
export default i18n |
|||
|
|||
export const languages = [ |
|||
{ value: 'zh-CN', label: '简体中文' }, |
|||
{ value: 'en-US', label: 'English' }, |
|||
] |
|||
|
|||
export function getLanguageLabel(value: string): string { |
|||
return languages.find(lang => lang.value === value)?.label || value |
|||
} |
@ -0,0 +1,158 @@ |
|||
export const enUS = { |
|||
common: { |
|||
loading: 'Loading...', |
|||
save: 'Save', |
|||
saving: 'Saving...', |
|||
cancel: 'Cancel', |
|||
confirm: 'Confirm', |
|||
delete: 'Delete', |
|||
edit: 'Edit', |
|||
search: 'Search', |
|||
noData: 'No data available', |
|||
success: 'Operation successful', |
|||
error: 'Operation failed', |
|||
create: 'Create', |
|||
filter: 'Filter', |
|||
required: 'Required', |
|||
}, |
|||
auth: { |
|||
login: 'Login', |
|||
register: 'Register', |
|||
logout: 'Logout', |
|||
email: 'Email', |
|||
password: 'Password', |
|||
confirmPassword: 'Confirm Password', |
|||
rememberMe: 'Remember me', |
|||
forgotPassword: 'Forgot password?', |
|||
noAccount: 'Don\'t have an account?', |
|||
hasAccount: 'Already have an account?', |
|||
loginSuccess: 'Login successful', |
|||
loginError: 'Login failed, please check your credentials', |
|||
registerSuccess: 'Registration successful', |
|||
registerError: 'Registration failed, please try again later', |
|||
logoutSuccess: 'Logged out successfully', |
|||
enterCredentials: 'Enter your login credentials', |
|||
createAccount: 'Create your account', |
|||
name: 'Name', |
|||
requestPasswordReset: 'Request Password Reset', |
|||
resetPassword: 'Reset Password', |
|||
resetPasswordSuccess: 'Password reset successful', |
|||
resetPasswordError: 'Password reset failed, please try again later', |
|||
requestResetSuccess: 'Password reset email sent', |
|||
requestResetError: 'Failed to send password reset email', |
|||
enterEmail: 'Enter your email address', |
|||
enterNewPassword: 'Enter new password', |
|||
confirmNewPassword: 'Confirm new password', |
|||
invalidToken: 'Invalid reset token', |
|||
userNameOrEmail: 'Username or Email', |
|||
userNameOrEmailPlaceholder: 'Enter username or email', |
|||
passwordPlaceholder: 'Enter password', |
|||
confirmPasswordPlaceholder: 'Enter password again', |
|||
haveAccount: 'Already have an account?', |
|||
forgotPasswordSuccess: 'Password reset email sent', |
|||
forgotPasswordError: 'Failed to send password reset email, please try again later', |
|||
}, |
|||
settings: { |
|||
title: 'Settings', |
|||
description: 'Manage your account settings and preferences', |
|||
appearance: 'Appearance', |
|||
theme: 'Theme', |
|||
light: 'Light', |
|||
dark: 'Dark', |
|||
system: 'System', |
|||
language: 'Language', |
|||
timezone: 'Timezone', |
|||
notifications: 'Notifications', |
|||
emailNotifications: 'Email notifications', |
|||
pushNotifications: 'Push notifications', |
|||
profile: 'Profile', |
|||
displayName: 'Display name', |
|||
displayNamePlaceholder: 'Enter your display name', |
|||
saveChanges: 'Save changes', |
|||
savingChanges: 'Saving...', |
|||
settingsUpdated: 'Settings updated', |
|||
settingsUpdateError: 'Failed to update settings', |
|||
settingsLoadError: 'Failed to load settings', |
|||
general: 'General Settings', |
|||
account: 'Account Settings', |
|||
security: 'Security Settings', |
|||
phoneNumber: 'Phone Number', |
|||
changePassword: 'Change Password', |
|||
currentPassword: 'Current Password', |
|||
newPassword: 'New Password', |
|||
confirmNewPassword: 'Confirm New Password', |
|||
saveSuccess: 'Settings saved successfully', |
|||
saveError: 'Failed to save settings, please try again later', |
|||
}, |
|||
navigation: { |
|||
home: 'Home', |
|||
profile: 'Profile', |
|||
settings: 'Settings', |
|||
users: 'User Management', |
|||
roles: 'Role Management', |
|||
}, |
|||
validation: { |
|||
required: 'This field is required', |
|||
email: 'Please enter a valid email address', |
|||
passwordMinLength: 'Password must be at least 6 characters', |
|||
passwordMismatch: 'Passwords do not match', |
|||
displayNameMinLength: 'Display name must be at least 2 characters', |
|||
passwordMatch: 'Passwords do not match', |
|||
phoneNumber: 'Please enter a valid phone number', |
|||
userNameOrEmail: 'Please enter username or email', |
|||
}, |
|||
profile: { |
|||
title: 'Profile', |
|||
basicInfo: 'Basic Information', |
|||
security: 'Security Settings', |
|||
activity: 'Activity Log', |
|||
lastLogin: 'Last Login', |
|||
lastLoginTime: 'Last Login Time', |
|||
lastLoginIP: 'Last Login IP', |
|||
lastLoginLocation: 'Last Login Location', |
|||
saveSuccess: 'Profile saved successfully', |
|||
saveError: 'Failed to save profile, please try again later', |
|||
}, |
|||
users: { |
|||
title: 'User Management', |
|||
create: 'Create User', |
|||
edit: 'Edit User', |
|||
delete: 'Delete User', |
|||
search: 'Search Users', |
|||
filter: 'Filter Users', |
|||
noUsers: 'No users found', |
|||
userName: 'Username', |
|||
email: 'Email', |
|||
phoneNumber: 'Phone Number', |
|||
role: 'Role', |
|||
status: 'Status', |
|||
lastLogin: 'Last Login', |
|||
actions: 'Actions', |
|||
createSuccess: 'User created successfully', |
|||
createError: 'Failed to create user, please try again later', |
|||
updateSuccess: 'User updated successfully', |
|||
updateError: 'Failed to update user, please try again later', |
|||
deleteSuccess: 'User deleted successfully', |
|||
deleteError: 'Failed to delete user, please try again later', |
|||
}, |
|||
roles: { |
|||
title: 'Role Management', |
|||
create: 'Create Role', |
|||
edit: 'Edit Role', |
|||
delete: 'Delete Role', |
|||
search: 'Search Roles', |
|||
filter: 'Filter Roles', |
|||
noRoles: 'No roles found', |
|||
name: 'Role Name', |
|||
description: 'Description', |
|||
permissions: 'Permissions', |
|||
users: 'Users', |
|||
actions: 'Actions', |
|||
createSuccess: 'Role created successfully', |
|||
createError: 'Failed to create role, please try again later', |
|||
updateSuccess: 'Role updated successfully', |
|||
updateError: 'Failed to update role, please try again later', |
|||
deleteSuccess: 'Role deleted successfully', |
|||
deleteError: 'Failed to delete role, please try again later', |
|||
}, |
|||
} |
@ -0,0 +1,156 @@ |
|||
export const zhCN = { |
|||
common: { |
|||
loading: '加载中...', |
|||
save: '保存', |
|||
saving: '保存中...', |
|||
cancel: '取消', |
|||
confirm: '确认', |
|||
delete: '删除', |
|||
edit: '编辑', |
|||
search: '搜索', |
|||
noData: '暂无数据', |
|||
success: '操作成功', |
|||
error: '操作失败', |
|||
create: '创建', |
|||
filter: '筛选', |
|||
required: '必填项', |
|||
}, |
|||
auth: { |
|||
login: '登录', |
|||
register: '注册', |
|||
logout: '退出登录', |
|||
email: '邮箱', |
|||
password: '密码', |
|||
confirmPassword: '确认密码', |
|||
rememberMe: '记住我', |
|||
forgotPassword: '忘记密码?', |
|||
noAccount: '还没有账号?', |
|||
hasAccount: '已有账号?', |
|||
loginSuccess: '登录成功', |
|||
loginError: '登录失败,请检查用户名和密码', |
|||
registerSuccess: '注册成功', |
|||
registerError: '注册失败,请稍后重试', |
|||
logoutSuccess: '已退出登录', |
|||
enterCredentials: '请输入您的登录信息', |
|||
createAccount: '创建您的账号', |
|||
name: '姓名', |
|||
requestPasswordReset: '请求重置密码', |
|||
resetPassword: '重置密码', |
|||
resetPasswordSuccess: '重置密码成功', |
|||
resetPasswordError: '重置密码失败,请稍后重试', |
|||
requestResetSuccess: '重置密码邮件已发送', |
|||
requestResetError: '发送重置密码邮件失败', |
|||
enterEmail: '请输入您的邮箱地址', |
|||
enterNewPassword: '请输入新密码', |
|||
confirmNewPassword: '请确认新密码', |
|||
invalidToken: '无效的重置令牌', |
|||
userNameOrEmail: '用户名或邮箱', |
|||
userNameOrEmailPlaceholder: '请输入用户名或邮箱', |
|||
passwordPlaceholder: '请输入密码', |
|||
confirmPasswordPlaceholder: '请再次输入密码', |
|||
haveAccount: '已有账号?', |
|||
forgotPasswordSuccess: '重置密码邮件已发送', |
|||
forgotPasswordError: '发送重置密码邮件失败,请稍后重试', |
|||
}, |
|||
settings: { |
|||
title: '设置', |
|||
description: '管理您的账户设置和偏好', |
|||
appearance: '外观', |
|||
theme: '主题', |
|||
light: '浅色', |
|||
dark: '深色', |
|||
system: '跟随系统', |
|||
language: '语言', |
|||
timezone: '时区', |
|||
notifications: '通知', |
|||
emailNotifications: '接收邮件通知', |
|||
pushNotifications: '接收推送通知', |
|||
profile: '个人资料', |
|||
displayName: '显示名称', |
|||
displayNamePlaceholder: '输入您的显示名称', |
|||
saveChanges: '保存更改', |
|||
savingChanges: '保存中...', |
|||
settingsUpdated: '设置已更新', |
|||
settingsUpdateError: '更新设置失败', |
|||
settingsLoadError: '加载设置失败', |
|||
general: '常规设置', |
|||
account: '账户设置', |
|||
security: '安全设置', |
|||
phoneNumber: '手机号码', |
|||
changePassword: '修改密码', |
|||
currentPassword: '当前密码', |
|||
newPassword: '新密码', |
|||
saveSuccess: '设置保存成功', |
|||
saveError: '设置保存失败,请稍后重试', |
|||
}, |
|||
navigation: { |
|||
home: '首页', |
|||
profile: '个人资料', |
|||
settings: '设置', |
|||
users: '用户管理', |
|||
roles: '角色管理', |
|||
}, |
|||
validation: { |
|||
required: '此字段为必填项', |
|||
email: '请输入有效的邮箱地址', |
|||
passwordMinLength: '密码长度至少为6个字符', |
|||
passwordMatch: '两次输入的密码不一致', |
|||
phoneNumber: '请输入有效的手机号码', |
|||
userNameOrEmail: '请输入用户名或邮箱', |
|||
displayNameMinLength: '显示名称至少需要2个字符', |
|||
}, |
|||
profile: { |
|||
title: '个人资料', |
|||
basicInfo: '基本信息', |
|||
security: '安全设置', |
|||
activity: '活动记录', |
|||
lastLogin: '最后登录', |
|||
lastLoginTime: '最后登录时间', |
|||
lastLoginIP: '最后登录IP', |
|||
lastLoginLocation: '最后登录地点', |
|||
saveSuccess: '个人资料保存成功', |
|||
saveError: '个人资料保存失败,请稍后重试', |
|||
}, |
|||
users: { |
|||
title: '用户管理', |
|||
create: '创建用户', |
|||
edit: '编辑用户', |
|||
delete: '删除用户', |
|||
search: '搜索用户', |
|||
filter: '筛选用户', |
|||
noUsers: '暂无用户', |
|||
userName: '用户名', |
|||
email: '邮箱', |
|||
phoneNumber: '手机号码', |
|||
role: '角色', |
|||
status: '状态', |
|||
lastLogin: '最后登录', |
|||
actions: '操作', |
|||
createSuccess: '用户创建成功', |
|||
createError: '用户创建失败,请稍后重试', |
|||
updateSuccess: '用户更新成功', |
|||
updateError: '用户更新失败,请稍后重试', |
|||
deleteSuccess: '用户删除成功', |
|||
deleteError: '用户删除失败,请稍后重试', |
|||
}, |
|||
roles: { |
|||
title: '角色管理', |
|||
create: '创建角色', |
|||
edit: '编辑角色', |
|||
delete: '删除角色', |
|||
search: '搜索角色', |
|||
filter: '筛选角色', |
|||
noRoles: '暂无角色', |
|||
name: '角色名称', |
|||
description: '角色描述', |
|||
permissions: '权限', |
|||
users: '用户数', |
|||
actions: '操作', |
|||
createSuccess: '角色创建成功', |
|||
createError: '角色创建失败,请稍后重试', |
|||
updateSuccess: '角色更新成功', |
|||
updateError: '角色更新失败,请稍后重试', |
|||
deleteSuccess: '角色删除成功', |
|||
deleteError: '角色删除失败,请稍后重试', |
|||
}, |
|||
} |
@ -0,0 +1,76 @@ |
|||
@tailwind base; |
|||
@tailwind components; |
|||
@tailwind utilities; |
|||
|
|||
@layer base { |
|||
:root { |
|||
--background: 0 0% 100%; |
|||
--foreground: 222.2 84% 4.9%; |
|||
|
|||
--card: 0 0% 100%; |
|||
--card-foreground: 222.2 84% 4.9%; |
|||
|
|||
--popover: 0 0% 100%; |
|||
--popover-foreground: 222.2 84% 4.9%; |
|||
|
|||
--primary: 222.2 47.4% 11.2%; |
|||
--primary-foreground: 210 40% 98%; |
|||
|
|||
--secondary: 210 40% 96.1%; |
|||
--secondary-foreground: 222.2 47.4% 11.2%; |
|||
|
|||
--muted: 210 40% 96.1%; |
|||
--muted-foreground: 215.4 16.3% 46.9%; |
|||
|
|||
--accent: 210 40% 96.1%; |
|||
--accent-foreground: 222.2 47.4% 11.2%; |
|||
|
|||
--destructive: 0 84.2% 60.2%; |
|||
--destructive-foreground: 210 40% 98%; |
|||
|
|||
--border: 214.3 31.8% 91.4%; |
|||
--input: 214.3 31.8% 91.4%; |
|||
--ring: 222.2 84% 4.9%; |
|||
|
|||
--radius: 0.5rem; |
|||
} |
|||
|
|||
.dark { |
|||
--background: 222.2 84% 4.9%; |
|||
--foreground: 210 40% 98%; |
|||
|
|||
--card: 222.2 84% 4.9%; |
|||
--card-foreground: 210 40% 98%; |
|||
|
|||
--popover: 222.2 84% 4.9%; |
|||
--popover-foreground: 210 40% 98%; |
|||
|
|||
--primary: 210 40% 98%; |
|||
--primary-foreground: 222.2 47.4% 11.2%; |
|||
|
|||
--secondary: 217.2 32.6% 17.5%; |
|||
--secondary-foreground: 210 40% 98%; |
|||
|
|||
--muted: 217.2 32.6% 17.5%; |
|||
--muted-foreground: 215 20.2% 65.1%; |
|||
|
|||
--accent: 217.2 32.6% 17.5%; |
|||
--accent-foreground: 210 40% 98%; |
|||
|
|||
--destructive: 0 62.8% 30.6%; |
|||
--destructive-foreground: 210 40% 98%; |
|||
|
|||
--border: 217.2 32.6% 17.5%; |
|||
--input: 217.2 32.6% 17.5%; |
|||
--ring: 212.7 26.8% 83.9%; |
|||
} |
|||
} |
|||
|
|||
@layer base { |
|||
* { |
|||
@apply border-border; |
|||
} |
|||
body { |
|||
@apply bg-background text-foreground; |
|||
} |
|||
} |
@ -0,0 +1,91 @@ |
|||
import axios from 'axios' |
|||
import { useAuthStore } from '@/store/auth' |
|||
|
|||
export const apiClient = axios.create({ |
|||
baseURL: import.meta.env.VITE_API_URL || 'https://localhost:7268/api', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
}) |
|||
|
|||
// 检查令牌是否过期
|
|||
const isTokenExpired = (expiresAt: string | null): boolean => { |
|||
if (!expiresAt) return true |
|||
const expirationTime = new Date(expiresAt).getTime() |
|||
const currentTime = new Date().getTime() |
|||
// 提前 30 秒刷新令牌
|
|||
return currentTime >= expirationTime - 30000 |
|||
} |
|||
|
|||
// 令牌刷新重试配置
|
|||
const MAX_RETRY_ATTEMPTS = 3 |
|||
const RETRY_DELAY = 1000 // 1秒
|
|||
|
|||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) |
|||
|
|||
apiClient.interceptors.request.use( |
|||
async (config) => { |
|||
const { accessToken, expiresAt, refreshAuthToken } = useAuthStore.getState() |
|||
|
|||
// 如果令牌即将过期,尝试刷新
|
|||
if (accessToken && isTokenExpired(expiresAt)) { |
|||
let retryCount = 0 |
|||
let success = false |
|||
|
|||
while (retryCount < MAX_RETRY_ATTEMPTS && !success) { |
|||
try { |
|||
await refreshAuthToken() |
|||
success = true |
|||
} catch (error) { |
|||
retryCount++ |
|||
if (retryCount === MAX_RETRY_ATTEMPTS) { |
|||
useAuthStore.getState().logout() |
|||
return Promise.reject(error) |
|||
} |
|||
await sleep(RETRY_DELAY * retryCount) // 指数退避
|
|||
} |
|||
} |
|||
} |
|||
|
|||
const { accessToken: newToken } = useAuthStore.getState() |
|||
if (newToken) { |
|||
config.headers.Authorization = `Bearer ${newToken}` |
|||
} |
|||
return config |
|||
}, |
|||
(error) => { |
|||
return Promise.reject(error) |
|||
} |
|||
) |
|||
|
|||
apiClient.interceptors.response.use( |
|||
(response) => response, |
|||
async (error) => { |
|||
const originalRequest = error.config |
|||
|
|||
if (error.response?.status === 401 && !originalRequest._retry) { |
|||
originalRequest._retry = true |
|||
|
|||
let retryCount = 0 |
|||
let success = false |
|||
|
|||
while (retryCount < MAX_RETRY_ATTEMPTS && !success) { |
|||
try { |
|||
await useAuthStore.getState().refreshAuthToken() |
|||
const { accessToken } = useAuthStore.getState() |
|||
originalRequest.headers.Authorization = `Bearer ${accessToken}` |
|||
return apiClient(originalRequest) |
|||
} catch (refreshError) { |
|||
retryCount++ |
|||
if (retryCount === MAX_RETRY_ATTEMPTS) { |
|||
useAuthStore.getState().logout() |
|||
return Promise.reject(refreshError) |
|||
} |
|||
await sleep(RETRY_DELAY * retryCount) // 指数退避
|
|||
} |
|||
} |
|||
} |
|||
|
|||
return Promise.reject(error) |
|||
} |
|||
) |
@ -0,0 +1,35 @@ |
|||
import { AxiosError } from 'axios' |
|||
import { toast } from 'react-hot-toast' |
|||
import { ApiResponse } from '@/types/api' |
|||
|
|||
export const handleApiError = (error: unknown) => { |
|||
if (error instanceof AxiosError) { |
|||
const response = error.response?.data as ApiResponse<unknown> |
|||
|
|||
if (response?.errorMessages?.length) { |
|||
response.errorMessages.forEach((message) => { |
|||
toast.error(message) |
|||
}) |
|||
} else { |
|||
toast.error('发生错误,请稍后重试') |
|||
} |
|||
} else { |
|||
toast.error('发生未知错误,请稍后重试') |
|||
} |
|||
} |
|||
|
|||
export const handleApiSuccess = (message: string) => { |
|||
toast.success(message) |
|||
} |
|||
|
|||
export const isApiError = (error: unknown): error is AxiosError => { |
|||
return error instanceof AxiosError |
|||
} |
|||
|
|||
export const getErrorMessage = (error: unknown): string => { |
|||
if (error instanceof AxiosError) { |
|||
const response = error.response?.data as ApiResponse<unknown> |
|||
return response?.errorMessages?.[0] || '发生错误,请稍后重试' |
|||
} |
|||
return '发生未知错误,请稍后重试' |
|||
} |
@ -0,0 +1,6 @@ |
|||
import { type ClassValue, clsx } from "clsx" |
|||
import { twMerge } from "tailwind-merge" |
|||
|
|||
export function cn(...inputs: ClassValue[]) { |
|||
return twMerge(clsx(inputs)) |
|||
} |
@ -0,0 +1,10 @@ |
|||
import { StrictMode } from 'react' |
|||
import { createRoot } from 'react-dom/client' |
|||
import './index.css' |
|||
import App from './App.tsx' |
|||
|
|||
createRoot(document.getElementById('root')!).render( |
|||
<StrictMode> |
|||
<App /> |
|||
</StrictMode>, |
|||
) |
@ -0,0 +1,82 @@ |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { z } from 'zod' |
|||
import { Link } from 'react-router-dom' |
|||
import { useTranslation } from 'react-i18next' |
|||
import { toast } from 'sonner' |
|||
import { Button } from '@/components/ui/button' |
|||
import { authService } from '@/services/auth.service' |
|||
|
|||
export function ForgotPassword() { |
|||
const { t } = useTranslation() |
|||
|
|||
const forgotPasswordSchema = z.object({ |
|||
email: z.string().email(t('validation.email')), |
|||
}) |
|||
|
|||
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema> |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isSubmitting }, |
|||
} = useForm<ForgotPasswordFormData>({ |
|||
resolver: zodResolver(forgotPasswordSchema), |
|||
}) |
|||
|
|||
const onSubmit = async (data: ForgotPasswordFormData) => { |
|||
try { |
|||
await authService.requestPasswordReset(data) |
|||
toast.success(t('auth.requestResetSuccess')) |
|||
} catch (error) { |
|||
toast.error(t('auth.requestResetError')) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> |
|||
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg"> |
|||
<div className="text-center"> |
|||
<h1 className="text-2xl font-bold">{t('auth.requestPasswordReset')}</h1> |
|||
<p className="mt-2 text-sm text-muted-foreground"> |
|||
{t('auth.enterEmail')} |
|||
</p> |
|||
</div> |
|||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="email" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.email')} |
|||
</label> |
|||
<input |
|||
id="email" |
|||
type="email" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('email')} |
|||
/> |
|||
{errors.email && ( |
|||
<p className="text-sm text-destructive">{errors.email.message}</p> |
|||
)} |
|||
</div> |
|||
<Button |
|||
type="submit" |
|||
className="w-full" |
|||
disabled={isSubmitting} |
|||
> |
|||
{isSubmitting ? t('common.loading') : t('auth.requestPasswordReset')} |
|||
</Button> |
|||
<div className="text-center text-sm"> |
|||
<Link |
|||
to="/login" |
|||
className="text-primary hover:underline" |
|||
> |
|||
{t('auth.login')} |
|||
</Link> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,29 @@ |
|||
import { useAuthStore } from '@/store/auth' |
|||
|
|||
export function Home() { |
|||
const { user } = useAuthStore() |
|||
|
|||
return ( |
|||
<div className="flex flex-col items-center justify-center space-y-4"> |
|||
<h1 className="text-4xl font-bold">欢迎使用 Cellular Management</h1> |
|||
<p className="text-muted-foreground"> |
|||
一个现代化的蜂窝网络管理系统 |
|||
</p> |
|||
{user && ( |
|||
<div className="mt-8 rounded-lg border bg-card p-6 shadow-sm"> |
|||
<h2 className="text-xl font-semibold">用户信息</h2> |
|||
<div className="mt-4 space-y-2"> |
|||
<p> |
|||
<span className="font-medium">姓名:</span> |
|||
{user.name} |
|||
</p> |
|||
<p> |
|||
<span className="font-medium">邮箱:</span> |
|||
{user.email} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,149 @@ |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { z } from 'zod' |
|||
import { useNavigate, useLocation, Link } from 'react-router-dom' |
|||
import { useTranslation } from 'react-i18next' |
|||
import { toast } from 'sonner' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Checkbox } from '@/components/ui/checkbox' |
|||
import { useAuthStore } from '@/store/auth' |
|||
import { authService } from '@/services/auth.service' |
|||
import { handleApiError } from '@/lib/error-handler' |
|||
|
|||
export function Login() { |
|||
const { t } = useTranslation() |
|||
const navigate = useNavigate() |
|||
const location = useLocation() |
|||
const login = useAuthStore((state) => state.login) |
|||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/' |
|||
|
|||
const loginSchema = z.object({ |
|||
userNameOrEmail: z.string() |
|||
.min(1, t('validation.required')) |
|||
.refine( |
|||
(value) => { |
|||
// 检查是否是有效的邮箱格式
|
|||
const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i |
|||
// 检查是否是有效的用户名格式(字母、数字、下划线,3-20个字符)
|
|||
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/ |
|||
return emailRegex.test(value) || usernameRegex.test(value) |
|||
}, |
|||
t('validation.userNameOrEmail') |
|||
), |
|||
password: z.string().min(6, t('validation.passwordMinLength')), |
|||
rememberMe: z.boolean().optional(), |
|||
}) |
|||
|
|||
type LoginFormData = z.infer<typeof loginSchema> |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isSubmitting }, |
|||
} = useForm<LoginFormData>({ |
|||
resolver: zodResolver(loginSchema), |
|||
defaultValues: { |
|||
userNameOrEmail: 'zhangsan2024', |
|||
password: 'Zhangsan@2024', |
|||
rememberMe: false, |
|||
}, |
|||
}) |
|||
|
|||
const onSubmit = async (data: LoginFormData) => { |
|||
try { |
|||
const response = await authService.login(data) |
|||
login(response, data.rememberMe ?? false) |
|||
toast.success(response.successMessage || t('auth.loginSuccess')) |
|||
navigate(from, { replace: true }) |
|||
} catch (error) { |
|||
handleApiError(error) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> |
|||
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg"> |
|||
<div className="text-center"> |
|||
<h1 className="text-2xl font-bold">{t('auth.login')}</h1> |
|||
<p className="mt-2 text-sm text-muted-foreground"> |
|||
{t('auth.enterCredentials')} |
|||
</p> |
|||
</div> |
|||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="userNameOrEmail" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.userNameOrEmail')} |
|||
</label> |
|||
<input |
|||
id="userNameOrEmail" |
|||
type="text" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
placeholder={t('auth.userNameOrEmailPlaceholder')} |
|||
{...register('userNameOrEmail')} |
|||
/> |
|||
{errors.userNameOrEmail && ( |
|||
<p className="text-sm text-destructive">{errors.userNameOrEmail.message}</p> |
|||
)} |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<div className="flex items-center justify-between"> |
|||
<label |
|||
htmlFor="password" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.password')} |
|||
</label> |
|||
<Link |
|||
to="/forgot-password" |
|||
className="text-sm text-primary hover:underline" |
|||
> |
|||
{t('auth.forgotPassword')} |
|||
</Link> |
|||
</div> |
|||
<input |
|||
id="password" |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
placeholder={t('auth.passwordPlaceholder')} |
|||
{...register('password')} |
|||
/> |
|||
{errors.password && ( |
|||
<p className="text-sm text-destructive">{errors.password.message}</p> |
|||
)} |
|||
</div> |
|||
<div className="flex items-center space-x-2"> |
|||
<Checkbox |
|||
id="rememberMe" |
|||
{...register('rememberMe')} |
|||
/> |
|||
<label |
|||
htmlFor="rememberMe" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.rememberMe')} |
|||
</label> |
|||
</div> |
|||
<Button |
|||
type="submit" |
|||
className="w-full" |
|||
disabled={isSubmitting} |
|||
> |
|||
{isSubmitting ? t('common.loading') : t('auth.login')} |
|||
</Button> |
|||
<div className="text-center text-sm"> |
|||
<span className="text-muted-foreground">{t('auth.noAccount')}</span>{' '} |
|||
<Link |
|||
to="/register" |
|||
className="text-primary hover:underline" |
|||
> |
|||
{t('auth.register')} |
|||
</Link> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,252 @@ |
|||
import { useEffect, useState } from 'react' |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { z } from 'zod' |
|||
import { toast } from 'sonner' |
|||
import { Button } from '@/components/ui/button' |
|||
import { userService } from '@/services/user.service' |
|||
import { useAuthStore } from '@/store/auth' |
|||
|
|||
const profileSchema = z.object({ |
|||
name: z.string().min(2, '姓名至少需要2个字符'), |
|||
phone: z.string().optional(), |
|||
address: z.string().optional(), |
|||
}) |
|||
|
|||
const passwordSchema = z.object({ |
|||
currentPassword: z.string().min(6, '当前密码至少需要6个字符'), |
|||
newPassword: z.string().min(6, '新密码至少需要6个字符'), |
|||
confirmPassword: z.string(), |
|||
}).refine((data) => data.newPassword === data.confirmPassword, { |
|||
message: '两次输入的新密码不一致', |
|||
path: ['confirmPassword'], |
|||
}) |
|||
|
|||
type ProfileFormData = z.infer<typeof profileSchema> |
|||
type PasswordFormData = z.infer<typeof passwordSchema> |
|||
|
|||
export function Profile() { |
|||
const { user, login } = useAuthStore() |
|||
const [isLoading, setIsLoading] = useState(true) |
|||
const [avatar, setAvatar] = useState<string>() |
|||
|
|||
const { |
|||
register: registerProfile, |
|||
handleSubmit: handleProfileSubmit, |
|||
formState: { errors: profileErrors, isSubmitting: isProfileSubmitting }, |
|||
} = useForm<ProfileFormData>({ |
|||
resolver: zodResolver(profileSchema), |
|||
}) |
|||
|
|||
const { |
|||
register: registerPassword, |
|||
handleSubmit: handlePasswordSubmit, |
|||
formState: { errors: passwordErrors, isSubmitting: isPasswordSubmitting }, |
|||
reset: resetPasswordForm, |
|||
} = useForm<PasswordFormData>({ |
|||
resolver: zodResolver(passwordSchema), |
|||
}) |
|||
|
|||
useEffect(() => { |
|||
loadProfile() |
|||
}, []) |
|||
|
|||
const loadProfile = async () => { |
|||
try { |
|||
const profile = await userService.getProfile() |
|||
setAvatar(profile.avatar) |
|||
// 设置表单默认值
|
|||
Object.entries(profile).forEach(([key, value]) => { |
|||
if (key in profileSchema.shape) { |
|||
registerProfile(key as keyof ProfileFormData, { value }) |
|||
} |
|||
}) |
|||
} catch (error) { |
|||
toast.error('加载用户资料失败') |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
const handleProfileUpdate = async (data: ProfileFormData) => { |
|||
try { |
|||
const updatedProfile = await userService.updateProfile(data) |
|||
login(updatedProfile, useAuthStore.getState().token!) |
|||
toast.success('个人资料更新成功') |
|||
} catch (error) { |
|||
toast.error('更新个人资料失败') |
|||
} |
|||
} |
|||
|
|||
const handlePasswordChange = async (data: PasswordFormData) => { |
|||
try { |
|||
await userService.changePassword({ |
|||
currentPassword: data.currentPassword, |
|||
newPassword: data.newPassword, |
|||
}) |
|||
toast.success('密码修改成功') |
|||
resetPasswordForm() |
|||
} catch (error) { |
|||
toast.error('密码修改失败,请检查当前密码是否正确') |
|||
} |
|||
} |
|||
|
|||
const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => { |
|||
const file = event.target.files?.[0] |
|||
if (!file) return |
|||
|
|||
try { |
|||
const { url } = await userService.uploadAvatar(file) |
|||
setAvatar(url) |
|||
await userService.updateProfile({ avatar: url }) |
|||
toast.success('头像更新成功') |
|||
} catch (error) { |
|||
toast.error('头像上传失败') |
|||
} |
|||
} |
|||
|
|||
if (isLoading) { |
|||
return <div className="flex justify-center p-8">加载中...</div> |
|||
} |
|||
|
|||
return ( |
|||
<div className="container max-w-2xl py-8"> |
|||
<h1 className="mb-8 text-2xl font-bold">个人资料</h1> |
|||
|
|||
<div className="space-y-8"> |
|||
{/* 头像上传 */} |
|||
<div className="flex items-center space-x-4"> |
|||
<div className="relative h-24 w-24 overflow-hidden rounded-full"> |
|||
<img |
|||
src={avatar || `https://ui-avatars.com/api/?name=${user?.name}`} |
|||
alt="头像" |
|||
className="h-full w-full object-cover" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<input |
|||
type="file" |
|||
accept="image/*" |
|||
onChange={handleAvatarChange} |
|||
className="hidden" |
|||
id="avatar-upload" |
|||
/> |
|||
<label htmlFor="avatar-upload"> |
|||
<Button variant="outline" asChild> |
|||
<span>更换头像</span> |
|||
</Button> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 基本信息表单 */} |
|||
<form onSubmit={handleProfileSubmit(handleProfileUpdate)} className="space-y-6"> |
|||
<h2 className="text-xl font-semibold">基本信息</h2> |
|||
<div className="space-y-4"> |
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">姓名</label> |
|||
<input |
|||
type="text" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...registerProfile('name')} |
|||
/> |
|||
{profileErrors.name && ( |
|||
<p className="text-sm text-destructive">{profileErrors.name.message}</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">邮箱</label> |
|||
<input |
|||
type="email" |
|||
value={user?.email} |
|||
disabled |
|||
className="flex h-9 w-full rounded-md border border-input bg-muted px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
/> |
|||
</div> |
|||
|
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">手机号码</label> |
|||
<input |
|||
type="tel" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...registerProfile('phone')} |
|||
/> |
|||
{profileErrors.phone && ( |
|||
<p className="text-sm text-destructive">{profileErrors.phone.message}</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">地址</label> |
|||
<input |
|||
type="text" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...registerProfile('address')} |
|||
/> |
|||
{profileErrors.address && ( |
|||
<p className="text-sm text-destructive">{profileErrors.address.message}</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
<Button type="submit" disabled={isProfileSubmitting}> |
|||
{isProfileSubmitting ? '保存中...' : '保存修改'} |
|||
</Button> |
|||
</form> |
|||
|
|||
{/* 修改密码表单 */} |
|||
<form onSubmit={handlePasswordSubmit(handlePasswordChange)} className="space-y-6"> |
|||
<h2 className="text-xl font-semibold">修改密码</h2> |
|||
<div className="space-y-4"> |
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">当前密码</label> |
|||
<input |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...registerPassword('currentPassword')} |
|||
/> |
|||
{passwordErrors.currentPassword && ( |
|||
<p className="text-sm text-destructive"> |
|||
{passwordErrors.currentPassword.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">新密码</label> |
|||
<input |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...registerPassword('newPassword')} |
|||
/> |
|||
{passwordErrors.newPassword && ( |
|||
<p className="text-sm text-destructive"> |
|||
{passwordErrors.newPassword.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">确认新密码</label> |
|||
<input |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...registerPassword('confirmPassword')} |
|||
/> |
|||
{passwordErrors.confirmPassword && ( |
|||
<p className="text-sm text-destructive"> |
|||
{passwordErrors.confirmPassword.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
<Button type="submit" disabled={isPasswordSubmitting}> |
|||
{isPasswordSubmitting ? '修改中...' : '修改密码'} |
|||
</Button> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,142 @@ |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { z } from 'zod' |
|||
import { useNavigate, Link } from 'react-router-dom' |
|||
import { useTranslation } from 'react-i18next' |
|||
import { toast } from 'sonner' |
|||
import { Button } from '@/components/ui/button' |
|||
import { authService } from '@/services/auth.service' |
|||
|
|||
export function Register() { |
|||
const { t } = useTranslation() |
|||
const navigate = useNavigate() |
|||
|
|||
const registerSchema = z.object({ |
|||
name: z.string().min(2, t('validation.displayNameMinLength')), |
|||
email: z.string().email(t('validation.email')), |
|||
password: z.string().min(6, t('validation.passwordMinLength')), |
|||
confirmPassword: z.string(), |
|||
}).refine((data) => data.password === data.confirmPassword, { |
|||
message: t('validation.passwordMismatch'), |
|||
path: ['confirmPassword'], |
|||
}) |
|||
|
|||
type RegisterFormData = z.infer<typeof registerSchema> |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isSubmitting }, |
|||
} = useForm<RegisterFormData>({ |
|||
resolver: zodResolver(registerSchema), |
|||
}) |
|||
|
|||
const onSubmit = async (data: RegisterFormData) => { |
|||
try { |
|||
await authService.register(data) |
|||
toast.success(t('auth.registerSuccess')) |
|||
navigate('/login') |
|||
} catch (error) { |
|||
toast.error(t('auth.registerError')) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> |
|||
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg"> |
|||
<div className="text-center"> |
|||
<h1 className="text-2xl font-bold">{t('auth.register')}</h1> |
|||
<p className="mt-2 text-sm text-muted-foreground"> |
|||
{t('auth.createAccount')} |
|||
</p> |
|||
</div> |
|||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="name" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.name')} |
|||
</label> |
|||
<input |
|||
id="name" |
|||
type="text" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('name')} |
|||
/> |
|||
{errors.name && ( |
|||
<p className="text-sm text-destructive">{errors.name.message}</p> |
|||
)} |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="email" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.email')} |
|||
</label> |
|||
<input |
|||
id="email" |
|||
type="email" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('email')} |
|||
/> |
|||
{errors.email && ( |
|||
<p className="text-sm text-destructive">{errors.email.message}</p> |
|||
)} |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="password" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.password')} |
|||
</label> |
|||
<input |
|||
id="password" |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('password')} |
|||
/> |
|||
{errors.password && ( |
|||
<p className="text-sm text-destructive">{errors.password.message}</p> |
|||
)} |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="confirmPassword" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.confirmPassword')} |
|||
</label> |
|||
<input |
|||
id="confirmPassword" |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('confirmPassword')} |
|||
/> |
|||
{errors.confirmPassword && ( |
|||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p> |
|||
)} |
|||
</div> |
|||
<Button |
|||
type="submit" |
|||
className="w-full" |
|||
disabled={isSubmitting} |
|||
> |
|||
{isSubmitting ? t('common.loading') : t('auth.register')} |
|||
</Button> |
|||
<div className="text-center text-sm"> |
|||
<span className="text-muted-foreground">{t('auth.hasAccount')}</span>{' '} |
|||
<Link |
|||
to="/login" |
|||
className="text-primary hover:underline" |
|||
> |
|||
{t('auth.login')} |
|||
</Link> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,122 @@ |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { z } from 'zod' |
|||
import { useNavigate, useSearchParams } from 'react-router-dom' |
|||
import { useTranslation } from 'react-i18next' |
|||
import { toast } from 'sonner' |
|||
import { Button } from '@/components/ui/button' |
|||
import { authService } from '@/services/auth.service' |
|||
|
|||
export function ResetPassword() { |
|||
const { t } = useTranslation() |
|||
const navigate = useNavigate() |
|||
const [searchParams] = useSearchParams() |
|||
const token = searchParams.get('token') |
|||
|
|||
const resetPasswordSchema = z.object({ |
|||
password: z.string().min(6, t('validation.passwordMinLength')), |
|||
confirmPassword: z.string(), |
|||
}).refine((data) => data.password === data.confirmPassword, { |
|||
message: t('validation.passwordMismatch'), |
|||
path: ['confirmPassword'], |
|||
}) |
|||
|
|||
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema> |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isSubmitting }, |
|||
} = useForm<ResetPasswordFormData>({ |
|||
resolver: zodResolver(resetPasswordSchema), |
|||
}) |
|||
|
|||
const onSubmit = async (data: ResetPasswordFormData) => { |
|||
if (!token) { |
|||
toast.error(t('auth.invalidToken')) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
await authService.resetPassword({ |
|||
token, |
|||
password: data.password, |
|||
confirmPassword: data.confirmPassword, |
|||
}) |
|||
toast.success(t('auth.resetPasswordSuccess')) |
|||
navigate('/login') |
|||
} catch (error) { |
|||
toast.error(t('auth.resetPasswordError')) |
|||
} |
|||
} |
|||
|
|||
if (!token) { |
|||
return ( |
|||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> |
|||
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg"> |
|||
<div className="text-center"> |
|||
<h1 className="text-2xl font-bold text-destructive"> |
|||
{t('auth.invalidToken')} |
|||
</h1> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
return ( |
|||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> |
|||
<div className="w-full max-w-md space-y-8 rounded-lg border bg-card p-8 shadow-lg"> |
|||
<div className="text-center"> |
|||
<h1 className="text-2xl font-bold">{t('auth.resetPassword')}</h1> |
|||
<p className="mt-2 text-sm text-muted-foreground"> |
|||
{t('auth.enterNewPassword')} |
|||
</p> |
|||
</div> |
|||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="password" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.password')} |
|||
</label> |
|||
<input |
|||
id="password" |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('password')} |
|||
/> |
|||
{errors.password && ( |
|||
<p className="text-sm text-destructive">{errors.password.message}</p> |
|||
)} |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<label |
|||
htmlFor="confirmPassword" |
|||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
> |
|||
{t('auth.confirmPassword')} |
|||
</label> |
|||
<input |
|||
id="confirmPassword" |
|||
type="password" |
|||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" |
|||
{...register('confirmPassword')} |
|||
/> |
|||
{errors.confirmPassword && ( |
|||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p> |
|||
)} |
|||
</div> |
|||
<Button |
|||
type="submit" |
|||
className="w-full" |
|||
disabled={isSubmitting} |
|||
> |
|||
{isSubmitting ? t('common.loading') : t('auth.resetPassword')} |
|||
</Button> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,186 @@ |
|||
import { useEffect, useState } from 'react' |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { z } from 'zod' |
|||
import { toast } from 'sonner' |
|||
import { useTranslation } from 'react-i18next' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Checkbox } from '@/components/ui/checkbox' |
|||
import { Select } from '@/components/ui/select' |
|||
import { Input } from '@/components/ui/input' |
|||
import { settingsService, UserSettings } from '@/services/settings.service' |
|||
import { useTheme } from '@/hooks/use-theme' |
|||
import { languages } from '@/i18n' |
|||
|
|||
const timezones = [ |
|||
{ value: 'Asia/Shanghai', label: '中国标准时间 (UTC+8)' }, |
|||
{ value: 'America/New_York', label: '美国东部时间 (UTC-5)' }, |
|||
{ value: 'Europe/London', label: '格林威治标准时间 (UTC+0)' }, |
|||
] |
|||
|
|||
export function Settings() { |
|||
const { t } = useTranslation() |
|||
const [isLoading, setIsLoading] = useState(true) |
|||
const { setTheme } = useTheme() |
|||
|
|||
const settingsSchema = z.object({ |
|||
theme: z.enum(['light', 'dark', 'system']), |
|||
language: z.string(), |
|||
notifications: z.object({ |
|||
email: z.boolean(), |
|||
push: z.boolean(), |
|||
}), |
|||
displayName: z.string().min(2, t('validation.displayNameMinLength')), |
|||
timezone: z.string(), |
|||
}) |
|||
|
|||
type SettingsFormData = z.infer<typeof settingsSchema> |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
reset, |
|||
formState: { errors, isSubmitting }, |
|||
} = useForm<SettingsFormData>({ |
|||
resolver: zodResolver(settingsSchema), |
|||
}) |
|||
|
|||
useEffect(() => { |
|||
loadSettings() |
|||
}, []) |
|||
|
|||
const loadSettings = async () => { |
|||
try { |
|||
const settings = await settingsService.getSettings() |
|||
reset(settings) |
|||
} catch (error) { |
|||
toast.error(t('settings.settingsLoadError')) |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
const onSubmit = async (data: SettingsFormData) => { |
|||
try { |
|||
await settingsService.updateSettings(data) |
|||
setTheme(data.theme) |
|||
toast.success(t('settings.settingsUpdated')) |
|||
} catch (error) { |
|||
toast.error(t('settings.settingsUpdateError')) |
|||
} |
|||
} |
|||
|
|||
if (isLoading) { |
|||
return ( |
|||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> |
|||
<div className="text-center"> |
|||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> |
|||
<p className="mt-2 text-sm text-muted-foreground">{t('common.loading')}</p> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
return ( |
|||
<div className="container max-w-2xl py-8"> |
|||
<div className="space-y-6"> |
|||
<div> |
|||
<h1 className="text-2xl font-bold">{t('settings.title')}</h1> |
|||
<p className="text-sm text-muted-foreground"> |
|||
{t('settings.description')} |
|||
</p> |
|||
</div> |
|||
|
|||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> |
|||
<div className="space-y-4"> |
|||
<h2 className="text-lg font-medium">{t('settings.appearance')}</h2> |
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">{t('settings.theme')}</label> |
|||
<Select |
|||
{...register('theme')} |
|||
options={[ |
|||
{ value: 'light', label: t('settings.light') }, |
|||
{ value: 'dark', label: t('settings.dark') }, |
|||
{ value: 'system', label: t('settings.system') }, |
|||
]} |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="space-y-4"> |
|||
<h2 className="text-lg font-medium">{t('settings.language')}</h2> |
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">{t('settings.language')}</label> |
|||
<Select |
|||
{...register('language')} |
|||
options={languages} |
|||
/> |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">{t('settings.timezone')}</label> |
|||
<Select |
|||
{...register('timezone')} |
|||
options={timezones} |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="space-y-4"> |
|||
<h2 className="text-lg font-medium">{t('settings.notifications')}</h2> |
|||
<div className="space-y-4"> |
|||
<div className="flex items-center space-x-2"> |
|||
<Checkbox |
|||
id="email-notifications" |
|||
{...register('notifications.email')} |
|||
/> |
|||
<label |
|||
htmlFor="email-notifications" |
|||
className="text-sm font-medium" |
|||
> |
|||
{t('settings.emailNotifications')} |
|||
</label> |
|||
</div> |
|||
<div className="flex items-center space-x-2"> |
|||
<Checkbox |
|||
id="push-notifications" |
|||
{...register('notifications.push')} |
|||
/> |
|||
<label |
|||
htmlFor="push-notifications" |
|||
className="text-sm font-medium" |
|||
> |
|||
{t('settings.pushNotifications')} |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="space-y-4"> |
|||
<h2 className="text-lg font-medium">{t('settings.profile')}</h2> |
|||
<div className="space-y-2"> |
|||
<label className="text-sm font-medium">{t('settings.displayName')}</label> |
|||
<Input |
|||
{...register('displayName')} |
|||
placeholder={t('settings.displayNamePlaceholder')} |
|||
/> |
|||
{errors.displayName && ( |
|||
<p className="text-sm text-destructive"> |
|||
{errors.displayName.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="flex justify-end"> |
|||
<Button |
|||
type="submit" |
|||
disabled={isSubmitting} |
|||
> |
|||
{isSubmitting ? t('settings.savingChanges') : t('settings.saveChanges')} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,59 @@ |
|||
import { createBrowserRouter } from 'react-router-dom' |
|||
import { Layout } from '@/components/layout' |
|||
import { Login } from '@/pages/login' |
|||
import { Register } from '@/pages/register' |
|||
import { ForgotPassword } from '@/pages/forgot-password' |
|||
import { ResetPassword } from '@/pages/reset-password' |
|||
import { Home } from '@/pages/home' |
|||
import { Profile } from '@/pages/profile' |
|||
import { Settings } from '@/pages/settings' |
|||
import { ProtectedRoute } from '@/components/protected-route' |
|||
|
|||
export const router = createBrowserRouter([ |
|||
{ |
|||
path: '/', |
|||
element: <Layout />, |
|||
children: [ |
|||
{ |
|||
index: true, |
|||
element: ( |
|||
<ProtectedRoute> |
|||
<Home /> |
|||
</ProtectedRoute> |
|||
), |
|||
}, |
|||
{ |
|||
path: 'login', |
|||
element: <Login />, |
|||
}, |
|||
{ |
|||
path: 'register', |
|||
element: <Register />, |
|||
}, |
|||
{ |
|||
path: 'forgot-password', |
|||
element: <ForgotPassword />, |
|||
}, |
|||
{ |
|||
path: 'reset-password', |
|||
element: <ResetPassword />, |
|||
}, |
|||
{ |
|||
path: 'profile', |
|||
element: ( |
|||
<ProtectedRoute> |
|||
<Profile /> |
|||
</ProtectedRoute> |
|||
), |
|||
}, |
|||
{ |
|||
path: 'settings', |
|||
element: ( |
|||
<ProtectedRoute requiredPermissions={['settings.view']}> |
|||
<Settings /> |
|||
</ProtectedRoute> |
|||
), |
|||
}, |
|||
], |
|||
}, |
|||
]) |
@ -0,0 +1,46 @@ |
|||
import { apiClient } from '@/lib/api-client' |
|||
import { AuthResponse, LoginRequest, RegisterRequest, ResetPasswordRequest, ResetPasswordConfirmRequest } from '@/types/auth' |
|||
|
|||
class AuthService { |
|||
async login(data: LoginRequest): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/login', data) |
|||
return response.data |
|||
} |
|||
|
|||
async register(data: RegisterRequest): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/register', data) |
|||
return response.data |
|||
} |
|||
|
|||
async refreshToken(refreshToken: string): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/refresh-token', { refreshToken }) |
|||
return response.data |
|||
} |
|||
|
|||
async requestPasswordReset(data: ResetPasswordRequest): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/forgot-password', data) |
|||
return response.data |
|||
} |
|||
|
|||
async resetPassword(data: ResetPasswordConfirmRequest): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/reset-password', data) |
|||
return response.data |
|||
} |
|||
|
|||
async getCurrentUser(): Promise<AuthResponse> { |
|||
const response = await apiClient.get<AuthResponse>('/auth/me') |
|||
return response.data |
|||
} |
|||
|
|||
async updateUserRole(userId: string, role: string): Promise<AuthResponse> { |
|||
const response = await apiClient.patch<AuthResponse>(`/auth/users/${userId}/role`, { role }) |
|||
return response.data |
|||
} |
|||
|
|||
async updateUserPermissions(userId: string, permissions: string[]): Promise<AuthResponse> { |
|||
const response = await apiClient.patch<AuthResponse>(`/auth/users/${userId}/permissions`, { permissions }) |
|||
return response.data |
|||
} |
|||
} |
|||
|
|||
export const authService = new AuthService() |
@ -0,0 +1,49 @@ |
|||
import { apiClient } from '@/lib/api-client' |
|||
|
|||
export interface UserSettings { |
|||
theme: 'light' | 'dark' | 'system' |
|||
language: string |
|||
notifications: { |
|||
email: boolean |
|||
push: boolean |
|||
} |
|||
displayName: string |
|||
timezone: string |
|||
} |
|||
|
|||
export const settingsService = { |
|||
async getSettings(): Promise<UserSettings> { |
|||
const response = await apiClient.get<UserSettings>('/settings') |
|||
return response.data |
|||
}, |
|||
|
|||
async updateSettings(settings: Partial<UserSettings>): Promise<UserSettings> { |
|||
const response = await apiClient.patch<UserSettings>('/settings', settings) |
|||
return response.data |
|||
}, |
|||
|
|||
async updateTheme(theme: UserSettings['theme']): Promise<UserSettings> { |
|||
const response = await apiClient.patch<UserSettings>('/settings/theme', { theme }) |
|||
return response.data |
|||
}, |
|||
|
|||
async updateLanguage(language: string): Promise<UserSettings> { |
|||
const response = await apiClient.patch<UserSettings>('/settings/language', { language }) |
|||
return response.data |
|||
}, |
|||
|
|||
async updateNotifications(notifications: UserSettings['notifications']): Promise<UserSettings> { |
|||
const response = await apiClient.patch<UserSettings>('/settings/notifications', { notifications }) |
|||
return response.data |
|||
}, |
|||
|
|||
async updateDisplayName(displayName: string): Promise<UserSettings> { |
|||
const response = await apiClient.patch<UserSettings>('/settings/display-name', { displayName }) |
|||
return response.data |
|||
}, |
|||
|
|||
async updateTimezone(timezone: string): Promise<UserSettings> { |
|||
const response = await apiClient.patch<UserSettings>('/settings/timezone', { timezone }) |
|||
return response.data |
|||
}, |
|||
} |
@ -0,0 +1,49 @@ |
|||
import { apiClient } from '@/lib/api-client' |
|||
|
|||
interface UserProfile { |
|||
id: string |
|||
name: string |
|||
email: string |
|||
avatar?: string |
|||
phone?: string |
|||
address?: string |
|||
} |
|||
|
|||
interface UpdateProfileRequest { |
|||
name?: string |
|||
phone?: string |
|||
address?: string |
|||
avatar?: string |
|||
} |
|||
|
|||
interface ChangePasswordRequest { |
|||
currentPassword: string |
|||
newPassword: string |
|||
} |
|||
|
|||
export const userService = { |
|||
async getProfile(): Promise<UserProfile> { |
|||
const response = await apiClient.get<UserProfile>('/users/profile') |
|||
return response.data |
|||
}, |
|||
|
|||
async updateProfile(data: UpdateProfileRequest): Promise<UserProfile> { |
|||
const response = await apiClient.patch<UserProfile>('/users/profile', data) |
|||
return response.data |
|||
}, |
|||
|
|||
async changePassword(data: ChangePasswordRequest): Promise<void> { |
|||
await apiClient.post('/users/change-password', data) |
|||
}, |
|||
|
|||
async uploadAvatar(file: File): Promise<{ url: string }> { |
|||
const formData = new FormData() |
|||
formData.append('avatar', file) |
|||
const response = await apiClient.post<{ url: string }>('/users/avatar', formData, { |
|||
headers: { |
|||
'Content-Type': 'multipart/form-data', |
|||
}, |
|||
}) |
|||
return response.data |
|||
}, |
|||
} |
@ -0,0 +1,144 @@ |
|||
import { create } from 'zustand' |
|||
import { persist } from 'zustand/middleware' |
|||
import { User, AuthResponse } from '@/types/auth' |
|||
import { authService } from '@/services/auth.service' |
|||
|
|||
interface AuthState { |
|||
user: User | null |
|||
accessToken: string | null |
|||
refreshToken: string | null |
|||
expiresAt: string | null |
|||
rememberMe: boolean |
|||
isAuthenticated: boolean |
|||
lastActivity: number | null |
|||
login: (response: AuthResponse, rememberMe: boolean) => void |
|||
logout: () => void |
|||
refreshAuthToken: () => Promise<void> |
|||
checkSession: () => void |
|||
updateLastActivity: () => void |
|||
} |
|||
|
|||
const INACTIVITY_TIMEOUT = 30 * 60 * 1000 // 30分钟
|
|||
|
|||
export const useAuthStore = create<AuthState>()( |
|||
persist( |
|||
(set, get) => ({ |
|||
user: null, |
|||
accessToken: null, |
|||
refreshToken: null, |
|||
expiresAt: null, |
|||
rememberMe: false, |
|||
isAuthenticated: false, |
|||
lastActivity: null, |
|||
|
|||
login: (response: AuthResponse, rememberMe: boolean) => { |
|||
set({ |
|||
user: response.data.user, |
|||
accessToken: response.data.accessToken, |
|||
refreshToken: response.data.refreshToken, |
|||
expiresAt: response.data.expiresAt, |
|||
rememberMe, |
|||
isAuthenticated: true, |
|||
lastActivity: Date.now(), |
|||
}) |
|||
|
|||
// 设置自动登出检查
|
|||
if (response.data.expiresAt) { |
|||
const expirationTime = new Date(response.data.expiresAt).getTime() |
|||
const currentTime = new Date().getTime() |
|||
const timeUntilExpiration = expirationTime - currentTime |
|||
|
|||
if (timeUntilExpiration > 0) { |
|||
setTimeout(() => { |
|||
get().logout() |
|||
}, timeUntilExpiration) |
|||
} |
|||
} |
|||
}, |
|||
|
|||
logout: () => { |
|||
set({ |
|||
user: null, |
|||
accessToken: null, |
|||
refreshToken: null, |
|||
expiresAt: null, |
|||
rememberMe: false, |
|||
isAuthenticated: false, |
|||
lastActivity: null, |
|||
}) |
|||
}, |
|||
|
|||
refreshAuthToken: async () => { |
|||
const { refreshToken } = get() |
|||
if (!refreshToken) return |
|||
|
|||
try { |
|||
const response = await authService.refreshToken(refreshToken) |
|||
set({ |
|||
accessToken: response.data.accessToken, |
|||
refreshToken: response.data.refreshToken, |
|||
expiresAt: response.data.expiresAt, |
|||
lastActivity: Date.now(), |
|||
}) |
|||
|
|||
// 更新自动登出检查
|
|||
if (response.data.expiresAt) { |
|||
const expirationTime = new Date(response.data.expiresAt).getTime() |
|||
const currentTime = new Date().getTime() |
|||
const timeUntilExpiration = expirationTime - currentTime |
|||
|
|||
if (timeUntilExpiration > 0) { |
|||
setTimeout(() => { |
|||
get().logout() |
|||
}, timeUntilExpiration) |
|||
} |
|||
} |
|||
} catch (error) { |
|||
get().logout() |
|||
} |
|||
}, |
|||
|
|||
checkSession: () => { |
|||
const { expiresAt, lastActivity, isAuthenticated } = get() |
|||
|
|||
if (!isAuthenticated) return |
|||
|
|||
// 检查令牌是否过期
|
|||
if (expiresAt) { |
|||
const expirationTime = new Date(expiresAt).getTime() |
|||
const currentTime = new Date().getTime() |
|||
|
|||
if (currentTime >= expirationTime) { |
|||
get().logout() |
|||
return |
|||
} |
|||
} |
|||
|
|||
// 检查用户是否长时间未活动
|
|||
if (lastActivity) { |
|||
const currentTime = Date.now() |
|||
const inactiveTime = currentTime - lastActivity |
|||
|
|||
if (inactiveTime >= INACTIVITY_TIMEOUT) { |
|||
get().logout() |
|||
} |
|||
} |
|||
}, |
|||
|
|||
updateLastActivity: () => { |
|||
set({ lastActivity: Date.now() }) |
|||
}, |
|||
}), |
|||
{ |
|||
name: 'auth-storage', |
|||
partialize: (state) => ({ |
|||
user: state.rememberMe ? state.user : null, |
|||
accessToken: state.rememberMe ? state.accessToken : null, |
|||
refreshToken: state.rememberMe ? state.refreshToken : null, |
|||
expiresAt: state.rememberMe ? state.expiresAt : null, |
|||
rememberMe: state.rememberMe, |
|||
lastActivity: state.rememberMe ? state.lastActivity : null, |
|||
}), |
|||
} |
|||
) |
|||
) |
@ -0,0 +1,48 @@ |
|||
/** |
|||
* 统一的API响应格式 |
|||
*/ |
|||
export interface ApiResponse<T> { |
|||
/** |
|||
* 成功消息 |
|||
*/ |
|||
successMessage: string | null; |
|||
|
|||
/** |
|||
* 错误消息列表 |
|||
*/ |
|||
errorMessages: string[] | null; |
|||
|
|||
/** |
|||
* 结果数据 |
|||
*/ |
|||
data: T; |
|||
|
|||
/** |
|||
* 判断操作是否成功 |
|||
*/ |
|||
isSuccess: boolean; |
|||
} |
|||
|
|||
/** |
|||
* 创建成功响应 |
|||
*/ |
|||
export function createSuccessResponse<T>(data: T, message?: string): ApiResponse<T> { |
|||
return { |
|||
successMessage: message || null, |
|||
errorMessages: null, |
|||
data, |
|||
isSuccess: true |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 创建错误响应 |
|||
*/ |
|||
export function createErrorResponse<T>(errorMessages: string[], data?: T): ApiResponse<T> { |
|||
return { |
|||
successMessage: null, |
|||
errorMessages, |
|||
data: data as T, |
|||
isSuccess: false |
|||
}; |
|||
} |
@ -0,0 +1,79 @@ |
|||
export type Role = 'Admin' | 'Manager' | 'User' |
|||
|
|||
export type Permission = 'users.view' | 'users.create' | 'users.edit' | 'users.delete' | 'settings.view' | 'settings.edit' | 'roles.view' | 'roles.edit' |
|||
|
|||
export interface RolePermissions { |
|||
role: Role |
|||
permissions: Permission[] |
|||
} |
|||
|
|||
export const rolePermissions: Record<Role, Permission[]> = { |
|||
Admin: [ |
|||
'users.view', |
|||
'users.create', |
|||
'users.edit', |
|||
'users.delete', |
|||
'settings.view', |
|||
'settings.edit', |
|||
'roles.view', |
|||
'roles.edit', |
|||
], |
|||
Manager: [ |
|||
'users.view', |
|||
'users.create', |
|||
'users.edit', |
|||
'settings.view', |
|||
'settings.edit', |
|||
], |
|||
User: [ |
|||
'settings.view', |
|||
], |
|||
} |
|||
|
|||
export interface User { |
|||
id: string |
|||
userName: string |
|||
email: string |
|||
phoneNumber: string |
|||
roles: Role[] |
|||
} |
|||
|
|||
export interface ApiResponse<T> { |
|||
successMessage: string | null |
|||
errorMessages: string[] | null |
|||
data: T |
|||
isSuccess: boolean |
|||
} |
|||
|
|||
export interface AuthData { |
|||
accessToken: string |
|||
refreshToken: string |
|||
expiresAt: string |
|||
user: User |
|||
} |
|||
|
|||
export type AuthResponse = ApiResponse<AuthData> |
|||
|
|||
export interface LoginRequest { |
|||
userNameOrEmail: string |
|||
password: string |
|||
rememberMe?: boolean |
|||
} |
|||
|
|||
export interface RegisterRequest { |
|||
userName: string |
|||
email: string |
|||
password: string |
|||
confirmPassword: string |
|||
phoneNumber: string |
|||
} |
|||
|
|||
export interface ResetPasswordRequest { |
|||
email: string |
|||
} |
|||
|
|||
export interface ResetPasswordConfirmRequest { |
|||
token: string |
|||
newPassword: string |
|||
confirmPassword: string |
|||
} |
@ -0,0 +1 @@ |
|||
/// <reference types="vite/client" />
|
@ -0,0 +1,81 @@ |
|||
/** @type {import('tailwindcss').Config} */ |
|||
module.exports = { |
|||
darkMode: ["class"], |
|||
content: [ |
|||
'./pages/**/*.{ts,tsx}', |
|||
'./components/**/*.{ts,tsx}', |
|||
'./app/**/*.{ts,tsx}', |
|||
'./src/**/*.{ts,tsx}', |
|||
], |
|||
theme: { |
|||
container: { |
|||
center: true, |
|||
padding: "2rem", |
|||
screens: { |
|||
"2xl": "1400px", |
|||
}, |
|||
}, |
|||
extend: { |
|||
colors: { |
|||
border: "hsl(var(--border))", |
|||
input: "hsl(var(--input))", |
|||
ring: "hsl(var(--ring))", |
|||
background: "hsl(var(--background))", |
|||
foreground: "hsl(var(--foreground))", |
|||
primary: { |
|||
DEFAULT: "hsl(var(--primary))", |
|||
foreground: "hsl(var(--primary-foreground))", |
|||
}, |
|||
secondary: { |
|||
DEFAULT: "hsl(var(--secondary))", |
|||
foreground: "hsl(var(--secondary-foreground))", |
|||
}, |
|||
destructive: { |
|||
DEFAULT: "hsl(var(--destructive))", |
|||
foreground: "hsl(var(--destructive-foreground))", |
|||
}, |
|||
muted: { |
|||
DEFAULT: "hsl(var(--muted))", |
|||
foreground: "hsl(var(--muted-foreground))", |
|||
}, |
|||
accent: { |
|||
DEFAULT: "hsl(var(--accent))", |
|||
foreground: "hsl(var(--accent-foreground))", |
|||
}, |
|||
popover: { |
|||
DEFAULT: "hsl(var(--popover))", |
|||
foreground: "hsl(var(--popover-foreground))", |
|||
}, |
|||
card: { |
|||
DEFAULT: "hsl(var(--card))", |
|||
foreground: "hsl(var(--card-foreground))", |
|||
}, |
|||
}, |
|||
borderRadius: { |
|||
lg: "var(--radius)", |
|||
md: "calc(var(--radius) - 2px)", |
|||
sm: "calc(var(--radius) - 4px)", |
|||
}, |
|||
keyframes: { |
|||
"accordion-down": { |
|||
from: { height: 0 }, |
|||
to: { height: "var(--radix-accordion-content-height)" }, |
|||
}, |
|||
"accordion-up": { |
|||
from: { height: "var(--radix-accordion-content-height)" }, |
|||
to: { height: 0 }, |
|||
}, |
|||
}, |
|||
animation: { |
|||
"accordion-down": "accordion-down 0.2s ease-out", |
|||
"accordion-up": "accordion-up 0.2s ease-out", |
|||
}, |
|||
}, |
|||
}, |
|||
plugins: [ |
|||
require("@tailwindcss/typography"), |
|||
require("@tailwindcss/forms"), |
|||
require("@tailwindcss/aspect-ratio"), |
|||
require("@tailwindcss/container-queries"), |
|||
], |
|||
} |
@ -0,0 +1,27 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |
|||
"target": "ES2020", |
|||
"useDefineForClassFields": true, |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"module": "ESNext", |
|||
"skipLibCheck": true, |
|||
|
|||
/* Bundler mode */ |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"verbatimModuleSyntax": true, |
|||
"moduleDetection": "force", |
|||
"noEmit": true, |
|||
"jsx": "react-jsx", |
|||
|
|||
/* Linting */ |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"erasableSyntaxOnly": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"noUncheckedSideEffectImports": true |
|||
}, |
|||
"include": ["src"] |
|||
} |
@ -0,0 +1,25 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ES2020", |
|||
"useDefineForClassFields": true, |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"module": "ESNext", |
|||
"skipLibCheck": true, |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "react-jsx", |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": ["./src/*"] |
|||
} |
|||
}, |
|||
"include": ["src"], |
|||
"references": [{ "path": "./tsconfig.node.json" }] |
|||
} |
@ -0,0 +1,10 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"composite": true, |
|||
"skipLibCheck": true, |
|||
"module": "ESNext", |
|||
"moduleResolution": "bundler", |
|||
"allowSyntheticDefaultImports": true |
|||
}, |
|||
"include": ["vite.config.ts"] |
|||
} |
@ -0,0 +1,22 @@ |
|||
import { defineConfig } from 'vite' |
|||
import react from '@vitejs/plugin-react' |
|||
import path from 'path' |
|||
|
|||
// https://vitejs.dev/config/
|
|||
export default defineConfig({ |
|||
plugins: [react()], |
|||
resolve: { |
|||
alias: { |
|||
'@': path.resolve(__dirname, './src'), |
|||
}, |
|||
}, |
|||
server: { |
|||
port: 3001, |
|||
open: true, |
|||
}, |
|||
test: { |
|||
globals: true, |
|||
environment: 'jsdom', |
|||
setupFiles: ['./src/test/setup.ts'], |
|||
}, |
|||
}) |
Loading…
Reference in new issue