feat: 百科知识库同步向量接入
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { get, post } from '@/lib/request'
|
||||
|
||||
// ==================== AI 配置管理 ====================
|
||||
|
||||
export interface SysAiConfig {
|
||||
id?: string
|
||||
isActive: number
|
||||
// Qdrant 配置
|
||||
qdrantUrl: string
|
||||
qdrantApiKey: string
|
||||
qdrantCollection: string
|
||||
vectorDimension: number
|
||||
// 对话大模型配置
|
||||
chatProvider: string
|
||||
chatApiUrl: string
|
||||
chatApiKey: string
|
||||
chatModelName: string
|
||||
// Embedding 向量模型配置(可独立于对话模型)
|
||||
embeddingProvider: string
|
||||
embeddingApiUrl: string
|
||||
embeddingApiKey: string
|
||||
embeddingModelName: string
|
||||
// 基础字段
|
||||
createdAt?: string
|
||||
createdAtStr?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
// 创建 AI 配置
|
||||
export function createAiConfig(data: Omit<SysAiConfig, 'id' | 'createdAt' | 'updatedAt'>) {
|
||||
return post<{ msg: string }>('/aiConfig/create', data)
|
||||
}
|
||||
|
||||
// 更新 AI 配置
|
||||
export function updateAiConfig(data: SysAiConfig) {
|
||||
return post<{ msg: string }>('/aiConfig/update', data)
|
||||
}
|
||||
|
||||
// 设置激活配置
|
||||
export function setActiveAiConfig(id: string) {
|
||||
return post<{ msg: string }>('/aiConfig/setActive', { id })
|
||||
}
|
||||
|
||||
// 获取 AI 配置列表
|
||||
export function getAiConfigList() {
|
||||
return get<{ data: { list: SysAiConfig[]; total: number } }>('/aiConfig/list')
|
||||
}
|
||||
|
||||
// 触发百科数据同步到 Qdrant
|
||||
export function syncWikiToQdrant() {
|
||||
return post<{ msg: string }>('/plant/chat/sync')
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
// 系统相关 API
|
||||
export * from './system'
|
||||
|
||||
// AI 配置相关 API
|
||||
export * from './ai'
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface Wiki {
|
||||
// 其他
|
||||
difficulty?: number // 1-5级
|
||||
isHot?: number // 0否 1是
|
||||
isVectorSynced?: number // 0否 1是
|
||||
relatedWikiIds?: string[]
|
||||
relatedWikis?: Wiki[]
|
||||
|
||||
@@ -174,3 +175,13 @@ export function uploadWikiImg(data: { id: string; ossIds: string[] }) {
|
||||
export function deleteWiki(ids: string[]) {
|
||||
return post<{ msg: string }>('/wiki/delete', { ids })
|
||||
}
|
||||
|
||||
// 同步点位到 Qdrant
|
||||
export function syncWikiQdrant(id: string) {
|
||||
return post<{ msg: string }>('/wiki/sync-qdrant', { id })
|
||||
}
|
||||
|
||||
// 从 Qdrant 删除点位
|
||||
export function deleteWikiQdrant(id: string) {
|
||||
return post<{ msg: string }>('/wiki/delete-qdrant', { id })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 共享页面 UI 组件库
|
||||
* 统一 AdminLayout 内所有页面的视觉风格:
|
||||
* - PageHeader 深色渐变 hero 头部(与 AiConfig 保持一致)
|
||||
* - SearchBar 搜索栏容器
|
||||
* - DataTable 带骨架屏、空状态的表格容器
|
||||
* - Pagination 统一分页控件
|
||||
* - DeleteDialog 删除二次确认弹窗
|
||||
*/
|
||||
import { Loader2, Trash2, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription,
|
||||
DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// PageHeader
|
||||
// ──────────────────────────────────────────────
|
||||
interface PageHeaderProps {
|
||||
/** lucide 图标组件,已 instantiated */
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
/** hero 右侧操作区 */
|
||||
actions?: React.ReactNode
|
||||
/** 页脚区(如同步提示条) */
|
||||
footer?: React.ReactNode
|
||||
/** 装饰球颜色,默认 primary */
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
icon, title, description, actions, footer, accentColor = 'bg-primary/20',
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6 text-white shadow-soft-lg">
|
||||
<div className={`absolute -top-10 -right-10 h-40 w-40 rounded-full ${accentColor} blur-3xl pointer-events-none`} />
|
||||
<div className="absolute -bottom-6 -left-6 h-28 w-28 rounded-full bg-white/5 blur-2xl pointer-events-none" />
|
||||
<div className="relative flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-11 w-11 rounded-xl bg-white/10 backdrop-blur-sm flex items-center justify-center ring-1 ring-white/20 shadow-lg shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-white/55 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2.5 flex-wrap">{actions}</div>}
|
||||
</div>
|
||||
{footer && <div className="relative mt-4">{footer}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// SearchBar
|
||||
// ──────────────────────────────────────────────
|
||||
interface SearchBarProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSearch: () => void
|
||||
placeholder?: string
|
||||
extra?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChange, onSearch, placeholder = '搜索...', extra }: SearchBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 rounded-2xl border border-border/60 bg-card shadow-soft">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9 h-9 bg-muted/40 border-transparent focus:bg-background focus:border-primary/30 rounded-lg"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && onSearch()}
|
||||
/>
|
||||
</div>
|
||||
{extra}
|
||||
<Button size="sm" className="h-9 px-4 shrink-0" onClick={onSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// DataTable (wrapper)
|
||||
// ──────────────────────────────────────────────
|
||||
interface DataTableProps {
|
||||
loading: boolean
|
||||
empty: boolean
|
||||
emptyText?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function DataTable({ loading, empty, emptyText = '暂无数据', children }: DataTableProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card shadow-soft overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-primary/50" />
|
||||
</div>
|
||||
) : empty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-2">
|
||||
<Search className="h-5 w-5 opacity-40" />
|
||||
</div>
|
||||
<p className="text-sm">{emptyText}</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
interface PaginationProps {
|
||||
total: number
|
||||
current: number
|
||||
pageSize: number
|
||||
onChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ total, current, pageSize, onChange, onPageSizeChange }: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const pages = Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
if (totalPages <= 5) return i + 1
|
||||
if (current <= 3) return i + 1
|
||||
if (current >= totalPages - 2) return totalPages - 4 + i
|
||||
return current - 2 + i
|
||||
})
|
||||
return (
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-t border-border/50 bg-muted/10">
|
||||
<p className="text-xs text-muted-foreground">共 <span className="font-medium text-foreground">{total}</span> 条</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-7 px-2.5 text-xs" disabled={current === 1} onClick={() => onChange(current - 1)}>上一页</Button>
|
||||
{pages.map(p => (
|
||||
<Button
|
||||
key={p}
|
||||
size="sm"
|
||||
variant={current === p ? 'default' : 'outline'}
|
||||
className="h-7 w-7 p-0 text-xs"
|
||||
onClick={() => onChange(p)}
|
||||
>{p}</Button>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="h-7 px-2.5 text-xs" disabled={current >= totalPages} onClick={() => onChange(current + 1)}>下一页</Button>
|
||||
<Select value={String(pageSize)} onValueChange={v => onPageSizeChange(Number(v))}>
|
||||
<SelectTrigger className="h-7 w-24 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 50].map(s => <SelectItem key={s} value={String(s)}>{s} 条/页</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// DeleteDialog
|
||||
// ──────────────────────────────────────────────
|
||||
interface DeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
title?: string
|
||||
description?: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onOpenChange, title = '确认删除', description, onConfirm }: DeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm rounded-2xl">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto mb-3 h-11 w-11 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-base">{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-center text-sm">{description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-center gap-2 mt-1">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Search,
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
Bot,
|
||||
} from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
@@ -64,6 +65,8 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
'file-text': <FileText className="h-4 w-4" />,
|
||||
'folder-tree': <FolderTree className="h-4 w-4" />,
|
||||
'folder': <Folder className="h-4 w-4" />,
|
||||
'bot': <Bot className="h-4 w-4" />,
|
||||
'ai': <Bot className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
function getIcon(iconName?: string): React.ReactNode {
|
||||
|
||||
+90
-177
@@ -1,44 +1,12 @@
|
||||
import {Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight} from 'lucide-react'
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import { Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight, Activity } from 'lucide-react'
|
||||
import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData'
|
||||
import { PageHeader } from '@/components/PageUI'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: '用户总数',
|
||||
value: mockUsers.length,
|
||||
icon: Users,
|
||||
change: '+12%',
|
||||
changeType: 'positive' as const,
|
||||
color: 'from-blue-500/20 to-blue-500/5',
|
||||
iconBg: 'bg-blue-500/10 text-blue-600',
|
||||
},
|
||||
{
|
||||
title: '话题数量',
|
||||
value: mockTopics.length,
|
||||
icon: MessageSquare,
|
||||
change: '+8%',
|
||||
changeType: 'positive' as const,
|
||||
color: 'from-violet-500/20 to-violet-500/5',
|
||||
iconBg: 'bg-violet-500/10 text-violet-600',
|
||||
},
|
||||
{
|
||||
title: '百科分类',
|
||||
value: mockCategories.length,
|
||||
icon: FolderTree,
|
||||
change: '0%',
|
||||
changeType: 'neutral' as const,
|
||||
color: 'from-amber-500/20 to-amber-500/5',
|
||||
iconBg: 'bg-amber-500/10 text-amber-600',
|
||||
},
|
||||
{
|
||||
title: '植物百科',
|
||||
value: mockPlants.length,
|
||||
icon: Leaf,
|
||||
change: '+25%',
|
||||
changeType: 'positive' as const,
|
||||
color: 'from-emerald-500/20 to-emerald-500/5',
|
||||
iconBg: 'bg-emerald-500/10 text-emerald-600',
|
||||
},
|
||||
{ title: '用户总数', value: mockUsers.length, icon: Users, change: '+12%', pos: true, from: 'from-blue-500/25', iconBg: 'bg-blue-500 text-white' },
|
||||
{ title: '话题数量', value: mockTopics.length, icon: MessageSquare, change: '+8%', pos: true, from: 'from-violet-500/25', iconBg: 'bg-violet-500 text-white' },
|
||||
{ title: '百科分类', value: mockCategories.length, icon: FolderTree, change: '0%', pos: false, from: 'from-amber-500/25', iconBg: 'bg-amber-500 text-white' },
|
||||
{ title: '植物百科', value: mockPlants.length, icon: Leaf, change: '+25%', pos: true, from: 'from-emerald-500/25', iconBg: 'bg-emerald-500 text-white' },
|
||||
]
|
||||
|
||||
const recentActivities = [
|
||||
@@ -49,184 +17,129 @@ const recentActivities = [
|
||||
]
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">概览</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">欢迎回来 👋</h1>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date().toLocaleDateString('zh-CN', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const now = new Date().toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}
|
||||
className="relative overflow-hidden border-0 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${stat.color} opacity-60`}/>
|
||||
<CardHeader className="relative flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
|
||||
<stat.icon className="h-4 w-4"/>
|
||||
return (
|
||||
<div className="space-y-5 animate-fadeIn">
|
||||
<PageHeader
|
||||
icon={<Activity className="h-5 w-5 text-white" />}
|
||||
title="欢迎回来 👋"
|
||||
description={now}
|
||||
accentColor="bg-emerald-500/20"
|
||||
actions={
|
||||
<div className="flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-white/10 ring-1 ring-white/20 text-xs text-white/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
系统运行正常
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
<div className="text-3xl font-bold tracking-tight">{stat.value}</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium ${
|
||||
stat.changeType === 'positive'
|
||||
? 'bg-emerald-500/10 text-emerald-600'
|
||||
: 'bg-muted text-muted-foreground' // 移除了对 'negative' 的处理
|
||||
}`}
|
||||
>
|
||||
{stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3"/>}
|
||||
{stat.change}
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="relative overflow-hidden rounded-2xl border border-border/50 bg-card p-5 shadow-soft hover:shadow-soft-lg transition-shadow">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${s.from} to-transparent opacity-40 pointer-events-none`} />
|
||||
<div className="relative flex items-start justify-between mb-4">
|
||||
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shadow-sm ${s.iconBg}`}>
|
||||
<s.icon className="h-5 w-5" />
|
||||
</div>
|
||||
{s.pos && (
|
||||
<span className="flex items-center gap-0.5 text-[11px] font-semibold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
||||
<ArrowUpRight className="h-3 w-3" />{s.change}
|
||||
</span>
|
||||
<span className="text-muted-foreground">较上月</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p className="text-3xl font-bold tracking-tight">{s.value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{s.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Content */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{/* Recent Topics */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
|
||||
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
|
||||
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
最新话题
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">最近发布的社区话题</CardDescription>
|
||||
<span className="font-semibold text-sm">最新话题</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">最近发布</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{mockTopics.slice(0, 3).map(topic => (
|
||||
<div
|
||||
key={topic.id}
|
||||
className="group flex items-start gap-4 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border cursor-pointer"
|
||||
>
|
||||
<div className="divide-y divide-border/30">
|
||||
{mockTopics.slice(0, 4).map(topic => (
|
||||
<div key={topic.id} className="flex items-center gap-3.5 px-5 py-3.5 hover:bg-muted/30 transition-colors cursor-pointer group">
|
||||
{topic.coverImage && (
|
||||
<img
|
||||
src={topic.coverImage}
|
||||
alt={topic.title}
|
||||
className="h-14 w-14 rounded-lg object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
<img src={topic.coverImage} alt={topic.title} className="h-10 w-10 rounded-lg object-cover shrink-0 ring-1 ring-border/40" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<h4 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors">{topic.title}</h4>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{topic.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3"/>
|
||||
{topic.viewCount}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate group-hover:text-primary transition-colors">{topic.title}</p>
|
||||
<div className="flex items-center gap-2.5 mt-0.5">
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Eye className="h-3 w-3" />{topic.viewCount}
|
||||
</span>
|
||||
<span className="font-medium">{topic.authorName}</span>
|
||||
<span className="text-xs text-muted-foreground">{topic.authorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activities */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
{/* Activities */}
|
||||
<div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
|
||||
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
|
||||
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
最近活动
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">系统最近的操作记录</CardDescription>
|
||||
<span className="font-semibold text-sm">最近活动</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">操作记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1">
|
||||
{recentActivities.map((activity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{activity.user.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="divide-y divide-border/30">
|
||||
{recentActivities.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3.5 px-5 py-3.5 hover:bg-muted/30 transition-colors">
|
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-primary">{a.user.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">
|
||||
<span className="font-medium">{activity.user}</span>{' '}
|
||||
<span className="text-muted-foreground">{activity.action}</span>{' '}
|
||||
<span className="font-medium text-primary">{activity.target}</span>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{a.user}</span>
|
||||
<span className="text-muted-foreground"> {a.action} </span>
|
||||
<span className="font-medium text-primary">{a.target}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{activity.time}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
{/* Plant categories */}
|
||||
<div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
|
||||
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
|
||||
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Leaf className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
植物百科概览
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">按分类统计的植物数量</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{mockCategories.map(category => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border hover:shadow-sm cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 text-2xl">
|
||||
{category.icon || '🌱'}
|
||||
<span className="font-semibold text-sm">植物百科概览</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">按分类统计</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm group-hover:text-primary transition-colors">{category.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{category.children?.length || 0} 个子分类
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
||||
{mockCategories.map(cat => (
|
||||
<div key={cat.id} className="flex items-center gap-3.5 rounded-xl border border-border/40 p-4 hover:bg-muted/30 hover:border-primary/20 transition-all cursor-pointer group">
|
||||
<div className="h-11 w-11 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl shrink-0">
|
||||
{cat.icon || '🌱'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold group-hover:text-primary transition-colors">{cat.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{cat.children?.length || 0} 个子分类</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+73
-126
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { PageHeader, DataTable, Pagination } from '@/components/PageUI'
|
||||
import { Plus, Search, Pencil, Trash2, Leaf, Sun, Droplets, Star, Eye, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
getWikiPage,
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
type WikiPageParams,
|
||||
uploadWikiImg,
|
||||
deleteWiki,
|
||||
syncWikiQdrant,
|
||||
deleteWikiQdrant,
|
||||
} from '@/api/wiki'
|
||||
import { uploadFile, type SystemOss } from '@/api/system'
|
||||
|
||||
@@ -251,6 +253,27 @@ export default function PlantsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 同步向量到 Qdrant
|
||||
const handleSyncQdrant = async (plant: Wiki) => {
|
||||
try {
|
||||
await syncWikiQdrant(plant.id)
|
||||
fetchPlants()
|
||||
} catch (err) {
|
||||
console.error('同步向量失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从 Qdrant 移除向量
|
||||
const handleDeleteQdrant = async (plant: Wiki) => {
|
||||
try {
|
||||
await deleteWikiQdrant(plant.id)
|
||||
fetchPlants()
|
||||
} catch (err) {
|
||||
console.error('移除向量失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (plant: Wiki) => {
|
||||
setSelectedPlant(plant)
|
||||
@@ -407,81 +430,57 @@ export default function PlantsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">植物百科</h1>
|
||||
<p className="text-muted-foreground">管理植物百科信息</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<PageHeader
|
||||
icon={<Leaf className="h-5 w-5 text-white" />}
|
||||
title="植物百科"
|
||||
description="管理植物百科数据与养护信息"
|
||||
accentColor="bg-emerald-500/25"
|
||||
actions={
|
||||
<div className="flex gap-2.5 items-center">
|
||||
{selectedIds.length > 0 && (
|
||||
<Button variant="destructive" onClick={handleBatchDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
批量删除 ({selectedIds.length})
|
||||
<Button size="sm" variant="outline"
|
||||
className="h-9 bg-white/10 border-white/20 text-white hover:bg-red-500/30 hover:border-red-400/40"
|
||||
onClick={handleBatchDelete}>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />批量删除 ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增植物
|
||||
<Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md" onClick={handleAdd}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />新增植物
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-destructive/15 text-destructive p-4 rounded-md">
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1 max-w-sm">
|
||||
<Input
|
||||
<div className="flex items-center gap-3 p-4 rounded-2xl border border-border/60 bg-card shadow-soft">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9 h-9 bg-muted/40 border-transparent focus:bg-background focus:border-primary/30 rounded-lg"
|
||||
placeholder="搜索植物名称..."
|
||||
value={searchParams.name}
|
||||
onChange={e => setSearchParams({ ...searchParams, name: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
value={String(searchParams.isHot ?? 'all')}
|
||||
onValueChange={v => setSearchParams({
|
||||
...searchParams,
|
||||
isHot: v === 'all' ? undefined : Number(v),
|
||||
current: 1
|
||||
})}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="1">热门</TabsTrigger>
|
||||
<TabsTrigger value="0">普通</TabsTrigger>
|
||||
<Tabs value={String(searchParams.isHot ?? 'all')} onValueChange={v => setSearchParams({ ...searchParams, isHot: v === 'all' ? undefined : Number(v), current: 1 })}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="all" className="text-xs">全部</TabsTrigger>
|
||||
<TabsTrigger value="1" className="text-xs">🔥 热门</TabsTrigger>
|
||||
<TabsTrigger value="0" className="text-xs">普通</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button size="sm" className="h-9 px-4 shrink-0" onClick={handleSearch}>搜索</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 植物列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Leaf className="h-5 w-5" />
|
||||
植物列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable loading={loading} empty={!loading && plants.length === 0} emptyText="暂无植物数据,点击新增">
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
@@ -499,8 +498,8 @@ export default function PlantsPage() {
|
||||
<TableHead>植物信息</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>特性</TableHead>
|
||||
<TableHead>难度</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>难度/状态</TableHead>
|
||||
<TableHead>向量同步</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -580,13 +579,24 @@ export default function PlantsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{plant.isHot === 1 ? (
|
||||
<Badge className="bg-orange-500 mb-1 block w-fit">热门</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="mb-1 block w-fit">普通</Badge>
|
||||
)}
|
||||
{renderDifficulty(plant.difficulty || 1)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{plant.isHot === 1 ? (
|
||||
<Badge className="bg-orange-500">热门</Badge>
|
||||
{plant.isVectorSynced === 1 ? (
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<Badge className="bg-teal-500 hover:bg-teal-600">已同步</Badge>
|
||||
<Button variant="link" className="h-auto p-0 text-xs text-muted-foreground hover:text-red-500" onClick={() => handleDeleteQdrant(plant)}>取消同步</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary">普通</Badge>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<Badge variant="outline" className="text-muted-foreground border-dashed">未同步</Badge>
|
||||
<Button variant="link" className="h-auto p-0 text-xs text-primary" onClick={() => handleSyncQdrant(plant)}>立即同步</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -613,74 +623,11 @@ export default function PlantsPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{totalPages > 0 && Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{totalPages === 0 && (
|
||||
<Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条/页</SelectItem>
|
||||
<SelectItem value="20">20 条/页</SelectItem>
|
||||
<SelectItem value="50">50 条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Pagination total={total} current={searchParams.current} pageSize={searchParams.pageSize}
|
||||
onChange={p => setSearchParams(s => ({ ...s, current: p }))}
|
||||
onPageSizeChange={s => setSearchParams(p => ({ ...p, pageSize: s, current: 1 }))} />
|
||||
</DataTable>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Bot, Plus, Pencil, Trash2, CheckCircle2,
|
||||
RefreshCw, Database, Cpu, ChevronDown, ChevronUp,
|
||||
AlertTriangle, Loader2, Zap, Sparkles, Activity,
|
||||
Server, Key, ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||
DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
getAiConfigList, createAiConfig, updateAiConfig,
|
||||
setActiveAiConfig, syncWikiToQdrant, type SysAiConfig,
|
||||
} from '@/api/ai'
|
||||
|
||||
const defaultForm: Omit<SysAiConfig, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
isActive: 0,
|
||||
qdrantUrl: '',
|
||||
qdrantApiKey: '',
|
||||
qdrantCollection: 'plants',
|
||||
vectorDimension: 104,
|
||||
chatProvider: 'deepseek',
|
||||
chatApiUrl: 'https://api.deepseek.com/v1',
|
||||
chatApiKey: '',
|
||||
chatModelName: 'deepseek-chat',
|
||||
embeddingProvider: 'local',
|
||||
embeddingApiUrl: 'http://localhost:11434/v1',
|
||||
embeddingApiKey: '',
|
||||
embeddingModelName: 'bge-m3',
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Sub-components
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ icon, label, color }: { icon: React.ReactNode; label: string; color: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-widest ${color} mb-3`}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldRow({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-medium">{label}</Label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px] text-muted-foreground/70">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Config Card
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function ConfigCard({
|
||||
cfg,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onActivate,
|
||||
}: {
|
||||
cfg: SysAiConfig
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onActivate: () => void
|
||||
}) {
|
||||
const isActive = cfg.isActive === 1
|
||||
return (
|
||||
<div className={`relative rounded-2xl border p-5 transition-all duration-300 group
|
||||
${isActive
|
||||
? 'border-emerald-300/60 bg-gradient-to-br from-emerald-50/60 to-white shadow-[0_0_0_1px_rgba(16,185,129,0.15),0_4px_20px_rgba(16,185,129,0.1)]'
|
||||
: 'border-border/60 bg-card hover:border-primary/30 hover:shadow-soft'
|
||||
}`}
|
||||
>
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className="absolute top-4 right-4 flex items-center gap-1.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-emerald-600 uppercase tracking-wide">激活中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shrink-0 shadow-sm
|
||||
${isActive ? 'bg-emerald-500 text-white' : 'bg-primary/10 text-primary'}`}
|
||||
>
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-16">
|
||||
<p className="font-semibold text-sm truncate">{cfg.chatModelName}</p>
|
||||
<p className="text-xs text-muted-foreground">{cfg.chatProvider || '未设置供应商'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{/* Chat LLM */}
|
||||
<div className="col-span-1 rounded-xl bg-blue-50/60 border border-blue-100/80 p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Cpu className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-[10px] font-semibold text-blue-600 uppercase tracking-wide">对话模型</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-foreground truncate">{cfg.chatModelName}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{cfg.chatApiUrl}</p>
|
||||
</div>
|
||||
|
||||
{/* Embedding */}
|
||||
<div className="col-span-1 rounded-xl bg-purple-50/60 border border-purple-100/80 p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Zap className="h-3 w-3 text-purple-500" />
|
||||
<span className="text-[10px] font-semibold text-purple-600 uppercase tracking-wide">向量模型</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-foreground truncate">{cfg.embeddingModelName}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{cfg.embeddingProvider}</p>
|
||||
</div>
|
||||
|
||||
{/* Qdrant */}
|
||||
<div className="col-span-1 rounded-xl bg-teal-50/60 border border-teal-100/80 p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Database className="h-3 w-3 text-teal-500" />
|
||||
<span className="text-[10px] font-semibold text-teal-600 uppercase tracking-wide">向量库</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-foreground truncate">{cfg.qdrantCollection}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{cfg.vectorDimension} 维</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Qdrant URL */}
|
||||
<div className="flex items-center gap-1.5 mb-4 px-2.5 py-1.5 rounded-lg bg-muted/40 border border-border/40">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground truncate flex-1">{cfg.qdrantUrl || '未设置 Qdrant 地址'}</span>
|
||||
{cfg.qdrantUrl && <ExternalLink className="h-3 w-3 text-muted-foreground/40 shrink-0" />}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm"
|
||||
onClick={onActivate}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
设为激活
|
||||
</Button>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="flex-1 h-8 flex items-center justify-center rounded-md bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-medium gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
当前激活
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="h-8 w-8 p-0" onClick={onEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/5 hover:border-destructive/30"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[10px] text-muted-foreground/50 text-right">
|
||||
{cfg.createdAtStr || cfg.createdAt}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Main Page
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export default function AiConfigPage() {
|
||||
const [configs, setConfigs] = useState<SysAiConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedConfig, setSelectedConfig] = useState<SysAiConfig | null>(null)
|
||||
const [formData, setFormData] = useState(defaultForm)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [embExpanded, setEmbExpanded] = useState(true)
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getAiConfigList()
|
||||
setConfigs(res.data?.list || [])
|
||||
} catch (e) {
|
||||
console.error('获取AI配置失败', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchConfigs() }, [])
|
||||
|
||||
const f = (key: keyof typeof formData, val: string | number) =>
|
||||
setFormData(prev => ({ ...prev, [key]: val }))
|
||||
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultForm)
|
||||
setIsEdit(false)
|
||||
setSelectedConfig(null)
|
||||
setEmbExpanded(true)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (cfg: SysAiConfig) => {
|
||||
setFormData({
|
||||
isActive: cfg.isActive,
|
||||
qdrantUrl: cfg.qdrantUrl,
|
||||
qdrantApiKey: cfg.qdrantApiKey,
|
||||
qdrantCollection: cfg.qdrantCollection,
|
||||
vectorDimension: cfg.vectorDimension,
|
||||
chatProvider: cfg.chatProvider,
|
||||
chatApiUrl: cfg.chatApiUrl,
|
||||
chatApiKey: cfg.chatApiKey,
|
||||
chatModelName: cfg.chatModelName,
|
||||
embeddingProvider: cfg.embeddingProvider,
|
||||
embeddingApiUrl: cfg.embeddingApiUrl,
|
||||
embeddingApiKey: cfg.embeddingApiKey,
|
||||
embeddingModelName: cfg.embeddingModelName,
|
||||
})
|
||||
setSelectedConfig(cfg)
|
||||
setIsEdit(true)
|
||||
setEmbExpanded(true)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleActivate = async (cfg: SysAiConfig) => {
|
||||
if (!cfg.id) return
|
||||
try {
|
||||
await setActiveAiConfig(cfg.id)
|
||||
fetchConfigs()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.qdrantUrl || !formData.chatApiUrl) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && selectedConfig?.id) {
|
||||
await updateAiConfig({ ...formData, id: selectedConfig.id })
|
||||
} else {
|
||||
await createAiConfig(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchConfigs()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncWiki = async () => {
|
||||
setSyncing(true)
|
||||
setSyncMsg(null)
|
||||
try {
|
||||
const res = await syncWikiToQdrant()
|
||||
setSyncMsg({ type: 'success', text: res.msg || '同步成功,百科数据已写入 Qdrant' })
|
||||
} catch (e: any) {
|
||||
setSyncMsg({ type: 'error', text: e?.message || '同步失败,请检查激活配置' })
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeConfig = configs.find(c => c.isActive === 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
|
||||
{/* ── Hero Header ── */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6 text-white shadow-soft-lg">
|
||||
{/* decorative blobs */}
|
||||
<div className="absolute -top-12 -right-12 h-48 w-48 rounded-full bg-primary/20 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-8 -left-8 h-32 w-32 rounded-full bg-purple-500/15 blur-2xl pointer-events-none" />
|
||||
|
||||
<div className="relative flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-2xl bg-white/10 backdrop-blur-sm flex items-center justify-center ring-1 ring-white/20 shadow-lg">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">AI 大模型配置</h1>
|
||||
<p className="text-sm text-white/60 mt-0.5">管理 Qdrant 向量库 · 对话模型 · Embedding 模型</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
{/* Stats pill */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/10 backdrop-blur-sm ring-1 ring-white/20 text-xs text-white/80">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>{configs.length} 条配置</span>
|
||||
{activeConfig && (
|
||||
<>
|
||||
<span className="w-px h-3 bg-white/20" />
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-emerald-300">{activeConfig.chatModelName}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white backdrop-blur-sm"
|
||||
onClick={handleSyncWiki}
|
||||
disabled={syncing || !activeConfig}
|
||||
>
|
||||
{syncing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
同步百科到向量库
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
新增配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync feedback */}
|
||||
{syncMsg && (
|
||||
<div className={`mt-4 flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm backdrop-blur-sm ring-1
|
||||
${syncMsg.type === 'success'
|
||||
? 'bg-emerald-500/15 ring-emerald-500/30 text-emerald-200'
|
||||
: 'bg-red-500/15 ring-red-500/30 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{syncMsg.type === 'success'
|
||||
? <CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
: <AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
}
|
||||
{syncMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Config Cards Grid ── */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-64 rounded-2xl skeleton" />
|
||||
))}
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
// Empty state
|
||||
<div className="flex flex-col items-center justify-center py-20 rounded-2xl border border-dashed border-border/60 bg-muted/20">
|
||||
<div className="h-16 w-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Bot className="h-8 w-8 text-primary/50" />
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground mb-1">尚未添加任何 AI 配置</p>
|
||||
<p className="text-sm text-muted-foreground mb-5">配置 Qdrant 向量库和大模型地址后,小程序可智能问答植物百科</p>
|
||||
<Button onClick={handleAdd} className="shadow-soft">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
立即添加
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{configs.map(cfg => (
|
||||
<ConfigCard
|
||||
key={cfg.id}
|
||||
cfg={cfg}
|
||||
onEdit={() => handleEdit(cfg)}
|
||||
onDelete={() => { setSelectedConfig(cfg); setDeleteDialogOpen(true) }}
|
||||
onActivate={() => handleActivate(cfg)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Create/Edit Dialog ── */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[92vh] overflow-y-auto p-0 gap-0 rounded-2xl">
|
||||
{/* Dialog header with gradient */}
|
||||
<div className="bg-gradient-to-br from-slate-900 to-slate-800 text-white px-6 py-5 rounded-t-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2.5 text-base">
|
||||
<div className="h-8 w-8 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
{isEdit ? '编辑 AI 配置' : '新增 AI 配置'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/50 text-xs mt-1">
|
||||
对话模型与 Embedding 模型可来自不同供应商,支持 DeepSeek、Qwen、Ollama 等
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* ── Qdrant Section ── */}
|
||||
<div className="rounded-xl border border-teal-200/60 bg-teal-50/30 p-4 space-y-4">
|
||||
<SectionLabel
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
label="Qdrant 向量库"
|
||||
color="text-teal-600"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FieldRow label="接口地址 *" hint="例:http://localhost:6333">
|
||||
<Input
|
||||
value={formData.qdrantUrl}
|
||||
onChange={e => f('qdrantUrl', e.target.value)}
|
||||
placeholder="http://localhost:6333"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="API Key" hint="无鉴权可留空">
|
||||
<div className="relative">
|
||||
<Key className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.qdrantApiKey}
|
||||
onChange={e => f('qdrantApiKey', e.target.value)}
|
||||
placeholder="留空"
|
||||
className="h-8 text-sm pl-8 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</FieldRow>
|
||||
<FieldRow label="Collection 名称">
|
||||
<Input
|
||||
value={formData.qdrantCollection}
|
||||
onChange={e => f('qdrantCollection', e.target.value)}
|
||||
placeholder="plants"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="向量维度" hint="需与 Embedding 模型输出维度一致">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.vectorDimension}
|
||||
onChange={e => f('vectorDimension', Number(e.target.value))}
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Chat LLM Section ── */}
|
||||
<div className="rounded-xl border border-blue-200/60 bg-blue-50/30 p-4 space-y-4">
|
||||
<SectionLabel
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
label="对话大模型"
|
||||
color="text-blue-600"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FieldRow label="供应商标识">
|
||||
<Input
|
||||
value={formData.chatProvider}
|
||||
onChange={e => f('chatProvider', e.target.value)}
|
||||
placeholder="deepseek / qwen / local"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="模型名称 *">
|
||||
<Input
|
||||
value={formData.chatModelName}
|
||||
onChange={e => f('chatModelName', e.target.value)}
|
||||
placeholder="deepseek-chat"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="API 接口地址 *" hint="OpenAI 兼容格式,结尾不含 /chat">
|
||||
<Input
|
||||
value={formData.chatApiUrl}
|
||||
onChange={e => f('chatApiUrl', e.target.value)}
|
||||
placeholder="https://api.deepseek.com/v1"
|
||||
className="h-8 text-sm bg-white col-span-2"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="API Key">
|
||||
<div className="relative">
|
||||
<Key className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.chatApiKey}
|
||||
onChange={e => f('chatApiKey', e.target.value)}
|
||||
placeholder="sk-... (本地部署可留空)"
|
||||
className="h-8 text-sm pl-8 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</FieldRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Embedding Section ── */}
|
||||
<div className="rounded-xl border border-purple-200/60 bg-purple-50/30 p-4 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbExpanded(!embExpanded)}
|
||||
className="w-full flex items-center justify-between text-purple-600 hover:text-purple-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Embedding 向量化模型
|
||||
</div>
|
||||
{embExpanded
|
||||
? <ChevronUp className="h-4 w-4 opacity-60" />
|
||||
: <ChevronDown className="h-4 w-4 opacity-60" />
|
||||
}
|
||||
</button>
|
||||
{embExpanded && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FieldRow label="供应商标识">
|
||||
<Input
|
||||
value={formData.embeddingProvider}
|
||||
onChange={e => f('embeddingProvider', e.target.value)}
|
||||
placeholder="local / qwen / openai"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="Embedding 模型名称 *">
|
||||
<Input
|
||||
value={formData.embeddingModelName}
|
||||
onChange={e => f('embeddingModelName', e.target.value)}
|
||||
placeholder="bge-m3"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="API 接口地址 *" hint="OpenAI 兼容格式">
|
||||
<Input
|
||||
value={formData.embeddingApiUrl}
|
||||
onChange={e => f('embeddingApiUrl', e.target.value)}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
className="h-8 text-sm bg-white"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="API Key">
|
||||
<div className="relative">
|
||||
<Key className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.embeddingApiKey}
|
||||
onChange={e => f('embeddingApiKey', e.target.value)}
|
||||
placeholder="留空"
|
||||
className="h-8 text-sm pl-8 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</FieldRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 pb-6 pt-0">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} className="h-9">取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting} className="h-9 min-w-[96px]">
|
||||
{submitting
|
||||
? <><Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />保存中</>
|
||||
: '保存配置'
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Dialog ── */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="max-w-sm rounded-2xl">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-base">确认删除配置?</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
此操作不可撤销。
|
||||
{selectedConfig?.isActive === 1 && (
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
⚠️ 该配置当前处于激活状态,删除后将无激活配置。
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-center gap-3 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(false)}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+170
-443
@@ -1,493 +1,220 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Upload, Search, MoreHorizontal, Trash2, Copy, Download, Image, FileText, Film, Music, Archive, File } from 'lucide-react'
|
||||
import { Upload, MoreHorizontal, Trash2, Copy, Download, Image, FileText, Film, Music, Archive, File, LayoutGrid, List, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getFileList,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
type SystemOss,
|
||||
type GetOssFileListParams,
|
||||
} from '@/api/system'
|
||||
import { PageHeader, SearchBar, Pagination } from '@/components/PageUI'
|
||||
import { getFileList, uploadFile, deleteFile, type SystemOss, type GetOssFileListParams } from '@/api/system'
|
||||
import { DeleteDialog } from '@/components/PageUI'
|
||||
|
||||
// 根据文件后缀获取图标
|
||||
function getFileIcon(suffix?: string) {
|
||||
const ext = suffix?.toLowerCase()
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext || '')) {
|
||||
return <Image className="h-6 w-6" />
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext || '')) {
|
||||
return <Film className="h-6 w-6" />
|
||||
}
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext || '')) {
|
||||
return <Music className="h-6 w-6" />
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
|
||||
return <Archive className="h-6 w-6" />
|
||||
}
|
||||
if (['doc', 'docx', 'pdf', 'txt', 'md', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) {
|
||||
return <FileText className="h-6 w-6" />
|
||||
}
|
||||
return <File className="h-6 w-6" />
|
||||
function getIcon(suffix?: string) {
|
||||
const e = suffix?.toLowerCase() || ''
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(e)) return <Image className="h-5 w-5" />
|
||||
if (['mp4', 'avi', 'mov', 'mkv'].includes(e)) return <Film className="h-5 w-5" />
|
||||
if (['mp3', 'wav', 'ogg', 'flac'].includes(e)) return <Music className="h-5 w-5" />
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return <Archive className="h-5 w-5" />
|
||||
if (['doc', 'docx', 'pdf', 'txt', 'md', 'xls', 'xlsx'].includes(e)) return <FileText className="h-5 w-5" />
|
||||
return <File className="h-5 w-5" />
|
||||
}
|
||||
const isImg = (s?: string) => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(s?.toLowerCase() || '')
|
||||
|
||||
// 判断是否是图片
|
||||
function isImage(suffix?: string) {
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(suffix?.toLowerCase() || '')
|
||||
const colorMap: Record<string, string> = {
|
||||
jpg: 'bg-pink-500/10 text-pink-600', jpeg: 'bg-pink-500/10 text-pink-600',
|
||||
png: 'bg-blue-500/10 text-blue-600', gif: 'bg-violet-500/10 text-violet-600',
|
||||
webp: 'bg-teal-500/10 text-teal-600', svg: 'bg-orange-500/10 text-orange-600',
|
||||
mp4: 'bg-red-500/10 text-red-600', pdf: 'bg-red-500/10 text-red-600',
|
||||
}
|
||||
const getIconBg = (s?: string) => colorMap[s?.toLowerCase() || ''] || 'bg-muted text-muted-foreground'
|
||||
|
||||
export default function FilesPage() {
|
||||
const [files, setFiles] = useState<SystemOss[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [searchParams, setSearchParams] = useState<GetOssFileListParams>({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
})
|
||||
const [sp, setSp] = useState<GetOssFileListParams>({ current: 1, pageSize: 20, keyword: '' })
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<SystemOss | null>(null)
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 获取文件列表
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileList(searchParams)
|
||||
setFiles(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
try { const res = await getFileList(sp); setFiles(res.data?.list || []); setTotal(res.data?.total || 0) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { fetchFiles() }, [sp.current, sp.pageSize, sp.keyword])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
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)
|
||||
try {
|
||||
await uploadFile(file)
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
console.error('上传文件失败:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
try { await uploadFile(file); fetchFiles() }
|
||||
catch (err) { console.error(err) } finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
|
||||
}
|
||||
const handleCopy = (url: string) => navigator.clipboard.writeText(url)
|
||||
const handleDownload = (f: SystemOss) => {
|
||||
const a = document.createElement('a'); a.href = f.url; a.download = f.name; a.target = '_blank'
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制链接
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url)
|
||||
// 可以添加 toast 提示
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = (file: SystemOss) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.name
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 预览文件
|
||||
const handlePreview = (file: SystemOss) => {
|
||||
setSelectedFile(file)
|
||||
setPreviewDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (file: SystemOss) => {
|
||||
setSelectedFile(file)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedFile) return
|
||||
|
||||
try {
|
||||
await deleteFile([selectedFile.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedFile(null)
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
console.error('删除文件失败:', error)
|
||||
}
|
||||
try { await deleteFile([selectedFile.id]); setDeleteDialogOpen(false); fetchFiles() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">文件管理</h1>
|
||||
<p className="text-muted-foreground">管理系统上传的所有文件资源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{uploading ? '上传中...' : '上传文件'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索文件名..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
<div className="flex border rounded-lg overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
网格
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<File className="h-5 w-5" />
|
||||
文件列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<File className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无文件,点击上传添加</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
// 网格视图
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="aspect-square flex items-center justify-center bg-muted cursor-pointer"
|
||||
onClick={() => handlePreview(file)}
|
||||
>
|
||||
{isImage(file.suffix) && file.url ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{getFileIcon(file.suffix)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium truncate" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{file.suffix?.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
const FileMenu = ({ f }: { f: SystemOss }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<Button variant="secondary" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleCopyUrl(file.url)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制链接
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteConfirm(file)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => handleCopy(f.url)}><Copy className="mr-2 h-3.5 w-3.5" />复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDownload(f)}><Download className="mr-2 h-3.5 w-3.5" />下载</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setSelectedFile(f); setDeleteDialogOpen(true) }}>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 列表视图
|
||||
<div className="space-y-2">
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-4 p-3 rounded-lg border hover:bg-muted/50 group"
|
||||
>
|
||||
{/* 图标/缩略图 */}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted overflow-hidden flex-shrink-0">
|
||||
{isImage(file.suffix) && file.url ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="h-full w-full object-cover cursor-pointer"
|
||||
onClick={() => handlePreview(file)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{getFileIcon(file.suffix)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.name}</p>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>{file.suffix?.toUpperCase()}</span>
|
||||
{file.width && file.height && (
|
||||
<span>{file.width} × {file.height}</span>
|
||||
)}
|
||||
<span>{file.createdAtStr || file.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="hidden md:block max-w-xs truncate text-sm text-muted-foreground">
|
||||
{file.url}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleCopyUrl(file.url)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDownload(file)}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteConfirm(file)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
if (totalPages === 0) {
|
||||
return <Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
}
|
||||
return Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<PageHeader
|
||||
icon={<File className="h-5 w-5 text-white" />}
|
||||
title="文件管理"
|
||||
description="管理系统上传的所有图片与文件资源"
|
||||
accentColor="bg-blue-500/20"
|
||||
actions={
|
||||
<>
|
||||
<input ref={fileRef} type="file" className="hidden" onChange={handleUpload} />
|
||||
<Button size="sm" variant="outline"
|
||||
className="h-9 bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white backdrop-blur-sm"
|
||||
onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
||||
{uploading ? '上传中...' : '上传文件'}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= Math.ceil(total / searchParams.pageSize) || total === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<select
|
||||
className="h-8 px-2 border rounded-md text-sm bg-background"
|
||||
value={searchParams.pageSize}
|
||||
onChange={e => setSearchParams({ ...searchParams, pageSize: Number(e.target.value), current: 1 })}
|
||||
>
|
||||
<option value={10}>10 条/页</option>
|
||||
<option value={20}>20 条/页</option>
|
||||
<option value={50}>50 条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 图片预览对话框 */}
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedFile?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center min-h-[300px] bg-muted rounded-lg overflow-hidden">
|
||||
{selectedFile && isImage(selectedFile.suffix) ? (
|
||||
<img
|
||||
src={selectedFile.url}
|
||||
alt={selectedFile.name}
|
||||
className="max-h-[70vh] object-contain"
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<div className="mx-auto mb-4">{getFileIcon(selectedFile?.suffix)}</div>
|
||||
<p>无法预览此文件类型</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{selectedFile?.suffix?.toUpperCase()}</span>
|
||||
{selectedFile?.width && selectedFile?.height && (
|
||||
<span>{selectedFile.width} × {selectedFile.height}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => selectedFile && handleCopyUrl(selectedFile.url)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制链接
|
||||
|
||||
<SearchBar
|
||||
value={sp.keyword || ''}
|
||||
onChange={v => setSp(p => ({ ...p, keyword: v }))}
|
||||
onSearch={() => setSp(p => ({ ...p, current: 1 }))}
|
||||
placeholder="搜索文件名..."
|
||||
extra={
|
||||
<div className="flex border border-border/60 rounded-lg overflow-hidden shrink-0">
|
||||
<Button variant={viewMode === 'grid' ? 'default' : 'ghost'} size="sm" className="h-9 rounded-none px-3" onClick={() => setViewMode('grid')}>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => selectedFile && handleDownload(selectedFile)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
<Button variant={viewMode === 'list' ? 'default' : 'ghost'} size="sm" className="h-9 rounded-none px-3" onClick={() => setViewMode('list')}>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card shadow-soft overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20"><Loader2 className="h-7 w-7 animate-spin text-primary/40" /></div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
|
||||
<div className="h-14 w-14 rounded-2xl bg-muted flex items-center justify-center">
|
||||
<File className="h-7 w-7 opacity-30" />
|
||||
</div>
|
||||
<p className="text-sm">暂无文件,点击右上角上传</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className="group relative rounded-xl border border-border/50 bg-background overflow-hidden hover:shadow-soft hover:border-primary/20 transition-all">
|
||||
<div className="aspect-square flex items-center justify-center bg-muted cursor-pointer overflow-hidden"
|
||||
onClick={() => { setSelectedFile(f); setPreviewOpen(true) }}>
|
||||
{isImg(f.suffix) && f.url
|
||||
? <img src={f.url} alt={f.name} className="h-full w-full object-cover hover:scale-105 transition-transform duration-300" />
|
||||
: <div className={`p-3 rounded-xl ${getIconBg(f.suffix)}`}>{getIcon(f.suffix)}</div>
|
||||
}
|
||||
</div>
|
||||
<div className="p-2.5 flex items-center justify-between gap-1">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">{f.name}</p>
|
||||
<Badge variant="secondary" className="text-[10px] mt-0.5 h-4 px-1">{f.suffix?.toUpperCase()}</Badge>
|
||||
</div>
|
||||
<FileMenu f={f} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className="group flex items-center gap-4 px-5 py-3.5 hover:bg-muted/20 transition-colors">
|
||||
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shrink-0 overflow-hidden ${getIconBg(f.suffix)}`}>
|
||||
{isImg(f.suffix) && f.url
|
||||
? <img src={f.url} alt={f.name} className="h-full w-full object-cover cursor-pointer" onClick={() => { setSelectedFile(f); setPreviewOpen(true) }} />
|
||||
: getIcon(f.suffix)
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{f.name}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{f.suffix?.toUpperCase()}</span>
|
||||
{f.width && f.height && <span>{f.width}×{f.height}</span>}
|
||||
<span>{f.createdAtStr || f.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hidden md:block max-w-56 truncate text-xs text-muted-foreground">{f.url}</p>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCopy(f.url)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleDownload(f)}><Download className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => { setSelectedFile(f); setDeleteDialogOpen(true) }}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Pagination total={total} current={sp.current} pageSize={sp.pageSize}
|
||||
onChange={p => setSp(s => ({ ...s, current: p }))}
|
||||
onPageSizeChange={s => setSp(p => ({ ...p, pageSize: s, current: 1 }))} />
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-w-4xl rounded-2xl bg-black/95 border-white/10">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white text-sm">{selectedFile?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center min-h-64 rounded-xl overflow-hidden">
|
||||
{selectedFile && isImg(selectedFile.suffix)
|
||||
? <img src={selectedFile.url} alt={selectedFile.name} className="max-h-[70vh] object-contain rounded-lg" />
|
||||
: <div className="flex flex-col items-center gap-3 text-white/50 py-12">{getIcon(selectedFile?.suffix)}<p className="text-sm">无法预览此类型</p></div>
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-white/50">
|
||||
<span>{selectedFile?.suffix?.toUpperCase()}</span>
|
||||
{selectedFile?.width && <span>{selectedFile.width}×{selectedFile.height}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs bg-white/10 border-white/20 text-white hover:bg-white/20" onClick={() => selectedFile && handleCopy(selectedFile.url)}>
|
||||
<Copy className="mr-1.5 h-3 w-3" />复制链接
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs bg-white/10 border-white/20 text-white hover:bg-white/20" onClick={() => selectedFile && handleDownload(selectedFile)}>
|
||||
<Download className="mr-1.5 h-3 w-3" />下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除文件 "{selectedFile?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}
|
||||
description={`确定删除文件「${selectedFile?.name}」吗?此操作不可撤销。`}
|
||||
onConfirm={handleDelete} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+134
-403
@@ -1,183 +1,80 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, ChevronRight, ChevronDown, Pencil, Trash2, FolderTree } from 'lucide-react'
|
||||
import { Plus, ChevronRight, ChevronDown, Pencil, Trash2, FolderTree, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getAllMenuTree,
|
||||
getMenuDetail,
|
||||
saveMenu,
|
||||
updateMenu,
|
||||
deleteMenu,
|
||||
type SystemMenu
|
||||
} from '@/api/system'
|
||||
import { PageHeader, DeleteDialog } from '@/components/PageUI'
|
||||
import { getAllMenuTree, getMenuDetail, saveMenu, updateMenu, deleteMenu, type SystemMenu } from '@/api/system'
|
||||
|
||||
// 图标选项
|
||||
const iconOptions = [
|
||||
{ value: 'dashboard', label: '仪表盘' },
|
||||
{ value: 'settings', label: '设置' },
|
||||
{ value: 'users', label: '用户' },
|
||||
{ value: 'shield', label: '盾牌' },
|
||||
{ value: 'menu', label: '菜单' },
|
||||
{ value: 'folder', label: '文件夹' },
|
||||
{ value: 'folder-tree', label: '目录树' },
|
||||
{ value: 'leaf', label: '叶子' },
|
||||
{ value: 'message', label: '消息' },
|
||||
{ value: 'book', label: '书籍' },
|
||||
{ value: 'file-text', label: '文档' },
|
||||
{ value: 'hash', label: '标签' },
|
||||
{ value: 'monitor', label: '监控' },
|
||||
{ value: 'home', label: '首页' },
|
||||
{ value: 'dashboard', label: '仪表盘' }, { value: 'settings', label: '设置' },
|
||||
{ value: 'users', label: '用户' }, { value: 'shield', label: '盾牌' },
|
||||
{ value: 'menu', label: '菜单' }, { value: 'folder', label: '文件夹' },
|
||||
{ value: 'folder-tree', label: '目录树' }, { value: 'leaf', label: '叶子' },
|
||||
{ value: 'message', label: '消息' }, { value: 'book', label: '书籍' },
|
||||
{ value: 'file-text', label: '文档' }, { value: 'hash', label: '标签' },
|
||||
{ value: 'monitor', label: '监控' }, { value: 'home', label: '首页' },
|
||||
{ value: 'bot', label: 'AI机器人' },
|
||||
]
|
||||
|
||||
interface MenuFormData {
|
||||
id?: string
|
||||
parentId: string
|
||||
category: number
|
||||
name: string
|
||||
title: string
|
||||
code: string
|
||||
permission: string
|
||||
locale: string
|
||||
icon: string
|
||||
sort: number
|
||||
interface MenuForm {
|
||||
id?: string; parentId: string; category: number; name: string
|
||||
title: string; code: string; permission: string; locale: string; icon: string; sort: number
|
||||
}
|
||||
const defaultForm: MenuForm = { parentId: '0', category: 1, name: '', title: '', code: '', permission: '', locale: '', icon: '', sort: 0 }
|
||||
|
||||
const defaultFormData: MenuFormData = {
|
||||
parentId: '0',
|
||||
category: 1,
|
||||
name: '',
|
||||
title: '',
|
||||
code: '',
|
||||
permission: '',
|
||||
locale: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
}
|
||||
|
||||
// 菜单树节点组件
|
||||
function MenuTreeNode({
|
||||
menu,
|
||||
level = 0,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: {
|
||||
menu: SystemMenu
|
||||
level?: number
|
||||
onEdit: (menu: SystemMenu) => void
|
||||
onDelete: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
function MenuNode({ menu, level = 0, onEdit, onDelete, onAddChild }: {
|
||||
menu: SystemMenu; level?: number
|
||||
onEdit: (m: SystemMenu) => void; onDelete: (id: string) => void; onAddChild: (pid: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(level < 2)
|
||||
const hasChildren = menu.children && menu.children.length > 0
|
||||
const [open, setOpen] = useState(level < 2)
|
||||
const hasChildren = !!menu.children?.length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-muted/50 group',
|
||||
level > 0 && 'ml-6'
|
||||
)}
|
||||
>
|
||||
{/* 展开/折叠按钮 */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted',
|
||||
!hasChildren && 'invisible'
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className={cn('flex items-center gap-2.5 py-2 px-3 rounded-xl hover:bg-muted/40 group transition-colors', level > 0 && 'ml-5')}>
|
||||
<button onClick={() => setOpen(!open)} className={cn('p-0.5 rounded text-muted-foreground', !hasChildren && 'invisible')}>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
<div className={cn('flex h-7 w-7 items-center justify-center rounded-lg shrink-0',
|
||||
menu.category === 1 ? 'bg-primary/10 text-primary' : 'bg-amber-500/10 text-amber-600')}>
|
||||
<FolderTree className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
{/* 名称和信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{menu.title || menu.name}</span>
|
||||
<Badge variant={menu.category === 1 ? 'default' : 'secondary'} className="text-xs">
|
||||
<span className="text-sm font-medium truncate">{menu.title || menu.name}</span>
|
||||
<Badge variant={menu.category === 1 ? 'default' : 'secondary'} className="text-[10px] h-4 px-1.5 shrink-0">
|
||||
{menu.category === 1 ? '菜单' : '按钮'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{menu.code && <span>路由: {menu.code}</span>}
|
||||
{menu.permission && <span>权限: {menu.permission}</span>}
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground mt-0.5">
|
||||
{menu.code && <span>路由: <code className="font-mono">{menu.code}</code></span>}
|
||||
{menu.permission && <span>权限: <code className="font-mono">{menu.permission}</code></span>}
|
||||
<span className="text-muted-foreground/50">排序: {menu.sort}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<span className="text-sm text-muted-foreground">排序: {menu.sort}</span>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{menu.category === 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onAddChild(menu.id)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAddChild(menu.id)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit(menu)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(menu)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(menu.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => onDelete(menu.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 子菜单 */}
|
||||
{expanded && hasChildren && (
|
||||
<div className="border-l border-border ml-6">
|
||||
{open && hasChildren && (
|
||||
<div className="ml-5 border-l border-border/40 pl-2 mt-0.5 space-y-0.5">
|
||||
{menu.children!.map(child => (
|
||||
<MenuTreeNode
|
||||
key={child.id}
|
||||
menu={child}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
<MenuNode key={child.id} menu={child} level={level + 1} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -191,329 +88,163 @@ export default function MenusPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedMenuId, setSelectedMenuId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<MenuFormData>(defaultFormData)
|
||||
const [form, setForm] = useState<MenuForm>(defaultForm)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 获取所有菜单树
|
||||
const fetchMenus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getAllMenuTree({ category: 1, parentId: '0' })
|
||||
setMenus(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('获取菜单失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenus()
|
||||
}, [])
|
||||
|
||||
// 展平菜单树用于父级选择
|
||||
const flattenMenus = (menuList: SystemMenu[], level = 0): { menu: SystemMenu; level: number }[] => {
|
||||
const result: { menu: SystemMenu; level: number }[] = []
|
||||
for (const menu of menuList) {
|
||||
if (menu.category === 1) { // 只有菜单类型才能作为父级
|
||||
result.push({ menu, level })
|
||||
if (menu.children) {
|
||||
result.push(...flattenMenus(menu.children, level + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
try { const res = await getAllMenuTree({ category: 1, parentId: '0' }); setMenus(res.data || []) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { fetchMenus() }, [])
|
||||
|
||||
const flattenMenus = (list: SystemMenu[], lvl = 0): { menu: SystemMenu; level: number }[] =>
|
||||
list.flatMap(m => m.category === 1 ? [{ menu: m, level: lvl }, ...(m.children ? flattenMenus(m.children, lvl + 1) : [])] : [])
|
||||
const flatMenus = flattenMenus(menus)
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理添加子菜单
|
||||
const handleAddChild = (parentId: string) => {
|
||||
setFormData({ ...defaultFormData, parentId })
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (menu: SystemMenu) => {
|
||||
try {
|
||||
const res = await getMenuDetail(menu.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
parentId: detail.parentId || '0',
|
||||
category: detail.category || 1,
|
||||
name: detail.name || '',
|
||||
title: detail.title || '',
|
||||
code: detail.code || '',
|
||||
permission: detail.permission || '',
|
||||
locale: detail.locale || '',
|
||||
icon: detail.icon || '',
|
||||
sort: detail.sort || 0,
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取菜单详情失败:', error)
|
||||
const d = res.data
|
||||
setForm({ id: d.id, parentId: d.parentId || '0', category: d.category || 1, name: d.name || '', title: d.title || '', code: d.code || '', permission: d.permission || '', locale: d.locale || '', icon: d.icon || '', sort: d.sort || 0 })
|
||||
setIsEdit(true); setDialogOpen(true)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (id: string) => {
|
||||
setSelectedMenuId(id)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedMenuId) return
|
||||
|
||||
try {
|
||||
await deleteMenu(selectedMenuId)
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedMenuId(null)
|
||||
fetchMenus()
|
||||
} catch (error) {
|
||||
console.error('删除菜单失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.title) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.name || !form.title) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateMenu(formData)
|
||||
} else {
|
||||
await saveMenu(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchMenus()
|
||||
} catch (error) {
|
||||
console.error('保存菜单失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
isEdit && form.id ? await updateMenu(form) : await saveMenu(form)
|
||||
setDialogOpen(false); fetchMenus()
|
||||
} catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
const handleDelete = async () => {
|
||||
if (!selectedMenuId) return
|
||||
try { await deleteMenu(selectedMenuId); setDeleteDialogOpen(false); fetchMenus() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">菜单管理</h1>
|
||||
<p className="text-muted-foreground">管理系统菜单和权限配置</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增菜单
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<PageHeader
|
||||
icon={<FolderTree className="h-5 w-5 text-white" />}
|
||||
title="菜单管理"
|
||||
description="管理系统导航菜单与权限节点"
|
||||
accentColor="bg-amber-500/25"
|
||||
actions={
|
||||
<Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md"
|
||||
onClick={() => { setForm(defaultForm); setIsEdit(false); setDialogOpen(true) }}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />新增菜单
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 菜单树 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderTree className="h-5 w-5" />
|
||||
菜单树
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-2xl border border-border/60 bg-card shadow-soft p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-7 w-7 animate-spin text-primary/50" /></div>
|
||||
) : menus.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无菜单数据,请添加
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
||||
<FolderTree className="h-10 w-10 opacity-20 mb-2" />
|
||||
<p className="text-sm">暂无菜单,点击右上角新增</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
{menus.map(menu => (
|
||||
<MenuTreeNode
|
||||
key={menu.id}
|
||||
menu={menu}
|
||||
<MenuNode key={menu.id} menu={menu}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDeleteConfirm}
|
||||
onAddChild={handleAddChild}
|
||||
onDelete={id => { setSelectedMenuId(id); setDeleteDialogOpen(true) }}
|
||||
onAddChild={pid => { setForm({ ...defaultForm, parentId: pid }); setIsEdit(false); setDialogOpen(true) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg rounded-2xl p-0 gap-0">
|
||||
<div className="bg-gradient-to-br from-slate-900 to-slate-800 text-white px-6 py-5 rounded-t-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑菜单' : '新增菜单'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改菜单信息' : '添加新的菜单或按钮权限'}
|
||||
</DialogDescription>
|
||||
<DialogTitle className="text-white text-base flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 rounded-lg bg-white/10 flex items-center justify-center"><FolderTree className="h-3.5 w-3.5" /></div>
|
||||
{isEdit ? '编辑菜单' : '新增菜单'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/50 text-xs">配置系统菜单或按钮权限项</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>父级菜单</Label>
|
||||
<Select
|
||||
value={formData.parentId}
|
||||
onValueChange={v => setFormData({ ...formData, parentId: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择父级" />
|
||||
</SelectTrigger>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">父级菜单</Label>
|
||||
<Select value={form.parentId} onValueChange={v => setForm({ ...form, parentId: v })}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择父级" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">顶级菜单</SelectItem>
|
||||
{flatMenus.map(({ menu, level }) => (
|
||||
<SelectItem key={menu.id} value={menu.id}>
|
||||
{' '.repeat(level)}{menu.title || menu.name}
|
||||
</SelectItem>
|
||||
<SelectItem key={menu.id} value={menu.id}>{' '.repeat(level)}{menu.title || menu.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={String(formData.category)}
|
||||
onValueChange={v => setFormData({ ...formData, category: Number(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">类型</Label>
|
||||
<Select value={String(form.category)} onValueChange={v => setForm({ ...form, category: Number(v) })}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">菜单</SelectItem>
|
||||
<SelectItem value="2">按钮</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input className="h-8 text-sm" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="英文标识" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="英文标识"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">标题 *</Label>
|
||||
<Input className="h-8 text-sm" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="显示名称" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>标题 *</Label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="显示名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.category === 1 && (
|
||||
{form.category === 1 && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>路由路径</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={e => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="/path"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">路由路径</Label>
|
||||
<Input className="h-8 text-sm" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="/path" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>图标</Label>
|
||||
<Select
|
||||
value={formData.icon}
|
||||
onValueChange={v => setFormData({ ...formData, icon: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择图标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{iconOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">图标</Label>
|
||||
<Select value={form.icon} onValueChange={v => setForm({ ...form, icon: v })}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择图标" /></SelectTrigger>
|
||||
<SelectContent>{iconOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>权限标识</Label>
|
||||
<Input
|
||||
value={formData.permission}
|
||||
onChange={e => setFormData({ ...formData, permission: e.target.value })}
|
||||
placeholder="module:action"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">权限标识</Label>
|
||||
<Input className="h-8 text-sm" value={form.permission} onChange={e => setForm({ ...form, permission: e.target.value })} placeholder="module:action" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sort}
|
||||
onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">排序</Label>
|
||||
<Input className="h-8 text-sm" type="number" value={form.sort} onChange={e => setForm({ ...form, sort: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>国际化键名</Label>
|
||||
<Input
|
||||
value={formData.locale}
|
||||
onChange={e => setFormData({ ...formData, locale: e.target.value })}
|
||||
placeholder="menu.xxx"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">国际化键名</Label>
|
||||
<Input className="h-8 text-sm" value={form.locale} onChange={e => setForm({ ...form, locale: e.target.value })} placeholder="menu.xxx" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
<DialogFooter className="px-5 pb-5 pt-0">
|
||||
<Button variant="outline" className="h-8" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button className="h-8 min-w-20" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除菜单将同时删除其所有子菜单和权限配置,此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}
|
||||
description="删除菜单将同时删除其所有子菜单和权限配置,此操作不可撤销。"
|
||||
onConfirm={handleDelete} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+126
-523
@@ -1,137 +1,44 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Shield, Key } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, Shield, Key, MoreHorizontal, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { PageHeader, SearchBar, DataTable, Pagination, DeleteDialog } from '@/components/PageUI'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
getRoleList,
|
||||
getRoleDetail,
|
||||
saveRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
grantMenu,
|
||||
getAllMenuTree,
|
||||
type SystemRole,
|
||||
type SystemMenu,
|
||||
type GetRoleListParams,
|
||||
} from '@/api/system'
|
||||
import { getRoleList, getRoleDetail, saveRole, updateRole, deleteRole, grantMenu, getAllMenuTree, type SystemRole, type SystemMenu, type GetRoleListParams } from '@/api/system'
|
||||
|
||||
interface RoleFormData {
|
||||
id?: string
|
||||
name: string
|
||||
code: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
const defaultFormData: RoleFormData = {
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 0,
|
||||
}
|
||||
|
||||
// 递归获取所有菜单ID
|
||||
function getAllMenuIds(menus: SystemMenu[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const menu of menus) {
|
||||
ids.push(menu.id)
|
||||
if (menu.children) {
|
||||
ids.push(...getAllMenuIds(menu.children))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
return menus.flatMap(m => [m.id, ...(m.children ? getAllMenuIds(m.children) : [])])
|
||||
}
|
||||
|
||||
// 菜单树选择组件
|
||||
function MenuTreeSelect({
|
||||
menus,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
level = 0,
|
||||
}: {
|
||||
menus: SystemMenu[]
|
||||
selectedIds: string[]
|
||||
onToggle: (id: string, children?: SystemMenu[]) => void
|
||||
level?: number
|
||||
function MenuTree({ menus, selectedIds, onToggle, level = 0 }: {
|
||||
menus: SystemMenu[]; selectedIds: string[]
|
||||
onToggle: (id: string, children?: SystemMenu[]) => void; level?: number
|
||||
}) {
|
||||
return (
|
||||
<div className={level > 0 ? 'ml-6 border-l pl-4' : ''}>
|
||||
<div className={level > 0 ? 'ml-5 border-l border-border/40 pl-3' : ''}>
|
||||
{menus.map(menu => {
|
||||
const hasChildren = menu.children && menu.children.length > 0
|
||||
const isChecked = selectedIds.includes(menu.id)
|
||||
const childIds = hasChildren ? getAllMenuIds(menu.children!) : []
|
||||
const allChildrenSelected = childIds.length > 0 && childIds.every(id => selectedIds.includes(id))
|
||||
const someChildrenSelected = childIds.some(id => selectedIds.includes(id))
|
||||
|
||||
const hasChildren = !!menu.children?.length
|
||||
const checked = selectedIds.includes(menu.id)
|
||||
return (
|
||||
<div key={menu.id} className="py-1">
|
||||
<div className="flex items-center gap-2 py-1 hover:bg-muted/50 rounded px-2">
|
||||
<div key={menu.id} className="py-0.5">
|
||||
<label className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-muted/40 cursor-pointer">
|
||||
<Checkbox
|
||||
id={`menu-${menu.id}`}
|
||||
checked={isChecked}
|
||||
ref={(el) => {
|
||||
if (el && hasChildren) {
|
||||
(el as HTMLButtonElement & { indeterminate?: boolean }).indeterminate =
|
||||
someChildrenSelected && !allChildrenSelected
|
||||
}
|
||||
}}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggle(menu.id, menu.children)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`menu-${menu.id}`}
|
||||
className="flex-1 cursor-pointer text-sm"
|
||||
>
|
||||
<span className="font-medium">{menu.title || menu.name}</span>
|
||||
{menu.permission && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({menu.permission})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Badge variant={menu.category === 1 ? 'outline' : 'secondary'} className="text-xs">
|
||||
<span className="text-sm flex-1">{menu.title || menu.name}</span>
|
||||
{menu.permission && <span className="text-[10px] text-muted-foreground">({menu.permission})</span>}
|
||||
<Badge variant={menu.category === 1 ? 'outline' : 'secondary'} className="text-[10px] h-4 px-1">
|
||||
{menu.category === 1 ? '菜单' : '按钮'}
|
||||
</Badge>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<MenuTreeSelect
|
||||
menus={menu.children!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
level={level + 1}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
{hasChildren && <MenuTree menus={menu.children!} selectedIds={selectedIds} onToggle={onToggle} level={level + 1} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -139,23 +46,22 @@ function MenuTreeSelect({
|
||||
)
|
||||
}
|
||||
|
||||
interface RoleForm { id?: string; name: string; code: string; sort: number }
|
||||
const defaultForm: RoleForm = { name: '', code: '', sort: 0 }
|
||||
|
||||
export default function RolesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [roles, setRoles] = useState<SystemRole[]>([])
|
||||
const [menus, setMenus] = useState<SystemMenu[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState<GetRoleListParams>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
})
|
||||
const [sp, setSp] = useState<GetRoleListParams>({ current: 1, pageSize: 10, keyword: '' })
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [menuDialogOpen, setMenuDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | null>(null)
|
||||
const [selectedMenuIds, setSelectedMenuIds] = useState<string[]>([])
|
||||
const [formData, setFormData] = useState<RoleFormData>(defaultFormData)
|
||||
const [form, setForm] = useState<RoleForm>(defaultForm)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -163,484 +69,181 @@ export default function RolesPage() {
|
||||
const canDelete = hasPermission('role:delete')
|
||||
const canGrant = hasPermission('role:grant')
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getRoleList(searchParams)
|
||||
setRoles(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
try { const res = await getRoleList(sp); setRoles(res.data?.list || []); setTotal(res.data?.total || 0) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { fetchRoles() }, [sp.current, sp.pageSize, sp.keyword])
|
||||
useEffect(() => { getAllMenuTree({ category: 1, parentId: '0' }).then(r => setMenus(r.data || [])).catch(console.error) }, [])
|
||||
|
||||
// 获取菜单树
|
||||
const fetchMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenuTree({ category: 1, parentId: '0' })
|
||||
setMenus(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('获取菜单树失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 只在组件挂载时获取菜单树
|
||||
useEffect(() => {
|
||||
fetchMenus()
|
||||
}, [])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (role: SystemRole) => {
|
||||
try {
|
||||
const res = await getRoleDetail(role.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
name: detail.name || '',
|
||||
code: detail.code || '',
|
||||
sort: detail.sort || 0,
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取角色详情失败:', error)
|
||||
const d = res.data
|
||||
setForm({ id: d.id, name: d.name || '', code: d.code || '', sort: d.sort || 0 })
|
||||
setIsEdit(true); setDialogOpen(true)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
// 处理授权菜单
|
||||
const handleGrantMenu = async (role: SystemRole) => {
|
||||
setSelectedRole(role)
|
||||
// 获取角色已有的菜单权限
|
||||
try {
|
||||
const res = await getRoleDetail(role.id)
|
||||
const menuIds = res.data.menus?.map((m: SystemMenu) => m.id) || []
|
||||
setSelectedMenuIds(menuIds)
|
||||
setMenuDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取角色菜单失败:', error)
|
||||
setSelectedMenuIds([])
|
||||
setMenuDialogOpen(true)
|
||||
try { const res = await getRoleDetail(role.id); setSelectedMenuIds(res.data.menus?.map((m: SystemMenu) => m.id) || []) }
|
||||
catch { setSelectedMenuIds([]) } finally { setMenuDialogOpen(true) }
|
||||
}
|
||||
}
|
||||
|
||||
// 切换菜单选择
|
||||
const handleMenuToggle = (menuId: string, children?: SystemMenu[]) => {
|
||||
setSelectedMenuIds(prev => {
|
||||
const isSelected = prev.includes(menuId)
|
||||
let newIds = isSelected
|
||||
? prev.filter(id => id !== menuId)
|
||||
: [...prev, menuId]
|
||||
|
||||
// 如果有子菜单,同时选中/取消子菜单
|
||||
if (children && children.length > 0) {
|
||||
const has = prev.includes(menuId)
|
||||
let next = has ? prev.filter(id => id !== menuId) : [...prev, menuId]
|
||||
if (children?.length) {
|
||||
const childIds = getAllMenuIds(children)
|
||||
if (isSelected) {
|
||||
newIds = newIds.filter(id => !childIds.includes(id))
|
||||
} else {
|
||||
newIds = [...new Set([...newIds, ...childIds])]
|
||||
next = has ? next.filter(id => !childIds.includes(id)) : [...new Set([...next, ...childIds])]
|
||||
}
|
||||
}
|
||||
|
||||
return newIds
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// 提交授权菜单
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.code) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
isEdit && form.id ? await updateRole(form) : await saveRole(form)
|
||||
setDialogOpen(false); fetchRoles()
|
||||
} catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
const handleSubmitGrant = async () => {
|
||||
if (!selectedRole) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds })
|
||||
setMenuDialogOpen(false)
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('授权菜单失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
try { await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds }); setMenuDialogOpen(false); fetchRoles() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (role: SystemRole) => {
|
||||
setSelectedRole(role)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedRole) return
|
||||
|
||||
try {
|
||||
await deleteRole([selectedRole.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedRole(null)
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error)
|
||||
try { await deleteRole([selectedRole.id]); setDeleteDialogOpen(false); fetchRoles() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.code) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateRole(formData)
|
||||
} else {
|
||||
await saveRole(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('保存角色失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">角色管理</h1>
|
||||
<p className="text-muted-foreground">管理系统角色和权限分配</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加角色
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<PageHeader
|
||||
icon={<Shield className="h-5 w-5 text-white" />}
|
||||
title="角色管理"
|
||||
description="管理系统角色与菜单权限分配"
|
||||
accentColor="bg-violet-500/25"
|
||||
actions={canWrite ? (
|
||||
<Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md"
|
||||
onClick={() => { setForm(defaultForm); setIsEdit(false); setDialogOpen(true) }}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />新增角色
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索角色名称..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 角色列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
角色列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<SearchBar value={sp.keyword || ''} onChange={v => setSp(p => ({ ...p, keyword: v }))} onSearch={() => setSp(p => ({ ...p, current: 1 }))} placeholder="搜索角色名称或编码..." />
|
||||
|
||||
<DataTable loading={loading} empty={!loading && roles.length === 0} emptyText="暂无角色数据">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>角色名称</TableHead>
|
||||
<TableRow className="hover:bg-transparent bg-muted/30">
|
||||
<TableHead className="pl-5">角色名称</TableHead>
|
||||
<TableHead>角色编码</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[80px]">操作</TableHead>
|
||||
<TableHead className="pr-5 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map(role => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
{roles.map(role => (
|
||||
<TableRow key={role.id} className="hover:bg-muted/20">
|
||||
<TableCell className="pl-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-4 w-4 text-violet-600" />
|
||||
</div>
|
||||
<span className="font-medium">{role.name}</span>
|
||||
<span className="font-medium text-sm">{role.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-0.5 rounded">
|
||||
{role.code}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{role.sort}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{role.createdAtStr || role.createdAt}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{role.code}</code></TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{role.sort}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{role.createdAtStr || role.createdAt}</TableCell>
|
||||
<TableCell className="pr-5 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canWrite && (
|
||||
<DropdownMenuItem onClick={() => handleEdit(role)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canGrant && (
|
||||
<DropdownMenuItem onClick={() => handleGrantMenu(role)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
授权菜单
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteConfirm(role)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
{canWrite && <DropdownMenuItem onClick={() => handleEdit(role)}><Pencil className="mr-2 h-3.5 w-3.5" />编辑</DropdownMenuItem>}
|
||||
{canGrant && <DropdownMenuItem onClick={() => handleGrantMenu(role)}><Key className="mr-2 h-3.5 w-3.5" />授权菜单</DropdownMenuItem>}
|
||||
{canDelete && <DropdownMenuItem className="text-destructive" onClick={() => { setSelectedRole(role); setDeleteDialogOpen(true) }}><Trash2 className="mr-2 h-3.5 w-3.5" />删除</DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<Pagination total={total} current={sp.current} pageSize={sp.pageSize} onChange={p => setSp(s => ({ ...s, current: p }))} onPageSizeChange={s => setSp(p => ({ ...p, pageSize: s, current: 1 }))} />
|
||||
</DataTable>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{totalPages > 0 && Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{totalPages === 0 && (
|
||||
<Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条/页</SelectItem>
|
||||
<SelectItem value="20">20 条/页</SelectItem>
|
||||
<SelectItem value="50">50 条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
{/* 新增/编辑 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-sm rounded-2xl p-0 gap-0">
|
||||
<div className="bg-gradient-to-br from-slate-900 to-slate-800 text-white px-6 py-5 rounded-t-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑角色' : '添加角色'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改角色信息' : '创建新的角色'}
|
||||
</DialogDescription>
|
||||
<DialogTitle className="text-white text-base flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 rounded-lg bg-white/10 flex items-center justify-center"><Shield className="h-3.5 w-3.5" /></div>
|
||||
{isEdit ? '编辑角色' : '新增角色'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/50 text-xs">{isEdit ? '修改角色信息' : '创建一个新角色'}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>角色名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:管理员、运营"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>角色编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={e => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:admin、operator"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">角色名称 *</Label>
|
||||
<Input className="h-8 text-sm" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:管理员、运营" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sort}
|
||||
onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">角色编码 *</Label>
|
||||
<Input className="h-8 text-sm" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="如:admin、operator" disabled={isEdit} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">排序</Label>
|
||||
<Input className="h-8 text-sm" type="number" value={form.sort} onChange={e => setForm({ ...form, sort: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
<DialogFooter className="px-6 pb-5 pt-0">
|
||||
<Button variant="outline" className="h-8" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button className="h-8 min-w-20" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 授权菜单对话框 */}
|
||||
{/* 授权菜单 */}
|
||||
<Dialog open={menuDialogOpen} onOpenChange={setMenuDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh]">
|
||||
<DialogContent className="max-w-lg rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>授权菜单</DialogTitle>
|
||||
<DialogDescription>
|
||||
为角色 "{selectedRole?.name}" 分配菜单权限
|
||||
</DialogDescription>
|
||||
<DialogTitle className="flex items-center gap-2 text-base"><Key className="h-4 w-4 text-violet-600" />授权菜单</DialogTitle>
|
||||
<DialogDescription className="text-xs">为角色「{selectedRole?.name}」配置可访问菜单</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4 max-h-[50vh] overflow-y-auto border rounded-lg p-4">
|
||||
{menus.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
暂无菜单数据
|
||||
<div className="rounded-xl border border-border/50 max-h-64 overflow-y-auto p-3">
|
||||
{menus.length ? <MenuTree menus={menus} selectedIds={selectedMenuIds} onToggle={handleMenuToggle} /> : <p className="text-center text-muted-foreground text-sm py-8">暂无菜单数据</p>}
|
||||
</div>
|
||||
) : (
|
||||
<MenuTreeSelect
|
||||
menus={menus}
|
||||
selectedIds={selectedMenuIds}
|
||||
onToggle={handleMenuToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>已选择 {selectedMenuIds.length} 个菜单</span>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>已选 <strong className="text-foreground">{selectedMenuIds.length}</strong> 个</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMenuIds(getAllMenuIds(menus))}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMenuIds([])}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setSelectedMenuIds(getAllMenuIds(menus))}>全选</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setSelectedMenuIds([])}>清空</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMenuDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmitGrant} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '确定'}
|
||||
<Button variant="outline" className="h-8" onClick={() => setMenuDialogOpen(false)}>取消</Button>
|
||||
<Button className="h-8" onClick={handleSubmitGrant} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存权限'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除角色 "{selectedRole?.name}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} description={`确定删除角色「${selectedRole?.name}」吗?此操作无法撤销。`} onConfirm={handleDelete} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+133
-457
@@ -1,71 +1,20 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Users, Key } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, Users, Key, MoreHorizontal, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { PageHeader, SearchBar, DataTable, Pagination, DeleteDialog } from '@/components/PageUI'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
getUserList,
|
||||
getUserDetail,
|
||||
saveUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getRoleList,
|
||||
grantRole,
|
||||
type SystemUser,
|
||||
type SystemRole,
|
||||
type GetUserListParams,
|
||||
} from '@/api/system'
|
||||
import { getUserList, getUserDetail, saveUser, updateUser, deleteUser, getRoleList, grantRole, type SystemUser, type SystemRole, type GetUserListParams } from '@/api/system'
|
||||
|
||||
interface UserFormData {
|
||||
id?: string
|
||||
account: string
|
||||
name: string
|
||||
nickName: string
|
||||
phone: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
const defaultFormData: UserFormData = {
|
||||
account: '',
|
||||
name: '',
|
||||
nickName: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
}
|
||||
interface UserForm { id?: string; account: string; name: string; nickName: string; phone: string; password?: string }
|
||||
const defaultForm: UserForm = { account: '', name: '', nickName: '', phone: '', password: '' }
|
||||
|
||||
export default function UsersPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
@@ -73,17 +22,13 @@ export default function UsersPage() {
|
||||
const [roles, setRoles] = useState<SystemRole[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState<GetUserListParams>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
})
|
||||
const [sp, setSp] = useState<GetUserListParams>({ current: 1, pageSize: 10, keyword: '' })
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<SystemUser | null>(null)
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([])
|
||||
const [formData, setFormData] = useState<UserFormData>(defaultFormData)
|
||||
const [form, setForm] = useState<UserForm>(defaultForm)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -91,484 +36,215 @@ export default function UsersPage() {
|
||||
const canDelete = hasPermission('user:delete')
|
||||
const canGrant = hasPermission('user:grant')
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getUserList(sp); setUsers(res.data?.list || []); setTotal(res.data?.total || 0) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { fetchUsers() }, [sp.current, sp.pageSize, sp.keyword])
|
||||
useEffect(() => { getRoleList({ current: 1, pageSize: 100 }).then(r => setRoles(r.data?.list || [])).catch(console.error) }, [])
|
||||
|
||||
const handleAdd = () => { setForm(defaultForm); setIsEdit(false); setDialogOpen(true) }
|
||||
const handleEdit = async (u: SystemUser) => {
|
||||
try {
|
||||
const res = await getUserList(searchParams)
|
||||
setUsers(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const res = await getUserDetail(u.id)
|
||||
const d = res.data
|
||||
setForm({ id: d.id, account: d.account || '', name: d.name || '', nickName: d.nickName || '', phone: d.phone || '' })
|
||||
setIsEdit(true); setDialogOpen(true)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const handleGrantRole = (u: SystemUser) => {
|
||||
setSelectedUser(u); setSelectedRoleIds(u.roles?.map(r => r.id) || []); setRoleDialogOpen(true)
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!form.account || !form.name) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await getRoleList({ current: 1, pageSize: 100 })
|
||||
setRoles(res.data?.list || [])
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error)
|
||||
isEdit && form.id ? await updateUser(form) : await saveUser({ ...form, clientId: 'pc' })
|
||||
setDialogOpen(false); fetchUsers()
|
||||
} catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 只在组件挂载时获取角色列表
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (user: SystemUser) => {
|
||||
try {
|
||||
const res = await getUserDetail(user.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
account: detail.account || '',
|
||||
name: detail.name || '',
|
||||
nickName: detail.nickName || '',
|
||||
phone: detail.phone || '',
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取用户详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分配角色
|
||||
const handleGrantRole = (user: SystemUser) => {
|
||||
setSelectedUser(user)
|
||||
setSelectedRoleIds(user.roles?.map(r => r.id) || [])
|
||||
setRoleDialogOpen(true)
|
||||
}
|
||||
|
||||
// 提交分配角色
|
||||
const handleSubmitGrant = async () => {
|
||||
if (!selectedUser) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds })
|
||||
setRoleDialogOpen(false)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('分配角色失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
try { await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds }); setRoleDialogOpen(false); fetchUsers() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (user: SystemUser) => {
|
||||
setSelectedUser(user)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser) return
|
||||
|
||||
try {
|
||||
await deleteUser([selectedUser.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
try { await deleteUser([selectedUser.id]); setDeleteDialogOpen(false); fetchUsers() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.account || !formData.name) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateUser(formData)
|
||||
} else {
|
||||
await saveUser({ ...formData, clientId: 'pc' })
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('保存用户失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">管理系统用户和权限</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加用户
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<PageHeader
|
||||
icon={<Users className="h-5 w-5 text-white" />}
|
||||
title="用户管理"
|
||||
description="管理后台系统账号与角色分配"
|
||||
accentColor="bg-blue-500/25"
|
||||
actions={canWrite ? (
|
||||
<Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md" onClick={handleAdd}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />新增用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名或手机号..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<SearchBar
|
||||
value={sp.keyword || ''}
|
||||
onChange={v => setSp(p => ({ ...p, keyword: v }))}
|
||||
onSearch={() => setSp(p => ({ ...p, current: 1 }))}
|
||||
placeholder="搜索账号、姓名或手机号..."
|
||||
/>
|
||||
|
||||
<DataTable loading={loading} empty={!loading && users.length === 0} emptyText="暂无用户数据">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableRow className="hover:bg-transparent bg-muted/30">
|
||||
<TableHead className="pl-5">用户</TableHead>
|
||||
<TableHead>账号</TableHead>
|
||||
<TableHead>手机号</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[80px]">操作</TableHead>
|
||||
<TableHead className="pr-5 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
{users.map(user => (
|
||||
<TableRow key={user.id} className="hover:bg-muted/20">
|
||||
<TableCell className="pl-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar?.url} alt={user.name} />
|
||||
<AvatarFallback>{(user.name || user.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
<Avatar className="h-8 w-8 ring-1 ring-border/50">
|
||||
<AvatarImage src={user.avatar?.url} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
|
||||
{(user.name || user.account)?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{user.name || user.nickName}</p>
|
||||
<p className="text-sm font-medium">{user.name || user.nickName}</p>
|
||||
{user.nickName && user.name !== user.nickName && (
|
||||
<p className="text-xs text-muted-foreground">{user.nickName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{user.account}</TableCell>
|
||||
<TableCell>{user.phone || '-'}</TableCell>
|
||||
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{user.account}</code></TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{user.phone || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles?.length ? (
|
||||
user.roles.map(role => (
|
||||
<Badge key={role.id} variant="secondary">{role.name}</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">未分配</span>
|
||||
)}
|
||||
{user.roles?.length
|
||||
? user.roles.map(r => <Badge key={r.id} variant="secondary" className="text-xs">{r.name}</Badge>)
|
||||
: <span className="text-xs text-muted-foreground">未分配</span>
|
||||
}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.createdAtStr || user.createdAt}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{user.createdAtStr || user.createdAt}</TableCell>
|
||||
<TableCell className="pr-5 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canWrite && (
|
||||
<DropdownMenuItem onClick={() => handleEdit(user)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canGrant && (
|
||||
<DropdownMenuItem onClick={() => handleGrantRole(user)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
分配角色
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteConfirm(user)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
{canWrite && <DropdownMenuItem onClick={() => handleEdit(user)}><Pencil className="mr-2 h-3.5 w-3.5" />编辑</DropdownMenuItem>}
|
||||
{canGrant && <DropdownMenuItem onClick={() => handleGrantRole(user)}><Key className="mr-2 h-3.5 w-3.5" />分配角色</DropdownMenuItem>}
|
||||
{canDelete && <DropdownMenuItem className="text-destructive" onClick={() => { setSelectedUser(user); setDeleteDialogOpen(true) }}><Trash2 className="mr-2 h-3.5 w-3.5" />删除</DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<Pagination total={total} current={sp.current} pageSize={sp.pageSize} onChange={p => setSp(s => ({ ...s, current: p }))} onPageSizeChange={s => setSp(p => ({ ...p, pageSize: s, current: 1 }))} />
|
||||
</DataTable>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{totalPages > 0 && Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{totalPages === 0 && (
|
||||
<Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条/页</SelectItem>
|
||||
<SelectItem value="20">20 条/页</SelectItem>
|
||||
<SelectItem value="50">50 条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
{/* 新增/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-md rounded-2xl p-0 gap-0">
|
||||
<div className="bg-gradient-to-br from-slate-900 to-slate-800 text-white px-6 py-5 rounded-t-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑用户' : '添加用户'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改用户信息' : '创建新的系统用户'}
|
||||
</DialogDescription>
|
||||
<DialogTitle className="text-white text-base flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 rounded-lg bg-white/10 flex items-center justify-center"><Users className="h-3.5 w-3.5" /></div>
|
||||
{isEdit ? '编辑用户' : '新增用户'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/50 text-xs">{isEdit ? '修改用户信息' : '创建新的后台系统用户'}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>账号 *</Label>
|
||||
<Input
|
||||
value={formData.account}
|
||||
onChange={e => setFormData({ ...formData, account: e.target.value })}
|
||||
placeholder="登录账号"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>姓名 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="用户姓名"
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">账号 *</Label>
|
||||
<Input className="h-8 text-sm" value={form.account} onChange={e => setForm({ ...form, account: e.target.value })} placeholder="登录账号" disabled={isEdit} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">姓名 *</Label>
|
||||
<Input className="h-8 text-sm" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="用户姓名" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>昵称</Label>
|
||||
<Input
|
||||
value={formData.nickName}
|
||||
onChange={e => setFormData({ ...formData, nickName: e.target.value })}
|
||||
placeholder="用户昵称"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">昵称</Label>
|
||||
<Input className="h-8 text-sm" value={form.nickName} onChange={e => setForm({ ...form, nickName: e.target.value })} placeholder="用户昵称" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder="手机号码"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">手机号</Label>
|
||||
<Input className="h-8 text-sm" value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} placeholder="手机号码" />
|
||||
</div>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="初始密码"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">初始密码</Label>
|
||||
<Input className="h-8 text-sm" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder="初始密码" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
<DialogFooter className="px-6 pb-5 pt-0">
|
||||
<Button variant="outline" className="h-8" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button className="h-8 min-w-20" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分配角色对话框 */}
|
||||
{/* 分配角色 Dialog */}
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-sm rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>分配角色</DialogTitle>
|
||||
<DialogDescription>
|
||||
为用户 "{selectedUser?.name || selectedUser?.account}" 分配角色
|
||||
</DialogDescription>
|
||||
<DialogTitle className="flex items-center gap-2 text-base"><Key className="h-4 w-4 text-primary" />分配角色</DialogTitle>
|
||||
<DialogDescription className="text-xs">为用户「{selectedUser?.name || selectedUser?.account}」分配角色</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto rounded-xl border border-border/50 p-3">
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted">
|
||||
<label key={role.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-muted/40 cursor-pointer">
|
||||
<Checkbox
|
||||
id={`role-${role.id}`}
|
||||
id={`r-${role.id}`}
|
||||
checked={selectedRoleIds.includes(role.id)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setSelectedRoleIds([...selectedRoleIds, role.id])
|
||||
} else {
|
||||
setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
|
||||
onCheckedChange={checked => checked
|
||||
? setSelectedRoleIds([...selectedRoleIds, role.id])
|
||||
: setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`role-${role.id}`} className="flex-1 cursor-pointer">
|
||||
<p className="font-medium">{role.name}</p>
|
||||
{role.code && (
|
||||
<p className="text-xs text-muted-foreground">{role.code}</p>
|
||||
)}
|
||||
</Label>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{role.name}</p>
|
||||
{role.code && <p className="text-xs text-muted-foreground">{role.code}</p>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmitGrant} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '确定'}
|
||||
<Button variant="outline" className="h-8" onClick={() => setRoleDialogOpen(false)}>取消</Button>
|
||||
<Button className="h-8" onClick={handleSubmitGrant} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '确定'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除用户 "{selectedUser?.name || selectedUser?.account}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description={`确定删除用户「${selectedUser?.name || selectedUser?.account}」吗?此操作无法撤销。`}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user