搭建用户体系

This commit is contained in:
2026-07-03 23:40:33 +08:00
parent 37a9f865ed
commit 2df19fcd02
40 changed files with 2622 additions and 0 deletions
+18
View File
@@ -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;"]
+14
View File
@@ -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>
+19
View File
@@ -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;
}
}
+34
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+4
View File
@@ -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

+48
View File
@@ -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}</>
}
+154
View File
@@ -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>
)
}
+181
View File
@@ -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>
)
},
}
+81
View File
@@ -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
}
+72
View File
@@ -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;
}
+119
View File
@@ -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'),
}
+69
View File
@@ -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'
}
+7
View File
@@ -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))
}
+14
View File
@@ -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>,
)
+75
View File
@@ -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>
)
}
+50
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+217
View File
@@ -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'
}
}
+35
View File
@@ -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 /> },
])
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+40
View File
@@ -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
+25
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -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',
},
})