34 changed files with 6061 additions and 1 deletions
@ -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,73 @@ |
|||
# 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/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) 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 |
|||
|
|||
## React Compiler |
|||
|
|||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). |
|||
|
|||
## 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 defineConfig([ |
|||
globalIgnores(['dist']), |
|||
{ |
|||
files: ['**/*.{ts,tsx}'], |
|||
extends: [ |
|||
// Other configs... |
|||
|
|||
// 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, |
|||
|
|||
// Other configs... |
|||
], |
|||
languageOptions: { |
|||
parserOptions: { |
|||
project: ['./tsconfig.node.json', './tsconfig.app.json'], |
|||
tsconfigRootDir: import.meta.dirname, |
|||
}, |
|||
// other options... |
|||
}, |
|||
}, |
|||
]) |
|||
``` |
|||
|
|||
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 defineConfig([ |
|||
globalIgnores(['dist']), |
|||
{ |
|||
files: ['**/*.{ts,tsx}'], |
|||
extends: [ |
|||
// Other configs... |
|||
// Enable lint rules for React |
|||
reactX.configs['recommended-typescript'], |
|||
// Enable lint rules for React DOM |
|||
reactDom.configs.recommended, |
|||
], |
|||
languageOptions: { |
|||
parserOptions: { |
|||
project: ['./tsconfig.node.json', './tsconfig.app.json'], |
|||
tsconfigRootDir: import.meta.dirname, |
|||
}, |
|||
// other options... |
|||
}, |
|||
}, |
|||
]) |
|||
``` |
|||
@ -0,0 +1,23 @@ |
|||
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' |
|||
import { defineConfig, globalIgnores } from 'eslint/config' |
|||
|
|||
export default defineConfig([ |
|||
globalIgnores(['dist']), |
|||
{ |
|||
files: ['**/*.{ts,tsx}'], |
|||
extends: [ |
|||
js.configs.recommended, |
|||
tseslint.configs.recommended, |
|||
reactHooks.configs.flat.recommended, |
|||
reactRefresh.configs.vite, |
|||
], |
|||
languageOptions: { |
|||
ecmaVersion: 2020, |
|||
globals: globals.browser, |
|||
}, |
|||
}, |
|||
]) |
|||
@ -0,0 +1,17 @@ |
|||
<!doctype html> |
|||
<html lang="zh-CN" class="dark"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Base Admin Panel</title> |
|||
<!-- Google Fonts --> |
|||
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|||
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
|||
</head> |
|||
<body class="bg-gray-950 text-gray-100 antialiased"> |
|||
<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,40 @@ |
|||
{ |
|||
"name": "pc", |
|||
"private": true, |
|||
"version": "0.0.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"build": "tsc -b && vite build", |
|||
"lint": "eslint .", |
|||
"preview": "vite preview" |
|||
}, |
|||
"dependencies": { |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"lucide-react": "^0.563.0", |
|||
"react": "^19.2.0", |
|||
"react-dom": "^19.2.0", |
|||
"react-router-dom": "^7.13.0", |
|||
"tailwind-merge": "^3.4.0", |
|||
"tailwindcss-animate": "^1.0.7" |
|||
}, |
|||
"devDependencies": { |
|||
"@eslint/js": "^9.39.1", |
|||
"@tailwindcss/postcss": "^4.1.18", |
|||
"@types/node": "^24.10.12", |
|||
"@types/react": "^19.2.7", |
|||
"@types/react-dom": "^19.2.3", |
|||
"@vitejs/plugin-react": "^5.1.1", |
|||
"autoprefixer": "^10.4.24", |
|||
"eslint": "^9.39.1", |
|||
"eslint-plugin-react-hooks": "^7.0.1", |
|||
"eslint-plugin-react-refresh": "^0.4.24", |
|||
"globals": "^16.5.0", |
|||
"postcss": "^8.5.6", |
|||
"tailwindcss": "^4.1.18", |
|||
"typescript": "~5.9.3", |
|||
"typescript-eslint": "^8.48.0", |
|||
"vite": "^7.3.1" |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
export default { |
|||
plugins: { |
|||
'@tailwindcss/postcss': {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
} |
|||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,35 @@ |
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' |
|||
import { AuthProvider } from './contexts/AuthContext' |
|||
import { ProtectedRoute } from './components/layout/ProtectedRoute' |
|||
import { MainLayout } from './components/layout/MainLayout' |
|||
import { LoginPage } from './pages/LoginPage' |
|||
import { DashboardPage } from './pages/DashboardPage' |
|||
import { UserManagementPage } from './pages/UserManagementPage' |
|||
import { SettingsPage } from './pages/SettingsPage' |
|||
|
|||
function App() { |
|||
return ( |
|||
<AuthProvider> |
|||
<BrowserRouter> |
|||
<Routes> |
|||
<Route path="/login" element={<LoginPage />} /> |
|||
<Route |
|||
path="/" |
|||
element={ |
|||
<ProtectedRoute> |
|||
<MainLayout /> |
|||
</ProtectedRoute> |
|||
} |
|||
> |
|||
<Route index element={<Navigate to="/dashboard" replace />} /> |
|||
<Route path="dashboard" element={<DashboardPage />} /> |
|||
<Route path="users" element={<UserManagementPage />} /> |
|||
<Route path="settings" element={<SettingsPage />} /> |
|||
</Route> |
|||
</Routes> |
|||
</BrowserRouter> |
|||
</AuthProvider> |
|||
) |
|||
} |
|||
|
|||
export default App |
|||
|
After Width: | Height: | Size: 4.0 KiB |
@ -0,0 +1,23 @@ |
|||
interface HeaderProps { |
|||
title: string |
|||
subtitle?: string |
|||
} |
|||
|
|||
export function Header({ title, subtitle }: HeaderProps) { |
|||
return ( |
|||
<header className="h-16 border-b border-gray-800 bg-gray-900/50 backdrop-blur-xl flex items-center px-6 fixed top-0 left-64 right-0 z-30"> |
|||
<div className="flex-1"> |
|||
<h1 className="text-xl font-bold text-white font-display">{title}</h1> |
|||
{subtitle && ( |
|||
<p className="text-xs text-gray-500 font-body mt-0.5">{subtitle}</p> |
|||
)} |
|||
</div> |
|||
|
|||
{/* Decorative elements */} |
|||
<div className="flex items-center gap-2"> |
|||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" /> |
|||
<span className="text-xs text-gray-500 font-body">系统正常</span> |
|||
</div> |
|||
</header> |
|||
) |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
import { Outlet } from 'react-router-dom' |
|||
import { Sidebar } from './Sidebar' |
|||
import { Header } from './Header' |
|||
|
|||
const pageTitles: Record<string, { title: string; subtitle?: string }> = { |
|||
'/dashboard': { title: '仪表盘', subtitle: '系统概览与数据统计' }, |
|||
'/users': { title: '用户管理', subtitle: '管理系统用户账号' }, |
|||
'/settings': { title: '系统设置', subtitle: '配置系统参数' }, |
|||
} |
|||
|
|||
export function MainLayout() { |
|||
const pathname = window.location.pathname |
|||
const pageInfo = pageTitles[pathname] || { title: 'BASE', subtitle: '' } |
|||
|
|||
return ( |
|||
<div className="min-h-screen bg-gray-950"> |
|||
<Sidebar /> |
|||
<div className="ml-64"> |
|||
<Header title={pageInfo.title} subtitle={pageInfo.subtitle} /> |
|||
<main className="p-6 pt-24"> |
|||
<Outlet /> |
|||
</main> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
import { Navigate } from 'react-router-dom' |
|||
import { useAuth } from '@/contexts/AuthContext' |
|||
|
|||
export function ProtectedRoute({ children }: { children: React.ReactNode }) { |
|||
const { isAuthenticated, isLoading } = useAuth() |
|||
|
|||
if (isLoading) { |
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center"> |
|||
<div className="animate-spin rounded-full h-12 w-12 border-2 border-sky-500 border-t-transparent" /> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
if (!isAuthenticated) { |
|||
return <Navigate to="/login" replace /> |
|||
} |
|||
|
|||
return <>{children}</> |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
import { NavLink } from 'react-router-dom' |
|||
import { LayoutDashboard, Users, LogOut, Settings } from 'lucide-react' |
|||
import { useAuth } from '@/contexts/AuthContext' |
|||
|
|||
const navItems = [ |
|||
{ path: '/dashboard', icon: LayoutDashboard, label: '首页' }, |
|||
{ path: '/users', icon: Users, label: '用户管理' }, |
|||
{ path: '/settings', icon: Settings, label: '设置' }, |
|||
] |
|||
|
|||
export function Sidebar() { |
|||
const { user, logout } = useAuth() |
|||
|
|||
return ( |
|||
<aside className="w-64 h-screen bg-gray-900/80 backdrop-blur-xl border-r border-gray-800 flex flex-col fixed left-0 top-0 z-40"> |
|||
{/* Logo */} |
|||
<div className="p-6 border-b border-gray-800"> |
|||
<div className="flex items-center gap-3"> |
|||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center glow-primary"> |
|||
<span className="text-xl font-bold text-white font-display">B</span> |
|||
</div> |
|||
<div> |
|||
<h1 className="text-lg font-bold text-white font-display">BASE</h1> |
|||
<p className="text-xs text-gray-500 font-body">管理面板</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Navigation */} |
|||
<nav className="flex-1 p-4 space-y-1"> |
|||
{navItems.map((item) => ( |
|||
<NavLink |
|||
key={item.path} |
|||
to={item.path} |
|||
className={({ isActive }) => |
|||
cn( |
|||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200', |
|||
'font-body text-sm font-medium', |
|||
isActive |
|||
? 'bg-gradient-to-r from-sky-500/20 to-blue-600/20 text-sky-400 border border-sky-500/30' |
|||
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200' |
|||
) |
|||
} |
|||
> |
|||
<item.icon className="h-5 w-5" /> |
|||
{item.label} |
|||
</NavLink> |
|||
))} |
|||
</nav> |
|||
|
|||
{/* User Info */} |
|||
<div className="p-4 border-t border-gray-800"> |
|||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-800/50"> |
|||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center"> |
|||
<span className="text-lg font-bold text-white font-display"> |
|||
{user?.username?.[0]?.toUpperCase() || 'U'} |
|||
</span> |
|||
</div> |
|||
<div className="flex-1 min-w-0"> |
|||
<p className="text-sm font-medium text-white font-body truncate"> |
|||
{user?.username || 'User'} |
|||
</p> |
|||
<p className="text-xs text-gray-500 font-body truncate"> |
|||
{user?.email || ''} |
|||
</p> |
|||
</div> |
|||
<button |
|||
onClick={logout} |
|||
className="p-2 text-gray-500 hover:text-red-400 transition-colors" |
|||
title="退出登录" |
|||
> |
|||
<LogOut className="h-4 w-4" /> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</aside> |
|||
) |
|||
} |
|||
|
|||
function cn(...classes: (string | undefined | false | null)[]) { |
|||
return classes.filter(Boolean).join(' ') |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
import { forwardRef, type ButtonHTMLAttributes } from 'react' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { |
|||
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost' | 'outline' |
|||
size?: 'sm' | 'md' | 'lg' |
|||
isLoading?: boolean |
|||
} |
|||
|
|||
const Button = forwardRef<HTMLButtonElement, ButtonProps>( |
|||
({ className, variant = 'primary', size = 'md', isLoading, disabled, children, ...props }, ref) => { |
|||
const variants = { |
|||
primary: |
|||
'bg-gradient-to-r from-sky-500 to-blue-600 text-white hover:from-sky-400 hover:to-blue-500 glow-primary border-sky-500/50', |
|||
secondary: |
|||
'bg-gray-800/80 text-gray-200 hover:bg-gray-700/80 border-gray-700/50', |
|||
destructive: |
|||
'bg-gradient-to-r from-red-600 to-rose-600 text-white hover:from-red-500 hover:to-rose-500 border-red-500/50', |
|||
ghost: 'bg-transparent text-gray-300 hover:bg-gray-800/50 border-transparent', |
|||
outline: 'bg-transparent text-gray-200 hover:bg-gray-800/50 border-gray-700', |
|||
} |
|||
|
|||
const sizes = { |
|||
sm: 'px-3 py-1.5 text-sm', |
|||
md: 'px-4 py-2 text-base', |
|||
lg: 'px-6 py-3 text-lg', |
|||
} |
|||
|
|||
return ( |
|||
<button |
|||
ref={ref} |
|||
className={cn( |
|||
'relative inline-flex items-center justify-center gap-2 rounded-lg border font-medium transition-all duration-200', |
|||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950', |
|||
'disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed', |
|||
'tech-border', |
|||
variants[variant], |
|||
sizes[size], |
|||
className |
|||
)} |
|||
disabled={disabled || isLoading} |
|||
{...props} |
|||
> |
|||
{isLoading && ( |
|||
<svg |
|||
className="animate-spin h-4 w-4" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
fill="none" |
|||
viewBox="0 0 24 24" |
|||
> |
|||
<circle |
|||
className="opacity-25" |
|||
cx="12" |
|||
cy="12" |
|||
r="10" |
|||
stroke="currentColor" |
|||
strokeWidth="4" |
|||
/> |
|||
<path |
|||
className="opacity-75" |
|||
fill="currentColor" |
|||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
|||
/> |
|||
</svg> |
|||
)} |
|||
{children} |
|||
</button> |
|||
) |
|||
} |
|||
) |
|||
|
|||
Button.displayName = 'Button' |
|||
|
|||
export { Button } |
|||
@ -0,0 +1,80 @@ |
|||
import { forwardRef, type HTMLAttributes } from 'react' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
|||
({ className, children, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn( |
|||
'rounded-xl border bg-gray-900/80 backdrop-blur-sm tech-border', |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
</div> |
|||
) |
|||
) |
|||
|
|||
Card.displayName = 'Card' |
|||
|
|||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
|||
({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn('flex flex-col space-y-1.5 p-6', className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
) |
|||
|
|||
CardHeader.displayName = 'CardHeader' |
|||
|
|||
const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>( |
|||
({ className, ...props }, ref) => ( |
|||
<h3 |
|||
ref={ref} |
|||
className={cn( |
|||
'text-2xl font-bold leading-none tracking-tight font-display text-white', |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
) |
|||
|
|||
CardTitle.displayName = 'CardTitle' |
|||
|
|||
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>( |
|||
({ className, ...props }, ref) => ( |
|||
<p |
|||
ref={ref} |
|||
className={cn('text-sm text-gray-400 font-body', className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
) |
|||
|
|||
CardDescription.displayName = 'CardDescription' |
|||
|
|||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
|||
({ className, ...props }, ref) => ( |
|||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> |
|||
) |
|||
) |
|||
|
|||
CardContent.displayName = 'CardContent' |
|||
|
|||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
|||
({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn('flex items-center p-6 pt-0', className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
) |
|||
|
|||
CardFooter.displayName = 'CardFooter' |
|||
|
|||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } |
|||
@ -0,0 +1,65 @@ |
|||
import { forwardRef, type InputHTMLAttributes } from 'react' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { |
|||
label?: string |
|||
error?: string |
|||
leftIcon?: React.ReactNode |
|||
rightIcon?: React.ReactNode |
|||
} |
|||
|
|||
const Input = forwardRef<HTMLInputElement, InputProps>( |
|||
({ className, label, error, leftIcon, rightIcon, type = 'text', id, ...props }, ref) => { |
|||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}` |
|||
|
|||
return ( |
|||
<div className="w-full space-y-1"> |
|||
{label && ( |
|||
<label |
|||
htmlFor={inputId} |
|||
className="block text-sm font-medium text-gray-300 font-body" |
|||
> |
|||
{label} |
|||
</label> |
|||
)} |
|||
<div className="relative"> |
|||
{leftIcon && ( |
|||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"> |
|||
{leftIcon} |
|||
</div> |
|||
)} |
|||
<input |
|||
ref={ref} |
|||
type={type} |
|||
id={inputId} |
|||
className={cn( |
|||
'flex h-11 w-full rounded-lg border bg-gray-900/80 px-4 py-2.5 text-sm text-gray-100', |
|||
'placeholder:text-gray-500', |
|||
'transition-all duration-200', |
|||
'focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500/50', |
|||
'disabled:cursor-not-allowed disabled:opacity-50', |
|||
'tech-border', |
|||
leftIcon && 'pl-10', |
|||
rightIcon && 'pr-10', |
|||
error && 'border-red-500/50 focus:ring-red-500/50 focus:border-red-500/50', |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
{rightIcon && ( |
|||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"> |
|||
{rightIcon} |
|||
</div> |
|||
)} |
|||
</div> |
|||
{error && ( |
|||
<p className="text-xs text-red-400 font-body">{error}</p> |
|||
)} |
|||
</div> |
|||
) |
|||
} |
|||
) |
|||
|
|||
Input.displayName = 'Input' |
|||
|
|||
export { Input } |
|||
@ -0,0 +1,83 @@ |
|||
import { useEffect, type ReactNode } from 'react' |
|||
import { X } from 'lucide-react' |
|||
import { cn } from '@/lib/utils' |
|||
import { Button } from './Button' |
|||
|
|||
interface ModalProps { |
|||
isOpen: boolean |
|||
onClose: () => void |
|||
title?: string |
|||
children: ReactNode |
|||
footer?: ReactNode |
|||
size?: 'sm' | 'md' | 'lg' | 'xl' |
|||
} |
|||
|
|||
export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) { |
|||
useEffect(() => { |
|||
if (isOpen) { |
|||
document.body.style.overflow = 'hidden' |
|||
} else { |
|||
document.body.style.overflow = '' |
|||
} |
|||
return () => { |
|||
document.body.style.overflow = '' |
|||
} |
|||
}, [isOpen]) |
|||
|
|||
if (!isOpen) return null |
|||
|
|||
const sizes = { |
|||
sm: 'max-w-md', |
|||
md: 'max-w-lg', |
|||
lg: 'max-w-2xl', |
|||
xl: 'max-w-4xl', |
|||
} |
|||
|
|||
return ( |
|||
<div className="fixed inset-0 z-50 flex items-center justify-center"> |
|||
{/* Backdrop */} |
|||
<div |
|||
className="absolute inset-0 bg-black/70 backdrop-blur-sm" |
|||
onClick={onClose} |
|||
/> |
|||
|
|||
{/* Modal */} |
|||
<div |
|||
className={cn( |
|||
'relative w-full bg-gray-900/95 backdrop-blur-xl rounded-xl border border-gray-800 shadow-2xl tech-border animate-scale-in', |
|||
sizes[size] |
|||
)} |
|||
onClick={(e) => e.stopPropagation()} |
|||
> |
|||
{/* Header */} |
|||
{title && ( |
|||
<div className="flex items-center justify-between p-6 border-b border-gray-800"> |
|||
<h2 className="text-xl font-bold text-white font-display">{title}</h2> |
|||
{onClose && ( |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={onClose} |
|||
className="h-8 w-8 p-0" |
|||
> |
|||
<X className="h-4 w-4" /> |
|||
</Button> |
|||
)} |
|||
</div> |
|||
)} |
|||
|
|||
{/* Content */} |
|||
<div className="p-6 max-h-[70vh] overflow-y-auto"> |
|||
{children} |
|||
</div> |
|||
|
|||
{/* Footer */} |
|||
{footer && ( |
|||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-800"> |
|||
{footer} |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
export const Table = ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => ( |
|||
<div className="relative w-full overflow-auto"> |
|||
<table className={cn('w-full caption-bottom text-sm', className)} {...props} /> |
|||
</div> |
|||
) |
|||
|
|||
export const TableHeader = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => ( |
|||
<thead className={cn('[&_tr]:border-b', className)} {...props} /> |
|||
) |
|||
|
|||
export const TableBody = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => ( |
|||
<tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} /> |
|||
) |
|||
|
|||
export const TableFooter = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => ( |
|||
<tfoot |
|||
className={cn('border-t bg-gray-900/50 font-medium text-gray-400 font-body', className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
|
|||
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => ( |
|||
<tr |
|||
className={cn( |
|||
'border-b border-gray-800 transition-colors hover:bg-gray-800/30 data-[state=selected]:bg-gray-800/50', |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
|
|||
export const TableHead = ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => ( |
|||
<th |
|||
className={cn( |
|||
'h-12 px-4 text-left align-middle font-medium text-gray-400 font-display uppercase tracking-wider text-xs [&:has([role=checkbox])]:pr-0', |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
|
|||
export const TableCell = ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => ( |
|||
<td |
|||
className={cn('p-4 align-middle text-gray-300 font-body', className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
|
|||
export const TableCaption = ({ className, ...props }: React.HTMLAttributes<HTMLTableCaptionElement>) => ( |
|||
<caption className={cn('mt-4 text-sm text-gray-500 font-body', className)} {...props} /> |
|||
) |
|||
@ -0,0 +1,88 @@ |
|||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' |
|||
import type { User } from '@/types' |
|||
import { apiClient } from '@/services/api' |
|||
|
|||
interface AuthContextType { |
|||
user: User | null |
|||
token: string | null |
|||
isAuthenticated: boolean |
|||
isLoading: boolean |
|||
login: (email: string, password: string) => Promise<void> |
|||
logout: () => void |
|||
} |
|||
|
|||
const AuthContext = createContext<AuthContextType | undefined>(undefined) |
|||
|
|||
export function AuthProvider({ children }: { children: ReactNode }) { |
|||
const [user, setUser] = useState<User | null>(null) |
|||
const [token, setToken] = useState<string | null>(null) |
|||
const [isLoading, setIsLoading] = useState(true) |
|||
|
|||
useEffect(() => { |
|||
const storedToken = localStorage.getItem('token') |
|||
const storedUser = localStorage.getItem('user') |
|||
|
|||
if (storedToken) { |
|||
setToken(storedToken) |
|||
} |
|||
|
|||
if (storedUser) { |
|||
try { |
|||
setUser(JSON.parse(storedUser)) |
|||
} catch { |
|||
localStorage.removeItem('user') |
|||
} |
|||
} |
|||
|
|||
setIsLoading(false) |
|||
}, []) |
|||
|
|||
const login = async (email: string, password: string) => { |
|||
try { |
|||
const response = await apiClient.login({ email, password }) |
|||
|
|||
if (response.success && response.token) { |
|||
setToken(response.token) |
|||
|
|||
// Mock user data - in real app, fetch from API
|
|||
const userData: User = { |
|||
id: 1, |
|||
username: email.split('@')[0], |
|||
email, |
|||
createdAt: new Date().toISOString(), |
|||
updatedAt: new Date().toISOString(), |
|||
} |
|||
setUser(userData) |
|||
localStorage.setItem('user', JSON.stringify(userData)) |
|||
} |
|||
} catch (error) { |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
const logout = () => { |
|||
apiClient.logout() |
|||
setToken(null) |
|||
setUser(null) |
|||
localStorage.removeItem('user') |
|||
} |
|||
|
|||
const value: AuthContextType = { |
|||
user, |
|||
token, |
|||
isAuthenticated: !!token, |
|||
isLoading, |
|||
login, |
|||
logout, |
|||
} |
|||
|
|||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> |
|||
} |
|||
|
|||
export function useAuth() { |
|||
const context = useContext(AuthContext) |
|||
if (context === undefined) { |
|||
throw new Error('useAuth must be used within an AuthProvider') |
|||
} |
|||
return context |
|||
} |
|||
@ -0,0 +1,183 @@ |
|||
@import "tailwindcss"; |
|||
|
|||
@theme { |
|||
--color-background: oklch(0.15 0 0); |
|||
--color-foreground: oklch(0.98 0.005 240); |
|||
--color-card: oklch(0.15 0 0); |
|||
--color-card-foreground: oklch(0.98 0.005 240); |
|||
--color-popover: oklch(0.15 0 0); |
|||
--color-popover-foreground: oklch(0.98 0.005 240); |
|||
--color-primary: oklch(0.65 0.2 240); |
|||
--color-primary-foreground: oklch(0.98 0.005 240); |
|||
--color-secondary: oklch(0.20 0.03 240); |
|||
--color-secondary-foreground: oklch(0.98 0.005 240); |
|||
--color-muted: oklch(0.20 0.03 240); |
|||
--color-muted-foreground: oklch(0.60 0.02 240); |
|||
--color-accent: oklch(0.20 0.03 240); |
|||
--color-accent-foreground: oklch(0.98 0.005 240); |
|||
--color-destructive: oklch(0.55 0.22 25); |
|||
--color-destructive-foreground: oklch(0.98 0.005 240); |
|||
--color-border: oklch(0.22 0.03 240); |
|||
--color-input: oklch(0.22 0.03 240); |
|||
--color-ring: oklch(0.65 0.2 240); |
|||
--radius: 0.5rem; |
|||
|
|||
--font-display: 'Oswald', sans-serif; |
|||
--font-body: 'Space Grotesk', sans-serif; |
|||
|
|||
--animate-accordion-down: accordion-down 0.2s ease-out; |
|||
--animate-accordion-up: accordion-up 0.2s ease-out; |
|||
--animate-slide-in-left: slide-in-left 0.4s ease-out; |
|||
--animate-slide-in-right: slide-in-right 0.4s ease-out; |
|||
--animate-fade-in: fade-in 0.3s ease-out; |
|||
--animate-scale-in: scale-in 0.2s ease-out; |
|||
|
|||
@keyframes accordion-down { |
|||
from { height: 0; } |
|||
to { height: var(--radix-accordion-content-height); } |
|||
} |
|||
|
|||
@keyframes accordion-up { |
|||
from { height: var(--radix-accordion-content-height); } |
|||
to { height: 0; } |
|||
} |
|||
|
|||
@keyframes slide-in-left { |
|||
from { transform: translateX(-100%); opacity: 0; } |
|||
to { transform: translateX(0); opacity: 1; } |
|||
} |
|||
|
|||
@keyframes slide-in-right { |
|||
from { transform: translateX(100%); opacity: 0; } |
|||
to { transform: translateX(0); opacity: 1; } |
|||
} |
|||
|
|||
@keyframes fade-in { |
|||
from { opacity: 0; } |
|||
to { opacity: 1; } |
|||
} |
|||
|
|||
@keyframes scale-in { |
|||
from { transform: scale(0.95); opacity: 0; } |
|||
to { transform: scale(1); opacity: 1; } |
|||
} |
|||
} |
|||
|
|||
body { |
|||
background-color: oklch(0.10 0.01 240); |
|||
color: oklch(0.98 0.005 240); |
|||
font-family: var(--font-body); |
|||
background-image: |
|||
radial-gradient(circle at 15% 50%, rgba(30, 58, 138, 0.15) 0%, transparent 25%), |
|||
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.1) 0%, transparent 25%), |
|||
linear-gradient(rgba(15, 23, 42, 0.95) 1px, transparent 1px), |
|||
linear-gradient(90deg, rgba(15, 23, 42, 0.95) 1px, transparent 1px); |
|||
background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px; |
|||
} |
|||
|
|||
/* Custom scrollbar */ |
|||
::-webkit-scrollbar { |
|||
width: 8px; |
|||
height: 8px; |
|||
} |
|||
|
|||
::-webkit-scrollbar-track { |
|||
background-color: oklch(0.15 0.01 240); |
|||
} |
|||
|
|||
::-webkit-scrollbar-thumb { |
|||
background-color: oklch(0.30 0.02 240); |
|||
border-radius: 0.125rem; |
|||
} |
|||
|
|||
::-webkit-scrollbar-thumb:hover { |
|||
background-color: oklch(0.35 0.02 240); |
|||
} |
|||
|
|||
/* Typography utilities */ |
|||
.font-display { |
|||
font-family: var(--font-display); |
|||
} |
|||
|
|||
.font-body { |
|||
font-family: var(--font-body); |
|||
} |
|||
|
|||
/* Industrial grid pattern */ |
|||
.grid-pattern { |
|||
background-image: |
|||
linear-gradient(rgba(56, 189, 248, 0.03) 1px, transparent 1px), |
|||
linear-gradient(90deg, rgba(56, 189, 248, 0.03) 1px, transparent 1px); |
|||
background-size: 60px 60px; |
|||
} |
|||
|
|||
/* Glowing effects */ |
|||
.glow-primary { |
|||
box-shadow: 0 0 20px rgba(56, 189, 248, 0.3), 0 0 40px rgba(56, 189, 248, 0.1); |
|||
} |
|||
|
|||
.glow-accent { |
|||
box-shadow: 0 0 20px rgba(251, 191, 36, 0.3), 0 0 40px rgba(251, 191, 36, 0.1); |
|||
} |
|||
|
|||
/* Diagonal stripe pattern */ |
|||
.stripe-pattern { |
|||
background: repeating-linear-gradient( |
|||
45deg, |
|||
transparent, |
|||
transparent 10px, |
|||
rgba(56, 189, 248, 0.02) 10px, |
|||
rgba(56, 189, 248, 0.02) 20px |
|||
); |
|||
} |
|||
|
|||
/* Tech border effect */ |
|||
.tech-border { |
|||
position: relative; |
|||
} |
|||
|
|||
.tech-border::before { |
|||
content: ''; |
|||
position: absolute; |
|||
inset: 0; |
|||
border: 1px solid transparent; |
|||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.5), rgba(168, 85, 247, 0.5), rgba(56, 189, 248, 0.5)) border-box; |
|||
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); |
|||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); |
|||
-webkit-mask-composite: xor; |
|||
mask-composite: exclude; |
|||
pointer-events: none; |
|||
} |
|||
|
|||
/* Scanline effect */ |
|||
.scanline { |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.scanline::after { |
|||
content: ''; |
|||
position: absolute; |
|||
inset: 0; |
|||
background: linear-gradient( |
|||
to bottom, |
|||
transparent 50%, |
|||
rgba(0, 0, 0, 0.02) 51% |
|||
); |
|||
background-size: 100% 4px; |
|||
pointer-events: none; |
|||
} |
|||
|
|||
/* Noise texture overlay */ |
|||
.noise-overlay { |
|||
position: relative; |
|||
} |
|||
|
|||
.noise-overlay::before { |
|||
content: ''; |
|||
position: absolute; |
|||
inset: 0; |
|||
opacity: 0.03; |
|||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); |
|||
pointer-events: none; |
|||
} |
|||
@ -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,165 @@ |
|||
import { Users, Zap, Activity, Database } from 'lucide-react' |
|||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
|||
|
|||
const stats = [ |
|||
{ |
|||
title: '总用户数', |
|||
value: '1,234', |
|||
change: '+12%', |
|||
icon: Users, |
|||
color: 'from-sky-500 to-blue-600', |
|||
}, |
|||
{ |
|||
title: '活跃用户', |
|||
value: '856', |
|||
change: '+8%', |
|||
icon: Activity, |
|||
color: 'from-green-500 to-emerald-600', |
|||
}, |
|||
{ |
|||
title: '系统负载', |
|||
value: '32%', |
|||
change: '-5%', |
|||
icon: Zap, |
|||
color: 'from-amber-500 to-orange-600', |
|||
}, |
|||
{ |
|||
title: '数据库状态', |
|||
value: '正常', |
|||
change: '稳定', |
|||
icon: Database, |
|||
color: 'from-purple-500 to-violet-600', |
|||
}, |
|||
] |
|||
|
|||
const recentActivity = [ |
|||
{ id: 1, user: 'john@example.com', action: '登录系统', time: '5 分钟前', status: 'success' }, |
|||
{ id: 2, user: 'jane@example.com', action: '更新资料', time: '15 分钟前', status: 'success' }, |
|||
{ id: 3, user: 'admin@example.com', action: '创建用户', time: '1 小时前', status: 'success' }, |
|||
{ id: 4, user: 'bob@example.com', action: '修改密码', time: '2 小时前', status: 'success' }, |
|||
{ id: 5, user: 'alice@example.com', action: '登录失败', time: '3 小时前', status: 'error' }, |
|||
] |
|||
|
|||
export function DashboardPage() { |
|||
return ( |
|||
<div className="space-y-6"> |
|||
{/* Stats Grid */} |
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-fade-in" style={{ animationDelay: '0.1s' }}> |
|||
{stats.map((stat, index) => ( |
|||
<Card key={index} className="overflow-hidden hover:border-gray-700 transition-colors"> |
|||
<CardContent className="p-6"> |
|||
<div className="flex items-start justify-between"> |
|||
<div className="space-y-1"> |
|||
<p className="text-sm font-medium text-gray-400 font-body">{stat.title}</p> |
|||
<p className="text-3xl font-bold text-white font-display">{stat.value}</p> |
|||
<p className="text-xs text-green-400 font-body">{stat.change}</p> |
|||
</div> |
|||
<div className={`p-3 rounded-lg bg-gradient-to-br ${stat.color}`}> |
|||
<stat.icon className="h-5 w-5 text-white" /> |
|||
</div> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
))} |
|||
</div> |
|||
|
|||
{/* Main Content Grid */} |
|||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
|||
{/* Chart Placeholder */} |
|||
<Card className="lg:col-span-2 animate-fade-in" style={{ animationDelay: '0.2s' }}> |
|||
<CardHeader> |
|||
<CardTitle>用户增长趋势</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="h-64 flex items-end justify-between gap-2 px-4"> |
|||
{[65, 72, 68, 80, 75, 85, 82, 90, 88, 95, 92, 100].map((height, i) => ( |
|||
<div |
|||
key={i} |
|||
className="flex-1 max-w-8 bg-gradient-to-t from-sky-600 to-sky-400 rounded-t-sm transition-all duration-300 hover:from-sky-500 hover:to-sky-300 relative group" |
|||
style={{ height: `${height}%` }} |
|||
> |
|||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-xs text-white px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap"> |
|||
{height}% |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
<div className="flex justify-between mt-4 px-4 text-xs text-gray-500 font-body"> |
|||
<span>1月</span> |
|||
<span>2月</span> |
|||
<span>3月</span> |
|||
<span>4月</span> |
|||
<span>5月</span> |
|||
<span>6月</span> |
|||
<span>7月</span> |
|||
<span>8月</span> |
|||
<span>9月</span> |
|||
<span>10月</span> |
|||
<span>11月</span> |
|||
<span>12月</span> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
{/* Recent Activity */} |
|||
<Card className="animate-fade-in" style={{ animationDelay: '0.3s' }}> |
|||
<CardHeader> |
|||
<CardTitle>最近活动</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="space-y-4"> |
|||
{recentActivity.map((activity) => ( |
|||
<div |
|||
key={activity.id} |
|||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors" |
|||
> |
|||
<div |
|||
className={`w-2 h-2 rounded-full mt-2 flex-shrink-0 ${ |
|||
activity.status === 'success' ? 'bg-green-500' : 'bg-red-500' |
|||
}`}
|
|||
/> |
|||
<div className="flex-1 min-w-0"> |
|||
<p className="text-sm text-gray-300 font-body truncate"> |
|||
{activity.user} |
|||
</p> |
|||
<p className="text-xs text-gray-500 font-body">{activity.action}</p> |
|||
</div> |
|||
<span className="text-xs text-gray-600 font-body whitespace-nowrap"> |
|||
{activity.time} |
|||
</span> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
|
|||
{/* Quick Actions */} |
|||
<Card className="animate-fade-in" style={{ animationDelay: '0.4s' }}> |
|||
<CardHeader> |
|||
<CardTitle>快捷操作</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> |
|||
{[ |
|||
{ label: '添加用户', icon: Users, action: 'users' }, |
|||
{ label: '系统设置', icon: Zap, action: 'settings' }, |
|||
{ label: '数据备份', icon: Database, action: 'backup' }, |
|||
{ label: '查看日志', icon: Activity, action: 'logs' }, |
|||
].map((item, index) => ( |
|||
<button |
|||
key={index} |
|||
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-gray-800 hover:border-sky-500/50 hover:bg-gray-800/50 transition-all duration-200 group" |
|||
> |
|||
<item.icon className="h-6 w-6 text-gray-500 group-hover:text-sky-400 transition-colors" /> |
|||
<span className="text-sm text-gray-400 group-hover:text-gray-200 font-body transition-colors"> |
|||
{item.label} |
|||
</span> |
|||
</button> |
|||
))} |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,135 @@ |
|||
import { useState } from 'react' |
|||
import { useNavigate } from 'react-router-dom' |
|||
import { Mail, Lock, AlertCircle, Zap } from 'lucide-react' |
|||
import { useAuth } from '@/contexts/AuthContext' |
|||
import { Input } from '@/components/ui/Input' |
|||
import { Button } from '@/components/ui/Button' |
|||
import { Card } from '@/components/ui/Card' |
|||
|
|||
export function LoginPage() { |
|||
const [email, setEmail] = useState('') |
|||
const [password, setPassword] = useState('') |
|||
const [error, setError] = useState('') |
|||
const [isLoading, setIsLoading] = useState(false) |
|||
const navigate = useNavigate() |
|||
const { login } = useAuth() |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault() |
|||
setError('') |
|||
setIsLoading(true) |
|||
|
|||
try { |
|||
await login(email, password) |
|||
navigate('/dashboard') |
|||
} catch (err) { |
|||
setError(err instanceof Error ? err.message : '登录失败') |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden grid-pattern"> |
|||
{/* Animated background elements */} |
|||
<div className="absolute inset-0 overflow-hidden"> |
|||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-sky-500/10 rounded-full blur-3xl animate-pulse" /> |
|||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }} /> |
|||
</div> |
|||
|
|||
{/* Decorative grid lines */} |
|||
<div className="absolute inset-0 pointer-events-none"> |
|||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-sky-500/30 to-transparent" /> |
|||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-purple-500/30 to-transparent" /> |
|||
<div className="absolute top-0 bottom-0 left-0 w-px bg-gradient-to-b from-transparent via-sky-500/30 to-transparent" /> |
|||
<div className="absolute top-0 bottom-0 right-0 w-px bg-gradient-to-b from-transparent via-purple-500/30 to-transparent" /> |
|||
</div> |
|||
|
|||
{/* Main content */} |
|||
<div className="relative z-10 w-full max-w-md px-4 animate-scale-in"> |
|||
{/* Logo and title */} |
|||
<div className="text-center mb-8"> |
|||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6 rounded-2xl bg-gradient-to-br from-sky-500 to-blue-600 glow-primary relative overflow-hidden"> |
|||
<div className="absolute inset-0 stripe-pattern" /> |
|||
<Zap className="h-10 w-10 text-white relative z-10" /> |
|||
</div> |
|||
<h1 className="text-4xl font-bold text-white font-display tracking-wide mb-2"> |
|||
BASE<span className="text-sky-400">.</span> |
|||
</h1> |
|||
<p className="text-gray-500 font-body">管理面板登录</p> |
|||
</div> |
|||
|
|||
{/* Login form */} |
|||
<Card className="backdrop-blur-xl bg-gray-900/90 border-gray-800"> |
|||
<form onSubmit={handleSubmit} className="space-y-6"> |
|||
<div className="space-y-4"> |
|||
<Input |
|||
type="email" |
|||
label="邮箱地址" |
|||
placeholder="user@example.com" |
|||
value={email} |
|||
onChange={(e) => setEmail(e.target.value)} |
|||
required |
|||
leftIcon={<Mail className="h-4 w-4" />} |
|||
disabled={isLoading} |
|||
/> |
|||
|
|||
<Input |
|||
type="password" |
|||
label="密码" |
|||
placeholder="••••••••" |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
required |
|||
leftIcon={<Lock className="h-4 w-4" />} |
|||
disabled={isLoading} |
|||
/> |
|||
</div> |
|||
|
|||
{error && ( |
|||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm font-body"> |
|||
<AlertCircle className="h-4 w-4 flex-shrink-0" /> |
|||
<span>{error}</span> |
|||
</div> |
|||
)} |
|||
|
|||
<Button |
|||
type="submit" |
|||
variant="primary" |
|||
size="lg" |
|||
className="w-full" |
|||
isLoading={isLoading} |
|||
> |
|||
登录 |
|||
</Button> |
|||
|
|||
<div className="text-center"> |
|||
<p className="text-sm text-gray-500 font-body"> |
|||
还没有账号?{' '} |
|||
<button |
|||
type="button" |
|||
className="text-sky-400 hover:text-sky-300 transition-colors font-medium" |
|||
> |
|||
注册账号 |
|||
</button> |
|||
</p> |
|||
</div> |
|||
</form> |
|||
</Card> |
|||
|
|||
{/* Footer info */} |
|||
<div className="mt-8 text-center"> |
|||
<p className="text-xs text-gray-600 font-body"> |
|||
© 2026 Base System. All rights reserved. |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Corner decorations */} |
|||
<div className="absolute top-0 left-0 w-24 h-24 border-l-2 border-t-2 border-sky-500/30 rounded-tl-3xl" /> |
|||
<div className="absolute top-0 right-0 w-24 h-24 border-r-2 border-t-2 border-purple-500/30 rounded-tr-3xl" /> |
|||
<div className="absolute bottom-0 left-0 w-24 h-24 border-l-2 border-b-2 border-purple-500/30 rounded-bl-3xl" /> |
|||
<div className="absolute bottom-0 right-0 w-24 h-24 border-r-2 border-b-2 border-sky-500/30 rounded-br-3xl" /> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
|||
import { Input } from '@/components/ui/Input' |
|||
import { Button } from '@/components/ui/Button' |
|||
import { Settings, Save, Bell, Lock, Palette } from 'lucide-react' |
|||
|
|||
export function SettingsPage() { |
|||
return ( |
|||
<div className="space-y-6 animate-fade-in max-w-2xl"> |
|||
{/* Profile Settings */} |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle className="flex items-center gap-2"> |
|||
<Settings className="h-5 w-5 text-sky-400" /> |
|||
个人设置 |
|||
</CardTitle> |
|||
</CardHeader> |
|||
<CardContent className="space-y-4"> |
|||
<Input label="用户名" placeholder="请输入用户名" /> |
|||
<Input label="邮箱" type="email" placeholder="请输入邮箱" /> |
|||
<Input label="手机号" placeholder="请输入手机号" /> |
|||
<div className="pt-4"> |
|||
<Button variant="primary"> |
|||
<Save className="h-4 w-4" /> |
|||
保存设置 |
|||
</Button> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
{/* Notification Settings */} |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle className="flex items-center gap-2"> |
|||
<Bell className="h-5 w-5 text-sky-400" /> |
|||
通知设置 |
|||
</CardTitle> |
|||
</CardHeader> |
|||
<CardContent className="space-y-4"> |
|||
<div className="flex items-center justify-between p-4 rounded-lg bg-gray-800/50"> |
|||
<div> |
|||
<p className="text-sm font-medium text-white">邮件通知</p> |
|||
<p className="text-xs text-gray-500">接收重要操作邮件通知</p> |
|||
</div> |
|||
<label className="relative inline-flex items-center cursor-pointer"> |
|||
<input type="checkbox" defaultChecked className="sr-only peer" /> |
|||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-sky-500" /> |
|||
</label> |
|||
</div> |
|||
<div className="flex items-center justify-between p-4 rounded-lg bg-gray-800/50"> |
|||
<div> |
|||
<p className="text-sm font-medium text-white">系统消息</p> |
|||
<p className="text-xs text-gray-500">接收系统更新消息</p> |
|||
</div> |
|||
<label className="relative inline-flex items-center cursor-pointer"> |
|||
<input type="checkbox" defaultChecked className="sr-only peer" /> |
|||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-sky-500" /> |
|||
</label> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
{/* Security Settings */} |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle className="flex items-center gap-2"> |
|||
<Lock className="h-5 w-5 text-sky-400" /> |
|||
安全设置 |
|||
</CardTitle> |
|||
</CardHeader> |
|||
<CardContent className="space-y-4"> |
|||
<Input label="当前密码" type="password" placeholder="请输入当前密码" /> |
|||
<Input label="新密码" type="password" placeholder="请输入新密码" /> |
|||
<Input label="确认密码" type="password" placeholder="请确认新密码" /> |
|||
<div className="pt-4"> |
|||
<Button variant="primary"> |
|||
修改密码 |
|||
</Button> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
{/* Theme Settings */} |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle className="flex items-center gap-2"> |
|||
<Palette className="h-5 w-5 text-sky-400" /> |
|||
外观设置 |
|||
</CardTitle> |
|||
</CardHeader> |
|||
<CardContent className="space-y-4"> |
|||
<div className="flex items-center justify-between p-4 rounded-lg bg-gray-800/50"> |
|||
<div> |
|||
<p className="text-sm font-medium text-white">深色模式</p> |
|||
<p className="text-xs text-gray-500">使用深色主题</p> |
|||
</div> |
|||
<label className="relative inline-flex items-center cursor-pointer"> |
|||
<input type="checkbox" defaultChecked className="sr-only peer" /> |
|||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-sky-500" /> |
|||
</label> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
import { useState, useEffect } from 'react' |
|||
import { Plus, Search, Edit2, Trash2 } from 'lucide-react' |
|||
import { Button } from '@/components/ui/Button' |
|||
import { Input } from '@/components/ui/Input' |
|||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
|||
import { Modal } from '@/components/ui/Modal' |
|||
import { |
|||
Table, |
|||
TableHeader, |
|||
TableBody, |
|||
TableRow, |
|||
TableHead, |
|||
TableCell, |
|||
} from '@/components/ui/Table' |
|||
import type { User, CreateUserRequest, UpdateUserRequest } from '@/types' |
|||
import { apiClient } from '@/services/api' |
|||
|
|||
export function UserManagementPage() { |
|||
const [users, setUsers] = useState<User[]>([]) |
|||
const [isLoading, setIsLoading] = useState(true) |
|||
const [searchQuery, setSearchQuery] = useState('') |
|||
const [isModalOpen, setIsModalOpen] = useState(false) |
|||
const [editingUser, setEditingUser] = useState<User | null>(null) |
|||
const [formData, setFormData] = useState<Partial<CreateUserRequest> & { password?: string }>({ |
|||
username: '', |
|||
email: '', |
|||
password: '', |
|||
phone: '', |
|||
}) |
|||
|
|||
useEffect(() => { |
|||
fetchUsers() |
|||
}, []) |
|||
|
|||
const fetchUsers = async () => { |
|||
try { |
|||
setIsLoading(true) |
|||
const response = await apiClient.getUsers({ page: 1, pageSize: 100 }) |
|||
if (response.success && response.data) { |
|||
setUsers(response.data.users) |
|||
} |
|||
} catch (error) { |
|||
console.error('Failed to fetch users:', error) |
|||
// Use mock data if API fails
|
|||
setUsers([ |
|||
{ id: 1, username: 'admin', email: 'admin@example.com', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, |
|||
{ id: 2, username: 'user1', email: 'user1@example.com', phone: '13800138000', createdAt: '2024-01-02', updatedAt: '2024-01-02' }, |
|||
{ id: 3, username: 'user2', email: 'user2@example.com', phone: '13900139000', createdAt: '2024-01-03', updatedAt: '2024-01-03' }, |
|||
]) |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
const handleCreateUser = async () => { |
|||
try { |
|||
await apiClient.createUser(formData as CreateUserRequest) |
|||
await fetchUsers() |
|||
setIsModalOpen(false) |
|||
resetForm() |
|||
} catch (error) { |
|||
console.error('Failed to create user:', error) |
|||
alert('创建用户失败') |
|||
} |
|||
} |
|||
|
|||
const handleUpdateUser = async () => { |
|||
if (!editingUser) return |
|||
try { |
|||
const { password, ...data } = formData as UpdateUserRequest & { password?: string } |
|||
await apiClient.updateUser(editingUser.id, data) |
|||
await fetchUsers() |
|||
setIsModalOpen(false) |
|||
resetForm() |
|||
} catch (error) { |
|||
console.error('Failed to update user:', error) |
|||
alert('更新用户失败') |
|||
} |
|||
} |
|||
|
|||
const handleDeleteUser = async (id: number) => { |
|||
if (!confirm('确定要删除该用户吗?')) return |
|||
try { |
|||
await apiClient.deleteUser(id) |
|||
await fetchUsers() |
|||
} catch (error) { |
|||
console.error('Failed to delete user:', error) |
|||
alert('删除用户失败') |
|||
} |
|||
} |
|||
|
|||
const openModal = (user?: User) => { |
|||
if (user) { |
|||
setEditingUser(user) |
|||
setFormData({ |
|||
username: user.username, |
|||
email: user.email, |
|||
phone: user.phone, |
|||
}) |
|||
} else { |
|||
setEditingUser(null) |
|||
setFormData({ |
|||
username: '', |
|||
email: '', |
|||
password: '', |
|||
phone: '', |
|||
}) |
|||
} |
|||
setIsModalOpen(true) |
|||
} |
|||
|
|||
const resetForm = () => { |
|||
setFormData({ |
|||
username: '', |
|||
email: '', |
|||
password: '', |
|||
phone: '', |
|||
}) |
|||
setEditingUser(null) |
|||
} |
|||
|
|||
const filteredUsers = users.filter( |
|||
(user) => |
|||
user.username.toLowerCase().includes(searchQuery.toLowerCase()) || |
|||
user.email.toLowerCase().includes(searchQuery.toLowerCase()) |
|||
) |
|||
|
|||
return ( |
|||
<div className="space-y-6 animate-fade-in"> |
|||
{/* Header */} |
|||
<Card> |
|||
<CardContent className="p-6"> |
|||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
|||
<div className="flex-1 w-full sm:max-w-md"> |
|||
<Input |
|||
placeholder="搜索用户..." |
|||
value={searchQuery} |
|||
onChange={(e) => setSearchQuery(e.target.value)} |
|||
leftIcon={<Search className="h-4 w-4" />} |
|||
/> |
|||
</div> |
|||
<Button |
|||
onClick={() => openModal()} |
|||
className="whitespace-nowrap" |
|||
> |
|||
<Plus className="h-4 w-4" /> |
|||
添加用户 |
|||
</Button> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
{/* Users Table */} |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>用户列表 ({filteredUsers.length})</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="overflow-x-auto"> |
|||
<Table> |
|||
<TableHeader> |
|||
<TableRow> |
|||
<TableHead>ID</TableHead> |
|||
<TableHead>用户名</TableHead> |
|||
<TableHead>邮箱</TableHead> |
|||
<TableHead>手机号</TableHead> |
|||
<TableHead>创建时间</TableHead> |
|||
<TableHead className="text-right">操作</TableHead> |
|||
</TableRow> |
|||
</TableHeader> |
|||
<TableBody> |
|||
{isLoading ? ( |
|||
<TableRow> |
|||
<TableCell>加载中...</TableCell> |
|||
</TableRow> |
|||
) : filteredUsers.length === 0 ? ( |
|||
<TableRow> |
|||
<TableCell>暂无数据</TableCell> |
|||
</TableRow> |
|||
) : ( |
|||
filteredUsers.map((user) => ( |
|||
<TableRow key={user.id}> |
|||
<TableCell className="text-gray-400">#{user.id}</TableCell> |
|||
<TableCell className="font-medium text-white">{user.username}</TableCell> |
|||
<TableCell className="text-gray-400">{user.email}</TableCell> |
|||
<TableCell className="text-gray-400">{user.phone || '-'}</TableCell> |
|||
<TableCell className="text-gray-400"> |
|||
{new Date(user.createdAt).toLocaleDateString('zh-CN')} |
|||
</TableCell> |
|||
<TableCell className="text-right"> |
|||
<div className="flex items-center justify-end gap-2"> |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => openModal(user)} |
|||
> |
|||
<Edit2 className="h-4 w-4" /> |
|||
</Button> |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => handleDeleteUser(user.id)} |
|||
className="text-red-400 hover:text-red-300" |
|||
> |
|||
<Trash2 className="h-4 w-4" /> |
|||
</Button> |
|||
</div> |
|||
</TableCell> |
|||
</TableRow> |
|||
)) |
|||
)} |
|||
</TableBody> |
|||
</Table> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
{/* Modal */} |
|||
<Modal |
|||
isOpen={isModalOpen} |
|||
onClose={() => { |
|||
setIsModalOpen(false) |
|||
resetForm() |
|||
}} |
|||
title={editingUser ? '编辑用户' : '添加用户'} |
|||
size="md" |
|||
footer={ |
|||
<> |
|||
<Button |
|||
variant="outline" |
|||
onClick={() => { |
|||
setIsModalOpen(false) |
|||
resetForm() |
|||
}} |
|||
> |
|||
取消 |
|||
</Button> |
|||
<Button |
|||
onClick={editingUser ? handleUpdateUser : handleCreateUser} |
|||
> |
|||
{editingUser ? '保存' : '创建'} |
|||
</Button> |
|||
</> |
|||
} |
|||
> |
|||
<div className="space-y-4"> |
|||
<Input |
|||
label="用户名" |
|||
placeholder="请输入用户名" |
|||
value={formData.username} |
|||
onChange={(e) => setFormData({ ...formData, username: e.target.value })} |
|||
/> |
|||
<Input |
|||
label="邮箱" |
|||
type="email" |
|||
placeholder="请输入邮箱" |
|||
value={formData.email} |
|||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} |
|||
/> |
|||
{!editingUser && ( |
|||
<Input |
|||
label="密码" |
|||
type="password" |
|||
placeholder="请输入密码" |
|||
value={formData.password} |
|||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} |
|||
/> |
|||
)} |
|||
<Input |
|||
label="手机号" |
|||
placeholder="请输入手机号" |
|||
value={formData.phone} |
|||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} |
|||
/> |
|||
</div> |
|||
</Modal> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,139 @@ |
|||
import type { |
|||
ApiResponse, |
|||
LoginResponse, |
|||
LoginRequest, |
|||
User, |
|||
UserListRequest, |
|||
UserListResponse, |
|||
CreateUserRequest, |
|||
UpdateUserRequest, |
|||
} from '@/types' |
|||
|
|||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1' |
|||
|
|||
class ApiClient { |
|||
private get token(): string | null { |
|||
return localStorage.getItem('token') |
|||
} |
|||
|
|||
private set token(value: string | null) { |
|||
if (value) { |
|||
localStorage.setItem('token', value) |
|||
} else { |
|||
localStorage.removeItem('token') |
|||
} |
|||
} |
|||
|
|||
private async request<T>( |
|||
endpoint: string, |
|||
options: RequestInit = {} |
|||
): Promise<T> { |
|||
const url = `${API_BASE_URL}${endpoint}` |
|||
const headers: Record<string, string> = { |
|||
'Content-Type': 'application/json', |
|||
} |
|||
|
|||
// Merge additional headers
|
|||
if (options.headers) { |
|||
Object.assign(headers, options.headers) |
|||
} |
|||
|
|||
if (this.token) { |
|||
headers['Authorization'] = `Bearer ${this.token}` |
|||
} |
|||
|
|||
try { |
|||
const response = await fetch(url, { |
|||
...options, |
|||
headers, |
|||
}) |
|||
|
|||
const data = await response.json() |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(data.message || 'Request failed') |
|||
} |
|||
|
|||
return data |
|||
} catch (error) { |
|||
console.error('API request error:', error) |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
// Auth
|
|||
async login(credentials: LoginRequest): Promise<LoginResponse> { |
|||
const response = await this.request<LoginResponse>('/auth/login', { |
|||
method: 'POST', |
|||
body: JSON.stringify(credentials), |
|||
}) |
|||
|
|||
if (response.success && response.token) { |
|||
this.token = response.token |
|||
} |
|||
|
|||
return response |
|||
} |
|||
|
|||
async logout(): Promise<void> { |
|||
this.token = null |
|||
} |
|||
|
|||
async refreshToken(refreshToken: string): Promise<LoginResponse> { |
|||
return this.request<LoginResponse>('/auth/refresh', { |
|||
method: 'POST', |
|||
body: JSON.stringify({ token: refreshToken }), |
|||
}) |
|||
} |
|||
|
|||
// User Management
|
|||
async getUsers(params: UserListRequest = {}): Promise<UserListResponse> { |
|||
const queryParams = new URLSearchParams() |
|||
if (params.page) queryParams.append('page', params.page.toString()) |
|||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString()) |
|||
if (params.keyword) queryParams.append('keyword', params.keyword) |
|||
|
|||
return this.request<UserListResponse>(`/user/users?${queryParams}`) |
|||
} |
|||
|
|||
async getUser(id: number): Promise<ApiResponse<User>> { |
|||
return this.request<ApiResponse<User>>(`/user/user/${id}`) |
|||
} |
|||
|
|||
async createUser(data: CreateUserRequest): Promise<ApiResponse<User>> { |
|||
return this.request<ApiResponse<User>>('/user/user', { |
|||
method: 'POST', |
|||
body: JSON.stringify(data), |
|||
}) |
|||
} |
|||
|
|||
async updateUser(id: number, data: UpdateUserRequest): Promise<ApiResponse<User>> { |
|||
return this.request<ApiResponse<User>>(`/user/user/${id}`, { |
|||
method: 'PUT', |
|||
body: JSON.stringify(data), |
|||
}) |
|||
} |
|||
|
|||
async deleteUser(id: number): Promise<ApiResponse<void>> { |
|||
return this.request<ApiResponse<void>>(`/user/user/${id}`, { |
|||
method: 'DELETE', |
|||
}) |
|||
} |
|||
|
|||
// Profile
|
|||
async getProfile(): Promise<ApiResponse> { |
|||
return this.request<ApiResponse>('/profile/me') |
|||
} |
|||
|
|||
// Health check
|
|||
async healthCheck(): Promise<{ status: string }> { |
|||
try { |
|||
const response = await fetch(`${API_BASE_URL}/health`) |
|||
return response.json() |
|||
} catch { |
|||
return { status: 'error' } |
|||
} |
|||
} |
|||
} |
|||
|
|||
export const apiClient = new ApiClient() |
|||
@ -0,0 +1,96 @@ |
|||
// API Response Types
|
|||
export interface ApiResponse<T = any> { |
|||
code: number |
|||
message: string |
|||
success: boolean |
|||
data?: T |
|||
} |
|||
|
|||
export interface LoginResponse { |
|||
code: number |
|||
message: string |
|||
success: boolean |
|||
token: string |
|||
} |
|||
|
|||
// User Types
|
|||
export interface User { |
|||
id: number |
|||
username: string |
|||
email: string |
|||
phone?: string |
|||
avatar?: string |
|||
createdAt: string |
|||
updatedAt: string |
|||
} |
|||
|
|||
export interface UserInfo extends User {} |
|||
|
|||
// Request Types
|
|||
export interface LoginRequest { |
|||
email: string |
|||
password: string |
|||
} |
|||
|
|||
export interface RegisterRequest { |
|||
username: string |
|||
email: string |
|||
password: string |
|||
phone?: string |
|||
} |
|||
|
|||
export interface CreateUserRequest { |
|||
username: string |
|||
email: string |
|||
password: string |
|||
phone?: string |
|||
} |
|||
|
|||
export interface UpdateUserRequest { |
|||
username?: string |
|||
email?: string |
|||
phone?: string |
|||
avatar?: string |
|||
} |
|||
|
|||
export interface UserListRequest { |
|||
page?: number |
|||
pageSize?: number |
|||
keyword?: string |
|||
} |
|||
|
|||
export interface UserListResponse { |
|||
code: number |
|||
message: string |
|||
success: boolean |
|||
data: { |
|||
users: User[] |
|||
total: number |
|||
page: number |
|||
pageSize: number |
|||
} |
|||
} |
|||
|
|||
// Profile Types
|
|||
export interface Profile { |
|||
id: number |
|||
userId: number |
|||
username: string |
|||
email: string |
|||
phone?: string |
|||
bio?: string |
|||
avatar?: string |
|||
createdAt: string |
|||
updatedAt: string |
|||
} |
|||
|
|||
export interface UpdateProfileRequest { |
|||
bio?: string |
|||
avatar?: string |
|||
phone?: string |
|||
} |
|||
|
|||
export interface ChangePasswordRequest { |
|||
oldPassword: string |
|||
newPassword: string |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |
|||
"target": "ES2022", |
|||
"useDefineForClassFields": true, |
|||
"lib": ["ES2022", "DOM", "DOM.Iterable"], |
|||
"module": "ESNext", |
|||
"types": ["vite/client"], |
|||
"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, |
|||
|
|||
/* Path Alias */ |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": ["./src/*"] |
|||
} |
|||
}, |
|||
"include": ["src"] |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
{ |
|||
"files": [], |
|||
"references": [ |
|||
{ "path": "./tsconfig.app.json" }, |
|||
{ "path": "./tsconfig.node.json" } |
|||
] |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |
|||
"target": "ES2023", |
|||
"lib": ["ES2023"], |
|||
"module": "ESNext", |
|||
"types": ["node"], |
|||
"skipLibCheck": true, |
|||
|
|||
/* Bundler mode */ |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"verbatimModuleSyntax": true, |
|||
"moduleDetection": "force", |
|||
"noEmit": true, |
|||
|
|||
/* Linting */ |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"erasableSyntaxOnly": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"noUncheckedSideEffectImports": true |
|||
}, |
|||
"include": ["vite.config.ts"] |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import { defineConfig } from 'vite' |
|||
import react from '@vitejs/plugin-react' |
|||
import path from 'path' |
|||
|
|||
// https://vite.dev/config/
|
|||
export default defineConfig({ |
|||
plugins: [react()], |
|||
resolve: { |
|||
alias: { |
|||
'@': path.resolve(__dirname, './src'), |
|||
}, |
|||
}, |
|||
}) |
|||
Loading…
Reference in new issue