feat: rbac接入完成,file接入完成

This commit is contained in:
Blizzard
2026-05-01 12:57:26 +08:00
parent e3e38800aa
commit 8ca5aa3148
10 changed files with 491 additions and 30 deletions
+17 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+14
View File
@@ -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
View File
@@ -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">
+16 -9
View File
@@ -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>}
+309
View File
@@ -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>
) )
} }