refactor: reorganize models to pkg/model and add authentication module

- Move ORM models from internal/model to pkg/model organized by module (auth/k8s/registry)
- Add authentication module with login, user management handlers
- Update all import paths to use new model locations
- Add frontend auth pages (Login, UserManagement) and authStore
- Remove deprecated internal/model/model.go
This commit is contained in:
loveuer
2025-11-20 14:55:48 +08:00
parent a80744c533
commit 8b655d3496
34 changed files with 1410 additions and 191 deletions

View File

@@ -1,11 +1,40 @@
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui/material'
import { Routes, Route, Link } from 'react-router-dom'
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material'
import { Routes, Route, Link, Navigate } from 'react-router-dom'
import { useState } from 'react'
import { AccountCircle, Logout } from '@mui/icons-material'
import { useAppStore } from './stores/appStore'
import { useAuthStore } from './stores/authStore'
import RegistryImageList from './pages/RegistryImageList'
import K8sResourceList from './pages/K8sResourceList'
import UserManagement from './pages/UserManagement'
import Login from './pages/Login'
function App() {
const { count, increment, decrement, reset } = useAppStore()
const { isAuthenticated, logout, username } = useAuthStore()
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleLogout = () => {
handleMenuClose()
logout()
}
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
return (
<Box sx={{ flexGrow: 1, minHeight: '100vh' }}>
@@ -17,12 +46,54 @@ function App() {
<Button color="inherit" component={Link} to="/"></Button>
<Button color="inherit" component={Link} to="/registry/image"></Button>
<Button color="inherit" component={Link} to="/k8s/resources"></Button>
<Button color="inherit" component={Link} to="/users"></Button>
<Box
sx={{
ml: 2,
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
padding: '4px 12px',
borderRadius: 1,
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
}}
onClick={handleMenuOpen}
>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main' }}>
{username?.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="body2">
{username}
</Typography>
</Box>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<MenuItem onClick={handleLogout}>
<Logout fontSize="small" sx={{ mr: 1 }} />
退
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Routes>
<Route path="/registry/image" element={<RegistryImageList />} />
<Route path="/k8s/resources" element={<K8sResourceList />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/" element={
<Box>
<Typography variant="h4" component="h1" gutterBottom>

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import {
Box,
Paper,
TextField,
Button,
Typography,
Alert,
CircularProgress,
Container,
InputAdornment,
IconButton,
} from '@mui/material'
import { Visibility, VisibilityOff } from '@mui/icons-material'
import { useAuthStore } from '../stores/authStore'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const { login, loading, error } = useAuthStore()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await login(username, password)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url(/api/v1/auth/wallpaper)',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: 'blur(8px)',
zIndex: -2,
},
'&::after': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
zIndex: -1,
},
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400,
}}
>
<Typography variant="h4" component="h1" gutterBottom align="center">
Cluster
</Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 3 }}>
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="用户名"
variant="outlined"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
autoFocus
/>
<TextField
fullWidth
label="密码"
type={showPassword ? 'text' : 'password'}
variant="outlined"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading || !username || !password}
sx={{ mt: 3 }}
>
{loading ? <CircularProgress size={24} /> : '登录'}
</Button>
</form>
</Paper>
</Box>
</Container>
)
}

View File

@@ -0,0 +1,353 @@
import { useEffect, useState } from 'react'
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
CircularProgress,
Alert,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Chip,
FormControl,
InputLabel,
Select,
MenuItem,
Checkbox,
FormGroup,
FormControlLabel,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material'
interface User {
id: number
username: string
email: string
nickname: string
role: string
status: string
permissions: string
created_at: string
}
interface UserFormData {
username: string
password: string
email: string
nickname: string
role: string
status: string
permissions: string[]
}
const PERMISSION_OPTIONS = [
{ value: 'registry_read', label: '镜像仓库 - 读' },
{ value: 'registry_write', label: '镜像仓库 - 写' },
{ value: 'cluster_read', label: '集群管理 - 读' },
{ value: 'cluster_write', label: '集群管理 - 写' },
{ value: 'user_read', label: '用户管理 - 读' },
{ value: 'user_write', label: '用户管理 - 写' },
]
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formData, setFormData] = useState<UserFormData>({
username: '',
password: '',
email: '',
nickname: '',
role: 'user',
status: 'active',
permissions: [],
})
const fetchUsers = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/v1/auth/user/list')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const result = await res.json()
setUsers(result.data?.users || [])
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [])
const handleOpenDialog = (user?: User) => {
if (user) {
setEditingUser(user)
setFormData({
username: user.username,
password: '',
email: user.email,
nickname: user.nickname,
role: user.role,
status: user.status,
permissions: user.permissions ? user.permissions.split(',') : [],
})
} else {
setEditingUser(null)
setFormData({
username: '',
password: '',
email: '',
nickname: '',
role: 'user',
status: 'active',
permissions: [],
})
}
setDialogOpen(true)
}
const handleCloseDialog = () => {
setDialogOpen(false)
setEditingUser(null)
}
const handleSubmit = async () => {
try {
const payload = {
...formData,
permissions: formData.permissions.join(','),
}
const url = editingUser
? `/api/v1/auth/user/update/${editingUser.id}`
: '/api/v1/auth/user/create'
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(errorData.message || `操作失败: HTTP ${res.status}`)
}
await fetchUsers()
handleCloseDialog()
} catch (err: any) {
setError(err.message)
}
}
const handleDelete = async (userId: number) => {
if (!window.confirm('确定要删除该用户吗?')) return
try {
const res = await fetch(`/api/v1/auth/user/delete/${userId}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await fetchUsers()
} catch (err: any) {
setError(err.message)
}
}
const handlePermissionToggle = (permission: string) => {
setFormData((prev) => ({
...prev,
permissions: prev.permissions.includes(permission)
? prev.permissions.filter((p) => p !== permission)
: [...prev.permissions, permission],
}))
}
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
)
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4" component="h1">
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.nickname}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Chip
label={user.role === 'admin' ? '管理员' : '普通用户'}
color={user.role === 'admin' ? 'primary' : 'default'}
size="small"
/>
</TableCell>
<TableCell>
<Chip
label={user.status === 'active' ? '激活' : '禁用'}
color={user.status === 'active' ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell>
{user.permissions?.split(',').map((p) => (
<Chip key={p} label={p} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
))}
</TableCell>
<TableCell>{new Date(user.created_at).toLocaleString()}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => handleOpenDialog(user)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(user.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>{editingUser ? '编辑用户' : '新建用户'}</DialogTitle>
<DialogContent>
<TextField
fullWidth
margin="normal"
label="用户名"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
disabled={!!editingUser}
/>
{!editingUser && (
<TextField
fullWidth
margin="normal"
label="密码"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
)}
<TextField
fullWidth
margin="normal"
label="邮箱"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<TextField
fullWidth
margin="normal"
label="昵称"
value={formData.nickname}
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
/>
<FormControl fullWidth margin="normal">
<InputLabel></InputLabel>
<Select
value={formData.role}
label="角色"
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
>
<MenuItem value="user"></MenuItem>
<MenuItem value="admin"></MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel></InputLabel>
<Select
value={formData.status}
label="状态"
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
>
<MenuItem value="active"></MenuItem>
<MenuItem value="inactive"></MenuItem>
</Select>
</FormControl>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
</Typography>
<FormGroup>
{PERMISSION_OPTIONS.map((perm) => (
<FormControlLabel
key={perm.value}
control={
<Checkbox
checked={formData.permissions.includes(perm.value)}
onChange={() => handlePermissionToggle(perm.value)}
/>
}
label={perm.label}
/>
))}
</FormGroup>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}></Button>
<Button onClick={handleSubmit} variant="contained">
{editingUser ? '保存' : '创建'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,77 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
token: string | null
username: string | null
loading: boolean
error: string | null
isAuthenticated: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
username: null,
loading: false,
error: null,
isAuthenticated: false,
login: async (username: string, password: string) => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(errorData.message || `登录失败: HTTP ${res.status}`)
}
const result = await res.json()
const { token } = result.data
set({
token,
username,
isAuthenticated: true,
loading: false,
error: null,
})
} catch (error: any) {
set({
loading: false,
error: error.message || '登录失败',
isAuthenticated: false,
})
throw error
}
},
logout: () => {
set({
token: null,
username: null,
isAuthenticated: false,
error: null,
})
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
token: state.token,
username: state.username,
isAuthenticated: state.isAuthenticated,
}),
}
)
)