feat: rbac接入完成,file接入完成
This commit is contained in:
+17
-1
@@ -10,7 +10,7 @@ export interface SystemUser {
|
|||||||
phone?: string
|
phone?: string
|
||||||
avatarId?: string
|
avatarId?: string
|
||||||
gender?: number // 0=未知 1=男 2=女
|
gender?: number // 0=未知 1=男 2=女
|
||||||
roles?: string[] // 角色 code 列表(仅 /auth/info 返回)
|
roles?: any[] // 可能是 string[] 也可能是 SystemRole[]
|
||||||
menus?: SystemMenu[] // 菜单树(仅 /auth/info 返回)
|
menus?: SystemMenu[] // 菜单树(仅 /auth/info 返回)
|
||||||
createdAt?: number // Unix 时间戳(秒)
|
createdAt?: number // Unix 时间戳(秒)
|
||||||
roleIds?: string[] // 关联角色 ID(用户管理接口)
|
roleIds?: string[] // 关联角色 ID(用户管理接口)
|
||||||
@@ -22,6 +22,7 @@ export interface SystemRole {
|
|||||||
code: string
|
code: string
|
||||||
sort?: number
|
sort?: number
|
||||||
menuIds?: string[] // 关联菜单 ID 列表
|
menuIds?: string[] // 关联菜单 ID 列表
|
||||||
|
createdAt?: number // Unix 时间戳(秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemMenu {
|
export interface SystemMenu {
|
||||||
@@ -45,6 +46,21 @@ export interface SystemOss {
|
|||||||
createdAt: string; updatedAt: string
|
createdAt: string; updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageConfig {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
endpoint: string
|
||||||
|
accessKeyId: string
|
||||||
|
accessKeySecret: string
|
||||||
|
bucketName: string
|
||||||
|
bucketUrl: string
|
||||||
|
region?: string
|
||||||
|
isDefault: number
|
||||||
|
status: number
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemClient {
|
export interface SystemClient {
|
||||||
id: string
|
id: string
|
||||||
clientId: string
|
clientId: string
|
||||||
|
|||||||
+34
-3
@@ -1,9 +1,9 @@
|
|||||||
import { post } from '@/lib/request'
|
import { post } from '@/lib/request'
|
||||||
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
|
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
|
||||||
import type { SystemOss } from '../system'
|
import type { SystemOss, StorageConfig } from '../system'
|
||||||
import type { PageResult } from '@/lib/request'
|
import type { PageResult } from '@/lib/request'
|
||||||
|
|
||||||
const BASE_URL = '/file/oss'
|
const BASE_URL = '/file'
|
||||||
|
|
||||||
const mockFiles: SystemOss[] = [
|
const mockFiles: SystemOss[] = [
|
||||||
{ id: '1', name: 'avatar.jpg', key: 'avatars/1.jpg', url: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=800&q=80', suffix: 'jpg', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
{ id: '1', name: 'avatar.jpg', key: 'avatars/1.jpg', url: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=800&q=80', suffix: 'jpg', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
@@ -17,7 +17,7 @@ export async function getFileList(params: { current: number; pageSize: number; k
|
|||||||
if (params.keyword) list = list.filter(f => f.name.includes(params.keyword!))
|
if (params.keyword) list = list.filter(f => f.name.includes(params.keyword!))
|
||||||
return paginate(list, params.current, params.pageSize)
|
return paginate(list, params.current, params.pageSize)
|
||||||
}
|
}
|
||||||
return post<PageResult<SystemOss>>(`${BASE_URL}/getFileList`, params)
|
return post<PageResult<SystemOss>>(`${BASE_URL}/list`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadFile(_file: File) {
|
export async function uploadFile(_file: File) {
|
||||||
@@ -47,3 +47,34 @@ export async function deleteFile(ids: string[]) {
|
|||||||
}
|
}
|
||||||
return post<null>(`${BASE_URL}/delete`, { ids })
|
return post<null>(`${BASE_URL}/delete`, { ids })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Storage Config APIs ====================
|
||||||
|
const CONFIG_URL = '/file/config'
|
||||||
|
|
||||||
|
export async function getStorageConfigList(params: { current: number; pageSize: number; type?: string; name?: string }) {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await delay()
|
||||||
|
return paginate([], params.current, params.pageSize)
|
||||||
|
}
|
||||||
|
return post<PageResult<StorageConfig>>(`${CONFIG_URL}/list`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStorageConfig(data: Partial<StorageConfig>) {
|
||||||
|
if (USE_MOCK) return delay()
|
||||||
|
return post<null>(`${CONFIG_URL}/create`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStorageConfig(data: Partial<StorageConfig>) {
|
||||||
|
if (USE_MOCK) return delay()
|
||||||
|
return post<null>(`${CONFIG_URL}/update`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStorageConfig(ids: string[]) {
|
||||||
|
if (USE_MOCK) return delay()
|
||||||
|
return post<null>(`${CONFIG_URL}/delete`, { ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDefaultStorageConfig(id: string) {
|
||||||
|
if (USE_MOCK) return delay()
|
||||||
|
return post<null>(`${CONFIG_URL}/setDefault`, { id })
|
||||||
|
}
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,4 @@
|
|||||||
import { post } from '@/lib/request'
|
import { get, post } from '@/lib/request'
|
||||||
import { USE_MOCK, delay, paginate } from '@/mock'
|
import { USE_MOCK, delay, paginate } from '@/mock'
|
||||||
import type { SystemRole } from '../system'
|
import type { SystemRole } from '../system'
|
||||||
import type { PageResult } from '@/lib/request'
|
import type { PageResult } from '@/lib/request'
|
||||||
@@ -37,3 +37,13 @@ export async function deleteRole(ids: string[]) {
|
|||||||
if (USE_MOCK) { await delay(); return null }
|
if (USE_MOCK) { await delay(); return null }
|
||||||
return post<null>(`${BASE_URL}/delete`, { ids })
|
return post<null>(`${BASE_URL}/delete`, { ids })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRoleDetail(id: string) {
|
||||||
|
if (USE_MOCK) { await delay(); return null }
|
||||||
|
return get<SystemRole & { menuIds: string[] }>(`${BASE_URL}/detail`, { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignRoleMenus(data: { roleId: string; menuIds: string[] }) {
|
||||||
|
if (USE_MOCK) { await delay(); return null }
|
||||||
|
return post<null>(`${BASE_URL}/assignMenus`, data)
|
||||||
|
}
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,4 @@
|
|||||||
import { post } from '@/lib/request'
|
import { get, post } from '@/lib/request'
|
||||||
import { USE_MOCK, delay, paginate } from '@/mock'
|
import { USE_MOCK, delay, paginate } from '@/mock'
|
||||||
import { mockUsers } from '@/mock/system/users'
|
import { mockUsers } from '@/mock/system/users'
|
||||||
import type { SystemUser } from '../system'
|
import type { SystemUser } from '../system'
|
||||||
@@ -36,3 +36,13 @@ export async function resetPassword(data: { id: string; password: string }) {
|
|||||||
if (USE_MOCK) { await delay(); return null }
|
if (USE_MOCK) { await delay(); return null }
|
||||||
return post<null>(`${BASE_URL}/resetPassword`, data)
|
return post<null>(`${BASE_URL}/resetPassword`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserDetail(id: string) {
|
||||||
|
if (USE_MOCK) { await delay(); return null }
|
||||||
|
return get<SystemUser & { roleIds: string[] }>(`${BASE_URL}/detail`, { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignUserRoles(data: { userId: string; roleIds: string[] }) {
|
||||||
|
if (USE_MOCK) { await delay(); return null }
|
||||||
|
return post<null>(`${BASE_URL}/assignRoles`, data)
|
||||||
|
}
|
||||||
|
|||||||
+2
-4
@@ -7,7 +7,7 @@ const AUTH_WHITELIST = ['/auth/captcha', '/auth/login']
|
|||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// Let Axios automatically set Content-Type based on payload type
|
||||||
})
|
})
|
||||||
|
|
||||||
request.interceptors.request.use(
|
request.interceptors.request.use(
|
||||||
@@ -18,9 +18,7 @@ request.interceptors.request.use(
|
|||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
if (config.data instanceof FormData) {
|
// Remove any custom handling, Axios natively handles FormData correctly without global Content-Type
|
||||||
config.headers.delete('Content-Type')
|
|
||||||
}
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error: AxiosError) => Promise.reject(error)
|
(error: AxiosError) => Promise.reject(error)
|
||||||
|
|||||||
@@ -236,9 +236,23 @@ export default function Logs() {
|
|||||||
<div><span className="text-muted-foreground inline-block w-20">操作者ID:</span> <span className="font-medium">{activeLog.userId}</span></div>
|
<div><span className="text-muted-foreground inline-block w-20">操作者ID:</span> <span className="font-medium">{activeLog.userId}</span></div>
|
||||||
<div><span className="text-muted-foreground inline-block w-20">客户端:</span> {activeLog.clientId}</div>
|
<div><span className="text-muted-foreground inline-block w-20">客户端:</span> {activeLog.clientId}</div>
|
||||||
<div><span className="text-muted-foreground inline-block w-20">IP地址:</span> <span className="font-mono">{activeLog.ip}</span></div>
|
<div><span className="text-muted-foreground inline-block w-20">IP地址:</span> <span className="font-mono">{activeLog.ip}</span></div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground inline-block w-20">响应耗时:</span>
|
||||||
|
<span className={`font-mono font-medium ${activeLog.latency > 200000000 ? 'text-amber-500' : 'text-emerald-600'}`}>
|
||||||
|
{(activeLog.latency / 1000000).toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground inline-block w-20">状态码:</span>
|
||||||
|
<span className={`font-mono font-medium ${activeLog.status === 200 ? 'text-emerald-600' : 'text-red-500'}`}>
|
||||||
|
{activeLog.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2"><span className="text-muted-foreground inline-block w-20">操作时间:</span> <span className="font-mono text-xs">{new Date(activeLog.createdAt * 1000).toLocaleString('zh-CN')}</span></div>
|
||||||
<div className="col-span-2"><span className="text-muted-foreground inline-block w-20">User-Agent:</span> <span className="text-xs text-muted-foreground">{activeLog.agent}</span></div>
|
<div className="col-span-2"><span className="text-muted-foreground inline-block w-20">User-Agent:</span> <span className="text-xs text-muted-foreground">{activeLog.agent}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{activeLog.body && (
|
{activeLog.body && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2"><Activity className="w-4 h-4" /> Request Payload</h4>
|
<h4 className="text-sm font-semibold flex items-center gap-2"><Activity className="w-4 h-4" /> Request Payload</h4>
|
||||||
|
|||||||
+48
-10
@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
import { getRoleList, createRole, updateRole, deleteRole } from '@/api/system/role'
|
import { getRoleList, createRole, updateRole, deleteRole, getRoleDetail, assignRoleMenus } from '@/api/system/role'
|
||||||
import { getMenuTree } from '@/api/system/menu'
|
import { getMenuTree } from '@/api/system/menu'
|
||||||
import type { SystemRole, SystemMenu } from '@/api/system'
|
import type { SystemRole, SystemMenu } from '@/api/system'
|
||||||
|
|
||||||
@@ -82,24 +82,62 @@ export default function Roles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPermDialog = (role: SystemRole) => {
|
const openPermDialog = async (role: SystemRole) => {
|
||||||
setActiveRole(role)
|
setActiveRole(role)
|
||||||
// Actually get the role's menus
|
const detail = await getRoleDetail(role.id)
|
||||||
setSelectedMenuIds(role.menuIds || [])
|
if (detail) {
|
||||||
|
setSelectedMenuIds(detail.menuIds || [])
|
||||||
|
} else {
|
||||||
|
setSelectedMenuIds([])
|
||||||
|
}
|
||||||
setPermDialogOpen(true)
|
setPermDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSavePerms = async () => {
|
const handleSavePerms = async () => {
|
||||||
if (activeRole) {
|
if (activeRole) {
|
||||||
await updateRole({ id: activeRole.id, menuIds: selectedMenuIds })
|
await assignRoleMenus({ roleId: activeRole.id, menuIds: selectedMenuIds })
|
||||||
fetchRoles()
|
fetchRoles()
|
||||||
}
|
}
|
||||||
setPermDialogOpen(false)
|
setPermDialogOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleMenu = (id: string, checked: boolean) => {
|
const toggleMenu = (menu: SystemMenu, checked: boolean) => {
|
||||||
if (checked) setSelectedMenuIds(prev => [...prev, id])
|
setSelectedMenuIds(prev => {
|
||||||
else setSelectedMenuIds(prev => prev.filter(i => i !== id))
|
const nextIds = new Set(prev)
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 向下级联:勾选所有子孙节点
|
||||||
|
const checkDescendants = (m: SystemMenu) => {
|
||||||
|
nextIds.add(m.id)
|
||||||
|
m.children?.forEach(checkDescendants)
|
||||||
|
}
|
||||||
|
checkDescendants(menu)
|
||||||
|
|
||||||
|
// 向上级联:勾选所有父辈节点
|
||||||
|
const findAndAddParents = (nodes: SystemMenu[], targetId: string, parents: string[]): boolean => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === targetId) {
|
||||||
|
parents.forEach(p => nextIds.add(p))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (node.children && findAndAddParents(node.children, targetId, [...parents, node.id])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
findAndAddParents(allMenus, menu.id, [])
|
||||||
|
} else {
|
||||||
|
// 向下级联:取消勾选所有子孙节点
|
||||||
|
const uncheckDescendants = (m: SystemMenu) => {
|
||||||
|
nextIds.delete(m.id)
|
||||||
|
m.children?.forEach(uncheckDescendants)
|
||||||
|
}
|
||||||
|
uncheckDescendants(menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(nextIds)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderMenuTree = (menus: SystemMenu[], depth = 0) => {
|
const renderMenuTree = (menus: SystemMenu[], depth = 0) => {
|
||||||
@@ -109,7 +147,7 @@ export default function Roles() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id={`menu-${menu.id}`}
|
id={`menu-${menu.id}`}
|
||||||
checked={selectedMenuIds.includes(menu.id)}
|
checked={selectedMenuIds.includes(menu.id)}
|
||||||
onCheckedChange={c => toggleMenu(menu.id, c as boolean)}
|
onCheckedChange={c => toggleMenu(menu, c as boolean)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`menu-${menu.id}`} className="font-normal cursor-pointer flex items-center gap-2">
|
<Label htmlFor={`menu-${menu.id}`} className="font-normal cursor-pointer flex items-center gap-2">
|
||||||
{menu.title} {menu.category === 2 && <span className="text-[10px] bg-muted px-1 rounded text-muted-foreground">按钮</span>}
|
{menu.title} {menu.category === 2 && <span className="text-[10px] bg-muted px-1 rounded text-muted-foreground">按钮</span>}
|
||||||
@@ -185,7 +223,7 @@ export default function Roles() {
|
|||||||
<TableCell className="font-mono text-xs">{role.code}</TableCell>
|
<TableCell className="font-mono text-xs">{role.code}</TableCell>
|
||||||
<TableCell>{role.sort}</TableCell>
|
<TableCell>{role.sort}</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
-
|
{role.createdAt ? new Date(role.createdAt * 1000).toLocaleDateString('zh-CN') : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right pr-6">
|
<TableCell className="text-right pr-6">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
|
||||||
import { getUserList, createUser, updateUser, deleteUser } from '@/api/system/user'
|
import { getUserList, createUser, updateUser, deleteUser, getUserDetail, assignUserRoles } from '@/api/system/user'
|
||||||
import { getRoleList } from '@/api/system/role'
|
import { getRoleList } from '@/api/system/role'
|
||||||
import type { SystemUser, SystemRole } from '@/api/system'
|
import type { SystemUser, SystemRole } from '@/api/system'
|
||||||
|
|
||||||
@@ -64,20 +64,26 @@ export default function UserManage() {
|
|||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditDialog = (user: SystemUser) => {
|
const openEditDialog = async (user: SystemUser) => {
|
||||||
setEditingUser(user)
|
setEditingUser(user)
|
||||||
setFormData({
|
setFormData({
|
||||||
account: user.account, name: user.name, phone: user.phone || '', clientId: '',
|
account: user.account, name: user.name, phone: user.phone || '', clientId: '',
|
||||||
roleIds: user.roles || []
|
roleIds: []
|
||||||
})
|
})
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
|
|
||||||
|
const detail = await getUserDetail(user.id)
|
||||||
|
if (detail) {
|
||||||
|
setFormData(prev => ({ ...prev, roleIds: detail.roleIds || [] }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (editingUser) {
|
if (editingUser) {
|
||||||
await updateUser({ id: editingUser.id, ...formData })
|
await updateUser({ id: editingUser.id, account: formData.account, name: formData.name, phone: formData.phone })
|
||||||
|
await assignUserRoles({ userId: editingUser.id, roleIds: formData.roleIds })
|
||||||
} else {
|
} else {
|
||||||
await createUser({ ...formData, password: '123' }) // 默认密码
|
await createUser({ ...formData, password: '123' })
|
||||||
}
|
}
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
@@ -172,11 +178,12 @@ export default function UserManage() {
|
|||||||
<TableCell>{user.phone || '-'}</TableCell>
|
<TableCell>{user.phone || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{user.roles?.map(code => {
|
{user.roles?.map(roleObj => {
|
||||||
const role = allRoles.find(r => r.code === code)
|
const name = typeof roleObj === 'string' ? roleObj : roleObj.name
|
||||||
|
const key = typeof roleObj === 'string' ? roleObj : roleObj.id
|
||||||
return (
|
return (
|
||||||
<Badge key={code} variant="default" className="text-[10px] h-5 font-normal bg-emerald-500 hover:bg-emerald-600 text-white">
|
<Badge key={key} variant="default" className="text-[10px] h-5 font-normal bg-emerald-500 hover:bg-emerald-600 text-white">
|
||||||
{role ? role.name : code}
|
{name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}) || <span className="text-muted-foreground text-xs">无角色</span>}
|
}) || <span className="text-muted-foreground text-xs">无角色</span>}
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Plus, Search, RefreshCw, Edit, Trash2, Database, CheckCircle2, XCircle } from 'lucide-react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
|
import { getStorageConfigList, createStorageConfig, updateStorageConfig, deleteStorageConfig, setDefaultStorageConfig } from '@/api/system/file'
|
||||||
|
import type { StorageConfig } from '@/api/system'
|
||||||
|
|
||||||
|
const STORAGE_TYPES = [
|
||||||
|
{ label: '自建 MinIO', value: 'minio' },
|
||||||
|
{ label: '阿里云 OSS', value: 'aliyun' },
|
||||||
|
{ label: '腾讯云 COS', value: 'tencent' },
|
||||||
|
{ label: '七牛云', value: 'qiniu' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function StorageConfigs() {
|
||||||
|
const [configs, setConfigs] = useState<StorageConfig[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [search, setSearch] = useState({ name: '', type: '' })
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingConfig, setEditingConfig] = useState<StorageConfig | null>(null)
|
||||||
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'setDefault', id: string } | null>(null)
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<Partial<StorageConfig>>({
|
||||||
|
name: '', type: 'minio', endpoint: '', accessKeyId: '', accessKeySecret: '',
|
||||||
|
bucketName: '', bucketUrl: '', region: '', status: 1, remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchConfigs = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getStorageConfigList({ current: 1, pageSize: 20, ...search })
|
||||||
|
if (res) {
|
||||||
|
setConfigs(res.list)
|
||||||
|
setTotal(res.total)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearch = () => fetchConfigs()
|
||||||
|
const handleReset = () => { setSearch({ name: '', type: '' }); setTimeout(() => fetchConfigs(), 0) }
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingConfig(null)
|
||||||
|
setFormData({
|
||||||
|
name: '', type: 'minio', endpoint: '', accessKeyId: '', accessKeySecret: '',
|
||||||
|
bucketName: '', bucketUrl: '', region: '', status: 1, remark: ''
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (conf: StorageConfig) => {
|
||||||
|
setEditingConfig(conf)
|
||||||
|
setFormData({ ...conf })
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (editingConfig) await updateStorageConfig({ id: editingConfig.id, ...formData })
|
||||||
|
else await createStorageConfig(formData)
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchConfigs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setConfirmAction({ type: 'delete', id })
|
||||||
|
setConfirmDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetDefault = (id: string) => {
|
||||||
|
setConfirmAction({ type: 'setDefault', id })
|
||||||
|
setConfirmDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeConfirmAction = async () => {
|
||||||
|
if (!confirmAction) return
|
||||||
|
if (confirmAction.type === 'delete') {
|
||||||
|
await deleteStorageConfig([confirmAction.id])
|
||||||
|
} else if (confirmAction.type === 'setDefault') {
|
||||||
|
await setDefaultStorageConfig(confirmAction.id)
|
||||||
|
}
|
||||||
|
setConfirmDialogOpen(false)
|
||||||
|
setConfirmAction(null)
|
||||||
|
fetchConfigs()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fadeIn">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">存储引擎配置</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">动态管理多云厂商的OSS配置,可随时切换默认的上传通道。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border/60 shadow-soft">
|
||||||
|
<CardHeader className="pb-3 border-b border-border/40">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5 text-primary" /> 引擎列表
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Select value={search.type} onValueChange={(val) => setSearch({ ...search, type: val === 'all' ? '' : val })}>
|
||||||
|
<SelectTrigger className="w-[140px] h-9">
|
||||||
|
<SelectValue placeholder="所有类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">所有类型</SelectItem>
|
||||||
|
{STORAGE_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索配置名称..."
|
||||||
|
className="pl-9 w-48 h-9"
|
||||||
|
value={search.name}
|
||||||
|
onChange={e => setSearch({ ...search, name: e.target.value })}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset} className="h-9 px-3">重置</Button>
|
||||||
|
<Button size="sm" onClick={openCreateDialog} className="h-9 gap-1.5">
|
||||||
|
<Plus className="h-4 w-4" /> 新增配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/30">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="pl-6">配置名称</TableHead>
|
||||||
|
<TableHead>厂商类型</TableHead>
|
||||||
|
<TableHead>Bucket / Endpoint</TableHead>
|
||||||
|
<TableHead>默认引擎</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="text-right pr-6">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary/50" /> 加载中...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">暂无数据</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
configs.map(conf => (
|
||||||
|
<TableRow key={conf.id} className="hover:bg-muted/20">
|
||||||
|
<TableCell className="font-medium pl-6">{conf.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-500/10 text-blue-500 border border-blue-500/20">
|
||||||
|
{STORAGE_TYPES.find(t => t.value === conf.type)?.label || conf.type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
<div className="font-medium">{conf.bucketName}</div>
|
||||||
|
<div className="text-muted-foreground text-xs mt-0.5 max-w-[200px] truncate" title={conf.endpoint}>{conf.endpoint}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{conf.isDefault === 1 ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-500 text-sm font-medium">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> 生效中
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => handleSetDefault(conf.id)}>
|
||||||
|
设为默认
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{conf.status === 1 ? <span className="text-emerald-500 text-sm">启用</span> : <span className="text-red-500 text-sm">停用</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right pr-6">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => openEditDialog(conf)}>
|
||||||
|
<Edit className="h-3.5 w-3.5 text-blue-500" /> 编辑
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1 hover:text-red-500" onClick={() => handleDelete(conf.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-red-500" /> 删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 text-sm text-muted-foreground">
|
||||||
|
<div>共 {total} 条记录</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>上一页</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>下一页</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingConfig ? '编辑存储引擎配置' : '新增存储引擎配置'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingConfig ? '修改当前配置项,修改 AccessKey 等凭证信息可能影响线上服务。' : '添加新的 OSS 凭证配置,之后可以将其切换为默认引擎。'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||||
|
<Label htmlFor="name">配置名称</Label>
|
||||||
|
<Input id="name" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} placeholder="例如:阿里云北京节点" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||||
|
<Label>存储厂商类型</Label>
|
||||||
|
<Select value={formData.type} onValueChange={(val) => setFormData({ ...formData, type: val })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STORAGE_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="endpoint">Endpoint / 接口地址</Label>
|
||||||
|
<Input id="endpoint" value={formData.endpoint} onChange={e => setFormData({ ...formData, endpoint: e.target.value })} placeholder="例如:oss-cn-beijing.aliyuncs.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||||
|
<Label htmlFor="accessKeyId">AccessKey ID</Label>
|
||||||
|
<Input id="accessKeyId" value={formData.accessKeyId} onChange={e => setFormData({ ...formData, accessKeyId: e.target.value })} placeholder="云厂商提供的 AK" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||||
|
<Label htmlFor="accessKeySecret">AccessKey Secret</Label>
|
||||||
|
<Input id="accessKeySecret" type="password" value={formData.accessKeySecret} onChange={e => setFormData({ ...formData, accessKeySecret: e.target.value })} placeholder="云厂商提供的 SK" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||||
|
<Label htmlFor="bucketName">Bucket Name (存储桶)</Label>
|
||||||
|
<Input id="bucketName" value={formData.bucketName} onChange={e => setFormData({ ...formData, bucketName: e.target.value })} placeholder="例如:my-sundynix-app" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||||
|
<Label htmlFor="region">Region (区域代号)</Label>
|
||||||
|
<Input id="region" value={formData.region} onChange={e => setFormData({ ...formData, region: e.target.value })} placeholder="选填,如:ap-guangzhou" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="bucketUrl">Bucket 外部访问域名 (CDN/直连)</Label>
|
||||||
|
<Input id="bucketUrl" value={formData.bucketUrl} onChange={e => setFormData({ ...formData, bucketUrl: e.target.value })} placeholder="例如:https://my-bucket.oss-cn-beijing.aliyuncs.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 flex items-center justify-between border rounded-md p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>配置状态</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">启用后才允许设置为默认引擎。</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={formData.status === 1} onCheckedChange={(c) => setFormData({ ...formData, status: c ? 1 : 0 })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave}>保存配置</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{confirmAction?.type === 'delete' ? '确认删除' : '设为默认引擎'}</DialogTitle>
|
||||||
|
<DialogDescription className="pt-2">
|
||||||
|
{confirmAction?.type === 'delete'
|
||||||
|
? '确定要删除该存储配置吗?此操作不可逆,相关文件将无法使用该配置进行读取!'
|
||||||
|
: '确定将该配置设为当前系统的全局默认上传引擎吗?新的上传操作将立即切换到此厂商。'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)}>取消</Button>
|
||||||
|
<Button variant={confirmAction?.type === 'delete' ? 'destructive' : 'default'} onClick={executeConfirmAction}>
|
||||||
|
{confirmAction?.type === 'delete' ? '确定删除' : '确定切换'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ export default function FilesPage() {
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [previewUrl, setPreviewUrl] = useState('')
|
const [previewUrl, setPreviewUrl] = useState('')
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Toast state
|
||||||
|
const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||||
|
const showToast = (type: 'success' | 'error', text: string) => {
|
||||||
|
setToastMessage({ type, text })
|
||||||
|
setTimeout(() => setToastMessage(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchList = useCallback(async () => {
|
const fetchList = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -44,7 +51,14 @@ export default function FilesPage() {
|
|||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]; if (!file) return
|
const file = e.target.files?.[0]; if (!file) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try { await uploadFile(file); fetchList() } catch (err) { console.error(err) }
|
try {
|
||||||
|
await uploadFile(file);
|
||||||
|
fetchList();
|
||||||
|
showToast('success', '上传成功!文件已保存到存储桶中。');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('error', '上传失败: ' + (err.message || JSON.stringify(err)));
|
||||||
|
}
|
||||||
finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
|
finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +143,20 @@ export default function FilesPage() {
|
|||||||
<Dialog open={!!previewUrl} onOpenChange={() => setPreviewUrl('')}>
|
<Dialog open={!!previewUrl} onOpenChange={() => setPreviewUrl('')}>
|
||||||
<DialogContent className="sm:max-w-[700px] p-2"><img src={previewUrl} alt="preview" className="w-full rounded-lg" /></DialogContent>
|
<DialogContent className="sm:max-w-[700px] p-2"><img src={previewUrl} alt="preview" className="w-full rounded-lg" /></DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Elegant Toast Notification */}
|
||||||
|
{toastMessage && (
|
||||||
|
<div className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[9999] flex items-center gap-3 px-6 py-4 rounded-xl shadow-2xl border transition-all duration-300 animate-in fade-in zoom-in-95 ${
|
||||||
|
toastMessage.type === 'success' ? 'bg-white border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{toastMessage.type === 'success' ? (
|
||||||
|
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm">{toastMessage.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user