搭建用户体系
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# 前端构建
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
|
||||
FROM ${NODE_IMAGE} AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
# 使用 nginx 提供静态资源
|
||||
FROM nginx:1.29.0-alpine
|
||||
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<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" />
|
||||
<meta name="theme-color" content="#0A0A0B" />
|
||||
<title>A股工具</title>
|
||||
</head>
|
||||
<body class="bg-base text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3019;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "stock-user-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.439.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#8B5CF6"/>
|
||||
<text x="50" y="68" font-family="system-ui, sans-serif" font-size="52" font-weight="bold" fill="white" text-anchor="middle">A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { api, UserInfo } from '@/lib/api'
|
||||
import { clearAuth, RoleName } from '@/lib/auth'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
allowedRoles?: RoleName[]
|
||||
requireAuth?: boolean
|
||||
}
|
||||
|
||||
export function AuthGuard({ children, allowedRoles, requireAuth = true }: AuthGuardProps) {
|
||||
const location = useLocation()
|
||||
const [user, setUser] = useState<UserInfo | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
api.me()
|
||||
.then(setUser)
|
||||
.catch(() => {
|
||||
clearAuth()
|
||||
setUser(null)
|
||||
})
|
||||
}, [location.pathname])
|
||||
|
||||
if (user === undefined) {
|
||||
return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (requireAuth && !user) {
|
||||
return <Navigate to="/login" state={{ from: location.pathname }} replace />
|
||||
}
|
||||
|
||||
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
User,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
Menu,
|
||||
} from 'lucide-react'
|
||||
import { api, UserInfo } from '@/lib/api'
|
||||
import { clearAuth, canManageUsers, roleLabel } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
|
||||
const BRAND = '#8B5CF6'
|
||||
|
||||
export function Layout() {
|
||||
const { resolved, setTheme, theme } = useTheme()
|
||||
const isDark = resolved === 'dark'
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
api.me()
|
||||
.then(setUser)
|
||||
.catch(() => {
|
||||
clearAuth()
|
||||
setUser(null)
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {}
|
||||
clearAuth()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
|
||||
{ to: '/profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === 'system') {
|
||||
setTheme(isDark ? 'light' : 'dark')
|
||||
} else {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden w-56 lg:w-60">
|
||||
<div className="px-5 py-5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="h-7 w-7 rounded-lg flex items-center justify-center text-white font-bold text-sm"
|
||||
style={{ background: BRAND }}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
|
||||
<div>A股</div>
|
||||
<div>工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
量化工具
|
||||
</div>
|
||||
<div
|
||||
className="mt-3 h-px"
|
||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
||||
isActive
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-3 py-3 shrink-0 space-y-2">
|
||||
{user && (
|
||||
<div className="px-3 py-2 rounded-btn bg-elevated/50">
|
||||
<div className="text-sm font-medium text-foreground truncate">{user.username}</div>
|
||||
<div className="text-[10px] text-secondary">{roleLabel(user.role)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-secondary hover:bg-elevated hover:text-foreground transition-colors"
|
||||
>
|
||||
{isDark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
{isDark ? '浅色' : '深色'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-danger hover:bg-danger/10 transition-colors"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col lg:grid lg:grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
||||
<span className="text-sm font-semibold">A股工作台</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={cn('fixed inset-0 z-40 lg:static lg:block', mobileOpen ? 'block' : 'hidden')}>
|
||||
<div className="absolute inset-0 bg-black/50 lg:hidden" onClick={() => setMobileOpen(false)} />
|
||||
<div className="relative z-10 h-full max-w-[14rem]">{sidebar}</div>
|
||||
</div>
|
||||
<main className="flex-1 min-h-0 overflow-auto scrollbar-gutter-stable p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { Shield, Lock, AlertCircle, Loader2, UserPlus, LogIn } from 'lucide-react'
|
||||
import { api, setToken } from '@/lib/api'
|
||||
import { setStoredUser } from '@/lib/auth'
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
if (mode === 'register' && password.length < 6) {
|
||||
setError('密码长度至少 6 位')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res =
|
||||
mode === 'login'
|
||||
? await api.login({ username: username.trim(), password })
|
||||
: await api.register({
|
||||
username: username.trim(),
|
||||
email: email.trim() || undefined,
|
||||
password,
|
||||
})
|
||||
setToken(res.token)
|
||||
setStoredUser(res.user)
|
||||
onSuccess()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '操作失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div
|
||||
className="rounded-2xl p-4 border border-border"
|
||||
style={{ background: 'linear-gradient(135deg, #8B5CF622, transparent)' }}
|
||||
>
|
||||
<Shield className="h-8 w-8" style={{ color: '#8B5CF6' }} />
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">
|
||||
{mode === 'login' ? '登录账号' : '注册账号'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-secondary leading-relaxed">
|
||||
{mode === 'login'
|
||||
? '请输入用户名和密码进入系统。'
|
||||
: '注册后默认获得用户权限。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="邮箱(可选)"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||
<Lock className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : mode === 'login' ? (
|
||||
<LogIn className="h-4 w-4" />
|
||||
) : (
|
||||
<UserPlus className="h-4 w-4" />
|
||||
)}
|
||||
{loading ? '处理中…' : mode === 'login' ? '登录' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login')
|
||||
setError('')
|
||||
}}
|
||||
className="text-xs text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
{mode === 'login' ? '没有账号?立即注册' : '已有账号?直接登录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// 简单内联 motion 组件,避免引入 framer-motion 依赖
|
||||
const motion = {
|
||||
div: ({ children, className, ...props }: any) => {
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el && props.initial) {
|
||||
el.style.opacity = String(props.initial.opacity ?? 1)
|
||||
el.style.transform = `translateY(${props.initial.y ?? 0}px)`
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = `all ${props.transition?.duration ?? 0.3}s ${(props.transition?.ease || [0.16, 1, 0.3, 1]).join(',')}`
|
||||
el.style.opacity = String(props.animate?.opacity ?? 1)
|
||||
el.style.transform = `translateY(${props.animate?.y ?? 0}px)`
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolved: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
if (theme !== 'system') return theme
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function updateMetaThemeColor(resolved: ResolvedTheme) {
|
||||
if (typeof document === 'undefined') return
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (!meta) return
|
||||
meta.setAttribute('content', resolved === 'dark' ? '#0A0A0B' : '#FAFAFA')
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved
|
||||
} catch {}
|
||||
return 'system'
|
||||
})
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(theme))
|
||||
|
||||
useEffect(() => {
|
||||
const nextResolved = resolveTheme(theme)
|
||||
setResolved(nextResolved)
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(nextResolved)
|
||||
try {
|
||||
localStorage.setItem('theme', theme)
|
||||
} catch {}
|
||||
updateMetaThemeColor(nextResolved)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
const nextResolved = resolveTheme('system')
|
||||
setResolved(nextResolved)
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(nextResolved)
|
||||
updateMetaThemeColor(nextResolved)
|
||||
}
|
||||
media.addEventListener('change', handler)
|
||||
return () => media.removeEventListener('change', handler)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
setThemeState(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 暗色为默认(html.dark) / 亮色用 :root 反转 */
|
||||
:root {
|
||||
--base: 0 0% 98%;
|
||||
--surface: 0 0% 100%;
|
||||
--elevated: 240 5% 96%;
|
||||
--border: 240 6% 90%;
|
||||
--fg-primary: 240 6% 10%;
|
||||
--fg-secondary: 240 4% 35%;
|
||||
--fg-muted: 240 5% 65%;
|
||||
--accent: 217 91% 60%;
|
||||
--bull: 4 87% 60%;
|
||||
--bear: 152 67% 45%;
|
||||
--warning: 32 95% 50%;
|
||||
--danger: 4 87% 60%;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--base: 240 5% 5%;
|
||||
--surface: 240 7% 10%;
|
||||
--elevated: 240 9% 14%;
|
||||
--border: 240 5% 22%;
|
||||
--fg-primary: 0 0% 98%;
|
||||
--fg-secondary: 240 5% 78%;
|
||||
--fg-muted: 240 5% 58%;
|
||||
--accent: 217 91% 60%;
|
||||
--bull: 4 87% 60%;
|
||||
--bear: 152 67% 45%;
|
||||
--warning: 32 95% 50%;
|
||||
--danger: 4 87% 60%;
|
||||
}
|
||||
|
||||
.scrollbar-gutter-stable { scrollbar-gutter: stable; }
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border) / 0.72);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--accent) / 0.75);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.tabular { font-variant-numeric: tabular-nums; }
|
||||
.num { font-family: theme('fontFamily.mono'); font-variant-numeric: tabular-nums; }
|
||||
|
||||
* { border-color: hsl(var(--border)); }
|
||||
|
||||
body {
|
||||
background: hsl(var(--base));
|
||||
color: hsl(var(--fg-primary));
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success?: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
role: 'system_admin' | 'admin' | 'user' | 'guest'
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
name: 'system_admin' | 'admin' | 'user' | 'guest'
|
||||
description?: string
|
||||
permissions: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('access_token')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
localStorage.removeItem('access_token')
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${path}`
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
let data: any
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data.error || data.message || `HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
publicInfo: () =>
|
||||
request<{ message: string; guest_allowed: boolean }>('/api/public'),
|
||||
|
||||
register: (body: { username: string; email?: string; password: string }) =>
|
||||
request<AuthResponse>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
login: (body: { username: string; password: string }) =>
|
||||
request<AuthResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
me: () => request<UserInfo>('/api/auth/me'),
|
||||
|
||||
logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listUsers: () => request<UserInfo[]>('/api/admin/users'),
|
||||
|
||||
createUser: (body: { username: string; email?: string; password: string; role?: string }) =>
|
||||
request<UserInfo>('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
updateUser: (id: string, body: Partial<{ email: string; role: string; status: string }>) =>
|
||||
request<UserInfo>(`/api/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
deleteUser: (id: string) =>
|
||||
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listRoles: () => request<Role[]>('/api/admin/roles'),
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { UserInfo } from './api'
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
try {
|
||||
return !!localStorage.getItem('access_token')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredUser(): UserInfo | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('user')
|
||||
return raw ? (JSON.parse(raw) as UserInfo) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredUser(user: UserInfo | null) {
|
||||
if (user) {
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
} else {
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
export type RoleName = 'system_admin' | 'admin' | 'user' | 'guest'
|
||||
|
||||
const ROLE_RANK: Record<RoleName, number> = {
|
||||
system_admin: 3,
|
||||
admin: 2,
|
||||
user: 1,
|
||||
guest: 0,
|
||||
}
|
||||
|
||||
export function roleRank(role: RoleName): number {
|
||||
return ROLE_RANK[role] ?? 0
|
||||
}
|
||||
|
||||
export function roleLabel(role: RoleName): string {
|
||||
switch (role) {
|
||||
case 'system_admin':
|
||||
return '系统管理员'
|
||||
case 'admin':
|
||||
return '管理员'
|
||||
case 'user':
|
||||
return '用户'
|
||||
case 'guest':
|
||||
return '游客'
|
||||
}
|
||||
}
|
||||
|
||||
export function canAccess(role: RoleName, allowed: RoleName[]): boolean {
|
||||
return allowed.includes(role)
|
||||
}
|
||||
|
||||
export function canManageUsers(role: RoleName): boolean {
|
||||
return role === 'admin' || role === 'system_admin'
|
||||
}
|
||||
|
||||
export function canDeleteUsers(role: RoleName): boolean {
|
||||
return role === 'system_admin'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { router } from './router'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutDashboard, Globe, ShieldCheck } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { getStoredUser, roleLabel } from '@/lib/auth'
|
||||
|
||||
export function Dashboard() {
|
||||
const [publicInfo, setPublicInfo] = useState<{ message: string } | null>(null)
|
||||
const user = getStoredUser()
|
||||
|
||||
useEffect(() => {
|
||||
api.publicInfo().then(setPublicInfo).catch(() => null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">看板</h1>
|
||||
<p className="text-sm text-secondary">欢迎回来,{user?.username || '游客'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card
|
||||
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
||||
title="当前身份"
|
||||
value={user ? roleLabel(user.role) : '游客'}
|
||||
desc={user ? `用户:${user.username}` : '未登录,仅可查看公开信息'}
|
||||
/>
|
||||
<Card
|
||||
icon={<Globe className="h-5 w-5 text-bear" />}
|
||||
title="公开信息"
|
||||
value="已接入"
|
||||
desc={publicInfo?.message || '加载中…'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">权限说明</h2>
|
||||
<ul className="space-y-2 text-sm text-secondary">
|
||||
<li><span className="text-accent font-medium">系统管理员</span>:可管理管理员、用户、系统配置</li>
|
||||
<li><span className="text-accent font-medium">管理员</span>:可管理普通用户</li>
|
||||
<li><span className="text-accent font-medium">用户</span>:可访问业务功能、修改个人资料</li>
|
||||
<li><span className="text-accent font-medium">游客</span>:仅查看公开内容,不可操作</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
desc,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
value: string
|
||||
desc: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-4 transition hover:border-accent/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-xs font-medium text-secondary uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="mt-1 text-xs text-muted">{desc}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginForm } from '@/components/LoginForm'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
const BRAND = '#8B5CF6'
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
|
||||
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
|
||||
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2.5 text-foreground">
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
||||
<span className="text-sm font-semibold tracking-tight">A股工具</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
|
||||
<LoginForm onSuccess={() => navigate('/', { replace: true })} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { User, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { api, UserInfo } from '@/lib/api'
|
||||
import { roleLabel } from '@/lib/auth'
|
||||
|
||||
export function Profile() {
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.me()
|
||||
.then(setUser)
|
||||
.catch((err: any) => setError(err?.message || '加载失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground">个人中心</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">用户名</span>
|
||||
<span className="text-sm font-medium text-foreground">{user?.username}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">角色</span>
|
||||
<span className="text-sm font-medium text-foreground">{user ? roleLabel(user.role) : '-'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">邮箱</span>
|
||||
<span className="text-sm font-medium text-foreground">{user?.email || '未设置'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">状态</span>
|
||||
<span className="text-sm font-medium text-foreground">{user?.status === 'active' ? '启用' : '禁用'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
|
||||
<span className="text-sm text-secondary">注册时间</span>
|
||||
<span className="text-sm font-medium text-foreground">{user ? new Date(user.created_at).toLocaleString() : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">关于</h2>
|
||||
<p className="text-sm text-secondary leading-relaxed">
|
||||
本系统为 A 股复盘工具的用户权限管理模块。不同角色拥有不同的操作权限,系统管理员可在「用户管理」中分配角色。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Plus, Trash2, Users as UsersIcon, AlertCircle } from 'lucide-react'
|
||||
import { api, Role, UserInfo } from '@/lib/api'
|
||||
import { canDeleteUsers, roleLabel } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Users() {
|
||||
const [users, setUsers] = useState<UserInfo[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [form, setForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [u, r] = await Promise.all([api.listUsers(), api.listRoles()])
|
||||
setUsers(u)
|
||||
setRoles(r)
|
||||
setError('')
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.createUser({
|
||||
username: form.username,
|
||||
email: form.email || undefined,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
})
|
||||
setForm({ username: '', email: '', password: '', role: 'user' })
|
||||
setFormOpen(false)
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除该用户?')) return
|
||||
try {
|
||||
await api.deleteUser(id)
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (user: UserInfo, status: string) => {
|
||||
try {
|
||||
await api.updateUser(user.id, { status })
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const currentUser = users.find((u) => u.username === JSON.parse(localStorage.getItem('user') || '{}')?.username)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground">用户管理</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormOpen(!formOpen)}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="rounded-card border border-border bg-surface p-4 grid grid-cols-1 md:grid-cols-5 gap-3"
|
||||
>
|
||||
<input
|
||||
required
|
||||
placeholder="用户名"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.name}>{roleLabel(r.name)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="md:col-span-5 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-elevated/50 text-secondary">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">用户名</th>
|
||||
<th className="px-4 py-3 text-left font-medium">角色</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted">暂无用户</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-elevated/30">
|
||||
<td className="px-4 py-3 text-foreground">{u.username}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded', roleBadge(u.role))}>
|
||||
{roleLabel(u.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={u.status}
|
||||
onChange={(e) => handleStatusChange(u, e.target.value)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
className="bg-base border border-border rounded-input px-2 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted text-xs">
|
||||
{new Date(u.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canDeleteUsers(currentUser?.role || 'user') && u.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => handleDelete(u.id)}
|
||||
className="p-1.5 rounded-btn text-danger hover:bg-danger/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function roleBadge(role: string) {
|
||||
switch (role) {
|
||||
case 'system_admin':
|
||||
return 'bg-accent/10 text-accent'
|
||||
case 'admin':
|
||||
return 'bg-purple-500/10 text-purple-400'
|
||||
case 'user':
|
||||
return 'bg-bear/10 text-bear'
|
||||
default:
|
||||
return 'bg-muted/10 text-muted'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { AuthGuard } from './components/AuthGuard'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Users } from './pages/Users'
|
||||
import { Profile } from './pages/Profile'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <Login /> },
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
<AuthGuard allowedRoles={['admin', 'system_admin']} requireAuth>
|
||||
<Users />
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: (
|
||||
<AuthGuard requireAuth>
|
||||
<Profile />
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
])
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
import animate from 'tailwindcss-animate'
|
||||
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
container: { center: true, padding: '1rem' },
|
||||
extend: {
|
||||
colors: {
|
||||
base: 'hsl(var(--base) / <alpha-value>)',
|
||||
surface: 'hsl(var(--surface) / <alpha-value>)',
|
||||
elevated: 'hsl(var(--elevated) / <alpha-value>)',
|
||||
border: 'hsl(var(--border) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--fg-primary) / <alpha-value>)',
|
||||
secondary: 'hsl(var(--fg-secondary) / <alpha-value>)',
|
||||
muted: 'hsl(var(--fg-muted) / <alpha-value>)',
|
||||
accent: 'hsl(var(--accent) / <alpha-value>)',
|
||||
bull: 'hsl(var(--bull) / <alpha-value>)',
|
||||
bear: 'hsl(var(--bear) / <alpha-value>)',
|
||||
warning: 'hsl(var(--warning) / <alpha-value>)',
|
||||
danger: 'hsl(var(--danger) / <alpha-value>)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', '"HarmonyOS Sans SC"', '"PingFang SC"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', '"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
borderRadius: {
|
||||
card: '8px',
|
||||
btn: '6px',
|
||||
input: '4px',
|
||||
dialog: '12px',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
smooth: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
} satisfies Config
|
||||
@@ -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,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3019',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user