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:
@@ -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>
|
||||
|
||||
132
frontend/src/pages/Login.tsx
Normal file
132
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
353
frontend/src/pages/UserManagement.tsx
Normal file
353
frontend/src/pages/UserManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/stores/authStore.ts
Normal file
77
frontend/src/stores/authStore.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user