feat: 百科知识库同步向量接入

This commit is contained in:
Blizzard
2026-04-21 17:33:44 +08:00
parent 7252738ef0
commit 82593281c5
12 changed files with 1733 additions and 2258 deletions
+52
View File
@@ -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
View File
@@ -1,3 +1,4 @@
// 系统相关 API // 系统相关 API
export * from './system' export * from './system'
// AI 配置相关 API
export * from './ai'
+11
View File
@@ -97,6 +97,7 @@ export interface Wiki {
// 其他 // 其他
difficulty?: number // 1-5级 difficulty?: number // 1-5级
isHot?: number // 0否 1是 isHot?: number // 0否 1是
isVectorSynced?: number // 0否 1是
relatedWikiIds?: string[] relatedWikiIds?: string[]
relatedWikis?: Wiki[] relatedWikis?: Wiki[]
@@ -174,3 +175,13 @@ export function uploadWikiImg(data: { id: string; ossIds: string[] }) {
export function deleteWiki(ids: string[]) { export function deleteWiki(ids: string[]) {
return post<{ msg: string }>('/wiki/delete', { ids }) 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 })
}
+202
View File
@@ -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>
)
}
+3
View File
@@ -20,6 +20,7 @@ import {
Search, Search,
Bell, Bell,
ChevronLeft, ChevronLeft,
Bot,
} from 'lucide-react' } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
@@ -64,6 +65,8 @@ const iconMap: Record<string, React.ReactNode> = {
'file-text': <FileText className="h-4 w-4" />, 'file-text': <FileText className="h-4 w-4" />,
'folder-tree': <FolderTree className="h-4 w-4" />, 'folder-tree': <FolderTree className="h-4 w-4" />,
'folder': <Folder 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 { function getIcon(iconName?: string): React.ReactNode {
+90 -177
View File
@@ -1,44 +1,12 @@
import {Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight} from 'lucide-react' import { Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight, Activity } from 'lucide-react'
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData' import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData'
import { PageHeader } from '@/components/PageUI'
const stats = [ const stats = [
{ { title: '用户总数', value: mockUsers.length, icon: Users, change: '+12%', pos: true, from: 'from-blue-500/25', iconBg: 'bg-blue-500 text-white' },
title: '用户总数', { title: '话题数量', value: mockTopics.length, icon: MessageSquare, change: '+8%', pos: true, from: 'from-violet-500/25', iconBg: 'bg-violet-500 text-white' },
value: mockUsers.length, { title: '百科分类', value: mockCategories.length, icon: FolderTree, change: '0%', pos: false, from: 'from-amber-500/25', iconBg: 'bg-amber-500 text-white' },
icon: Users, { title: '植物百科', value: mockPlants.length, icon: Leaf, change: '+25%', pos: true, from: 'from-emerald-500/25', iconBg: 'bg-emerald-500 text-white' },
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',
},
] ]
const recentActivities = [ const recentActivities = [
@@ -49,184 +17,129 @@ const recentActivities = [
] ]
export default function DashboardPage() { export default function DashboardPage() {
return ( const now = new Date().toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
<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>
{/* Stats Grid */} return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="space-y-5 animate-fadeIn">
{stats.map((stat, index) => ( <PageHeader
<Card key={index} icon={<Activity className="h-5 w-5 text-white" />}
className="relative overflow-hidden border-0 shadow-sm hover:shadow-md transition-shadow duration-300"> title="欢迎回来 👋"
<div className={`absolute inset-0 bg-gradient-to-br ${stat.color} opacity-60`}/> description={now}
<CardHeader className="relative flex flex-row items-center justify-between pb-2"> accentColor="bg-emerald-500/20"
<CardTitle className="text-sm font-medium text-muted-foreground"> actions={
{stat.title} <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">
</CardTitle> <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
<stat.icon className="h-4 w-4"/>
</div> </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"> {/* Stats */}
<span <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium ${ {stats.map((s, i) => (
stat.changeType === 'positive' <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">
? 'bg-emerald-500/10 text-emerald-600' <div className={`absolute inset-0 bg-gradient-to-br ${s.from} to-transparent opacity-40 pointer-events-none`} />
: 'bg-muted text-muted-foreground' // 移除了对 'negative' 的处理 <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" />
{stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3"/>} </div>
{stat.change} {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>
<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> </div>
</CardContent>
</Card>
))} ))}
</div> </div>
{/* Content Grid */} {/* Content */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-5 lg:grid-cols-2">
{/* Recent Topics */} {/* Recent Topics */}
<Card className="border-0 shadow-sm"> <div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
<CardHeader className="pb-4"> <div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
<div className="flex items-center justify-between"> <div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
<div>
<CardTitle className="text-base font-semibold flex items-center gap-2">
<div className="rounded-lg bg-primary/10 p-1.5">
<MessageSquare className="h-4 w-4 text-primary" /> <MessageSquare className="h-4 w-4 text-primary" />
</div> </div>
<span className="font-semibold text-sm"></span>
</CardTitle> <span className="ml-auto text-xs text-muted-foreground"></span>
<CardDescription className="mt-1"></CardDescription>
</div> </div>
</div> <div className="divide-y divide-border/30">
</CardHeader> {mockTopics.slice(0, 4).map(topic => (
<CardContent className="pt-0"> <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">
<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"
>
{topic.coverImage && ( {topic.coverImage && (
<img <img src={topic.coverImage} alt={topic.title} className="h-10 w-10 rounded-lg object-cover shrink-0 ring-1 ring-border/40" />
src={topic.coverImage}
alt={topic.title}
className="h-14 w-14 rounded-lg object-cover ring-1 ring-border/50"
/>
)} )}
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0">
<h4 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors">{topic.title}</h4> <p className="text-sm font-medium truncate group-hover:text-primary transition-colors">{topic.title}</p>
<p className="text-xs text-muted-foreground line-clamp-1"> <div className="flex items-center gap-2.5 mt-0.5">
{topic.content} <span className="flex items-center gap-1 text-xs text-muted-foreground">
</p> <Eye className="h-3 w-3" />{topic.viewCount}
<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}
</span> </span>
<span className="font-medium">{topic.authorName}</span> <span className="text-xs text-muted-foreground">{topic.authorName}</span>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</CardContent> </div>
</Card>
{/* Recent Activities */} {/* Activities */}
<Card className="border-0 shadow-sm"> <div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
<CardHeader className="pb-4"> <div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
<div className="flex items-center justify-between"> <div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
<div>
<CardTitle className="text-base font-semibold flex items-center gap-2">
<div className="rounded-lg bg-primary/10 p-1.5">
<TrendingUp className="h-4 w-4 text-primary" /> <TrendingUp className="h-4 w-4 text-primary" />
</div> </div>
<span className="font-semibold text-sm"></span>
</CardTitle> <span className="ml-auto text-xs text-muted-foreground"></span>
<CardDescription className="mt-1"></CardDescription>
</div> </div>
</div> <div className="divide-y divide-border/30">
</CardHeader> {recentActivities.map((a, i) => (
<CardContent className="pt-0"> <div key={i} className="flex items-center gap-3.5 px-5 py-3.5 hover:bg-muted/30 transition-colors">
<div className="space-y-1"> <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">
{recentActivities.map((activity, index) => ( <span className="text-xs font-bold text-primary">{a.user.charAt(0).toUpperCase()}</span>
<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> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm truncate"> <p className="text-sm">
<span className="font-medium">{activity.user}</span>{' '} <span className="font-medium">{a.user}</span>
<span className="text-muted-foreground">{activity.action}</span>{' '} <span className="text-muted-foreground"> {a.action} </span>
<span className="font-medium text-primary">{activity.target}</span> <span className="font-medium text-primary">{a.target}</span>
</p> </p>
<p className="text-xs text-muted-foreground">{activity.time}</p> <p className="text-xs text-muted-foreground">{a.time}</p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</CardContent> </div>
</Card>
</div> </div>
{/* Quick Stats */} {/* Plant categories */}
<Card className="border-0 shadow-sm"> <div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
<CardHeader className="pb-4"> <div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
<CardTitle className="text-base font-semibold flex items-center gap-2"> <div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
<div className="rounded-lg bg-primary/10 p-1.5">
<Leaf className="h-4 w-4 text-primary" /> <Leaf className="h-4 w-4 text-primary" />
</div> </div>
<span className="font-semibold text-sm"></span>
</CardTitle> <span className="ml-auto text-xs text-muted-foreground"></span>
<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 || '🌱'}
</div> </div>
<div className="flex-1 min-w-0"> <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
<h4 className="font-medium text-sm group-hover:text-primary transition-colors">{category.name}</h4> {mockCategories.map(cat => (
<p className="text-xs text-muted-foreground mt-0.5"> <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">
{category.children?.length || 0} <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">
</p> {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> </div>
))} ))}
</div> </div>
</CardContent> </div>
</Card>
</div> </div>
) )
} }
+73 -126
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react' 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 { Plus, Search, Pencil, Trash2, Leaf, Sun, Droplets, Star, Eye, X } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -29,7 +30,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
getWikiPage, getWikiPage,
@@ -42,6 +42,8 @@ import {
type WikiPageParams, type WikiPageParams,
uploadWikiImg, uploadWikiImg,
deleteWiki, deleteWiki,
syncWikiQdrant,
deleteWikiQdrant,
} from '@/api/wiki' } from '@/api/wiki'
import { uploadFile, type SystemOss } from '@/api/system' 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) => { const handleDeleteConfirm = (plant: Wiki) => {
setSelectedPlant(plant) setSelectedPlant(plant)
@@ -407,81 +430,57 @@ export default function PlantsPage() {
) )
} }
const totalPages = Math.ceil(total / searchParams.pageSize)
return ( return (
<div className="space-y-6"> <div className="space-y-4 animate-fadeIn">
{/* 页面标题 */} <PageHeader
<div className="flex items-center justify-between"> icon={<Leaf className="h-5 w-5 text-white" />}
<div> title="植物百科"
<h1 className="text-3xl font-bold tracking-tight"></h1> description="管理植物百科数据与养护信息"
<p className="text-muted-foreground"></p> accentColor="bg-emerald-500/25"
</div> actions={
<div className="flex gap-2"> <div className="flex gap-2.5 items-center">
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<Button variant="destructive" onClick={handleBatchDelete}> <Button size="sm" variant="outline"
<Trash2 className="mr-2 h-4 w-4" /> className="h-9 bg-white/10 border-white/20 text-white hover:bg-red-500/30 hover:border-red-400/40"
({selectedIds.length}) onClick={handleBatchDelete}>
<Trash2 className="mr-1.5 h-4 w-4" /> ({selectedIds.length})
</Button> </Button>
)} )}
<Button onClick={handleAdd}> <Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md" onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-1.5 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> }
/>
{/* 错误提示 */}
{error && ( {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} {error}
</div> </div>
)} )}
{/* 搜索和过滤 */} <div className="flex items-center gap-3 p-4 rounded-2xl border border-border/60 bg-card shadow-soft">
<Card> <div className="flex-1 relative">
<CardContent className="pt-6"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<div className="flex gap-4 items-end"> <Input className="pl-9 h-9 bg-muted/40 border-transparent focus:bg-background focus:border-primary/30 rounded-lg"
<div className="flex-1 max-w-sm">
<Input
placeholder="搜索植物名称..." placeholder="搜索植物名称..."
value={searchParams.name} value={searchParams.name}
onChange={e => setSearchParams({ ...searchParams, name: e.target.value })} onChange={e => setSearchParams({ ...searchParams, name: e.target.value })}
onKeyDown={e => e.key === 'Enter' && handleSearch()} onKeyDown={e => e.key === 'Enter' && handleSearch()}
/> />
</div> </div>
<Tabs <Tabs value={String(searchParams.isHot ?? 'all')} onValueChange={v => setSearchParams({ ...searchParams, isHot: v === 'all' ? undefined : Number(v), current: 1 })}>
value={String(searchParams.isHot ?? 'all')} <TabsList className="h-9">
onValueChange={v => setSearchParams({ <TabsTrigger value="all" className="text-xs"></TabsTrigger>
...searchParams, <TabsTrigger value="1" className="text-xs">🔥 </TabsTrigger>
isHot: v === 'all' ? undefined : Number(v), <TabsTrigger value="0" className="text-xs"></TabsTrigger>
current: 1
})}
>
<TabsList>
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="1"></TabsTrigger>
<TabsTrigger value="0"></TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button onClick={handleSearch}> <Button size="sm" className="h-9 px-4 shrink-0" onClick={handleSearch}></Button>
<Search className="mr-2 h-4 w-4" />
</Button>
</div> </div>
</CardContent>
</Card>
{/* 植物列表 */} <DataTable loading={loading} empty={!loading && plants.length === 0} emptyText="暂无植物数据,点击新增">
<Card> <div>
<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>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <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 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>/</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead> <TableHead className="w-[120px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -580,13 +579,24 @@ export default function PlantsPage() {
</div> </div>
</TableCell> </TableCell>
<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)} {renderDifficulty(plant.difficulty || 1)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{plant.isHot === 1 ? ( {plant.isVectorSynced === 1 ? (
<Badge className="bg-orange-500"></Badge> <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>
<TableCell> <TableCell>
@@ -613,74 +623,11 @@ export default function PlantsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{/* 分页 */}
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{total}
</div> </div>
<div className="flex items-center gap-2"> <Pagination total={total} current={searchParams.current} pageSize={searchParams.pageSize}
<Button onChange={p => setSearchParams(s => ({ ...s, current: p }))}
variant="outline" onPageSizeChange={s => setSearchParams(p => ({ ...p, pageSize: s, current: 1 }))} />
size="sm" </DataTable>
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}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+609
View File
@@ -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 DeepSeekQwenOllama
</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
View File
@@ -1,493 +1,220 @@
import { useState, useEffect, useRef } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import { PageHeader, SearchBar, Pagination } from '@/components/PageUI'
getFileList, import { getFileList, uploadFile, deleteFile, type SystemOss, type GetOssFileListParams } from '@/api/system'
uploadFile, import { DeleteDialog } from '@/components/PageUI'
deleteFile,
type SystemOss,
type GetOssFileListParams,
} from '@/api/system'
// 根据文件后缀获取图标 function getIcon(suffix?: string) {
function getFileIcon(suffix?: string) { const e = suffix?.toLowerCase() || ''
const ext = suffix?.toLowerCase() if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(e)) return <Image className="h-5 w-5" />
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext || '')) { if (['mp4', 'avi', 'mov', 'mkv'].includes(e)) return <Film className="h-5 w-5" />
return <Image className="h-6 w-6" /> 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 (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext || '')) { if (['doc', 'docx', 'pdf', 'txt', 'md', 'xls', 'xlsx'].includes(e)) return <FileText className="h-5 w-5" />
return <Film className="h-6 w-6" /> return <File className="h-5 w-5" />
}
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" />
} }
const isImg = (s?: string) => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(s?.toLowerCase() || '')
// 判断是否是图片 const colorMap: Record<string, string> = {
function isImage(suffix?: string) { jpg: 'bg-pink-500/10 text-pink-600', jpeg: 'bg-pink-500/10 text-pink-600',
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(suffix?.toLowerCase() || '') 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() { export default function FilesPage() {
const [files, setFiles] = useState<SystemOss[]>([]) const [files, setFiles] = useState<SystemOss[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [searchParams, setSearchParams] = useState<GetOssFileListParams>({ const [sp, setSp] = useState<GetOssFileListParams>({ current: 1, pageSize: 20, keyword: '' })
current: 1,
pageSize: 20,
keyword: '',
})
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedFile, setSelectedFile] = useState<SystemOss | null>(null) 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 [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const fileInputRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
// 获取文件列表
const fetchFiles = async () => { const fetchFiles = async () => {
setLoading(true) setLoading(true)
try { try { const res = await getFileList(sp); setFiles(res.data?.list || []); setTotal(res.data?.total || 0) }
const res = await getFileList(searchParams) catch (e) { console.error(e) } finally { setLoading(false) }
setFiles(res.data?.list || [])
setTotal(res.data?.total || 0)
} catch (error) {
console.error('获取文件列表失败:', error)
} 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 handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]; if (!file) return
if (!file) return
setUploading(true) setUploading(true)
try { try { await uploadFile(file); fetchFiles() }
await uploadFile(file) catch (err) { console.error(err) } finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
fetchFiles()
} catch (error) {
console.error('上传文件失败:', error)
} finally {
setUploading(false)
if (fileInputRef.current) {
fileInputRef.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 () => { const handleDelete = async () => {
if (!selectedFile) return if (!selectedFile) return
try { await deleteFile([selectedFile.id]); setDeleteDialogOpen(false); fetchFiles() }
try { catch (e) { console.error(e) }
await deleteFile([selectedFile.id])
setDeleteDialogOpen(false)
setSelectedFile(null)
fetchFiles()
} catch (error) {
console.error('删除文件失败:', error)
}
} }
return ( const FileMenu = ({ f }: { f: SystemOss }) => (
<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">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-8 w-8"> <Button variant="secondary" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => handleCopyUrl(file.url)}> <DropdownMenuItem onClick={() => handleCopy(f.url)}><Copy className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>
<Copy className="mr-2 h-4 w-4" /> <DropdownMenuItem onClick={() => handleDownload(f)}><Download className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => { setSelectedFile(f); setDeleteDialogOpen(true) }}>
</DropdownMenuItem> <Trash2 className="mr-2 h-3.5 w-3.5" />
<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" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 ( return (
<Button <div className="space-y-4 animate-fadeIn">
key={page} <PageHeader
variant={searchParams.current === page ? 'default' : 'outline'} icon={<File className="h-5 w-5 text-white" />}
size="sm" title="文件管理"
className="w-8 h-8 p-0" description="管理系统上传的所有图片与文件资源"
onClick={() => setSearchParams({ ...searchParams, current: page })} accentColor="bg-blue-500/20"
> actions={
{page} <>
<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> </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"> <SearchBar
<div className="mx-auto mb-4">{getFileIcon(selectedFile?.suffix)}</div> value={sp.keyword || ''}
<p></p> onChange={v => setSp(p => ({ ...p, keyword: v }))}
</div> onSearch={() => setSp(p => ({ ...p, current: 1 }))}
)} placeholder="搜索文件名..."
</div> extra={
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex border border-border/60 rounded-lg overflow-hidden shrink-0">
<div className="flex items-center gap-4"> <Button variant={viewMode === 'grid' ? 'default' : 'ghost'} size="sm" className="h-9 rounded-none px-3" onClick={() => setViewMode('grid')}>
<span>{selectedFile?.suffix?.toUpperCase()}</span> <LayoutGrid className="h-4 w-4" />
{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" />
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => selectedFile && handleDownload(selectedFile)}> <Button variant={viewMode === 'list' ? 'default' : 'ghost'} size="sm" className="h-9 rounded-none px-3" onClick={() => setViewMode('list')}>
<Download className="mr-2 h-4 w-4" /> <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> </Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 删除确认对话框 */} <DeleteDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> description={`确定删除文件「${selectedFile?.name}」吗?此操作不可撤销。`}
<DialogContent> onConfirm={handleDelete} />
<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>
</div> </div>
) )
} }
+134 -403
View File
@@ -1,183 +1,80 @@
import { useState, useEffect } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' 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 { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
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 { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import { PageHeader, DeleteDialog } from '@/components/PageUI'
getAllMenuTree, import { getAllMenuTree, getMenuDetail, saveMenu, updateMenu, deleteMenu, type SystemMenu } from '@/api/system'
getMenuDetail,
saveMenu,
updateMenu,
deleteMenu,
type SystemMenu
} from '@/api/system'
// 图标选项
const iconOptions = [ const iconOptions = [
{ value: 'dashboard', label: '仪表盘' }, { value: 'dashboard', label: '仪表盘' }, { value: 'settings', label: '设置' },
{ value: 'settings', label: '设置' }, { value: 'users', label: '用户' }, { value: 'shield', label: '盾牌' },
{ value: 'users', label: '用户' }, { value: 'menu', label: '菜单' }, { value: 'folder', label: '文件夹' },
{ value: 'shield', label: '盾牌' }, { value: 'folder-tree', label: '目录树' }, { value: 'leaf', label: '叶子' },
{ value: 'menu', label: '菜单' }, { value: 'message', label: '消息' }, { value: 'book', label: '书籍' },
{ value: 'folder', label: '文件夹' }, { value: 'file-text', label: '文档' }, { value: 'hash', label: '标签' },
{ value: 'folder-tree', label: '目录树' }, { value: 'monitor', label: '监控' }, { value: 'home', label: '首页' },
{ value: 'leaf', label: '叶子' }, { value: 'bot', label: 'AI机器人' },
{ value: 'message', label: '消息' },
{ value: 'book', label: '书籍' },
{ value: 'file-text', label: '文档' },
{ value: 'hash', label: '标签' },
{ value: 'monitor', label: '监控' },
{ value: 'home', label: '首页' },
] ]
interface MenuFormData { interface MenuForm {
id?: string id?: string; parentId: string; category: number; name: string
parentId: string title: string; code: string; permission: string; locale: string; icon: string; sort: number
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 = { function MenuNode({ menu, level = 0, onEdit, onDelete, onAddChild }: {
parentId: '0', menu: SystemMenu; level?: number
category: 1, onEdit: (m: SystemMenu) => void; onDelete: (id: string) => void; onAddChild: (pid: string) => void
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
}) { }) {
const [expanded, setExpanded] = useState(level < 2) const [open, setOpen] = useState(level < 2)
const hasChildren = menu.children && menu.children.length > 0 const hasChildren = !!menu.children?.length
return ( return (
<div> <div>
<div <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')}>
className={cn( <button onClick={() => setOpen(!open)} className={cn('p-0.5 rounded text-muted-foreground', !hasChildren && 'invisible')}>
'flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-muted/50 group', {open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
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" />
)}
</button> </button>
<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')}>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary"> <FolderTree className="h-3.5 w-3.5" />
<FolderTree className="h-4 w-4" />
</div> </div>
{/* 名称和信息 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium truncate">{menu.title || menu.name}</span> <span className="text-sm font-medium truncate">{menu.title || menu.name}</span>
<Badge variant={menu.category === 1 ? 'default' : 'secondary'} className="text-xs"> <Badge variant={menu.category === 1 ? 'default' : 'secondary'} className="text-[10px] h-4 px-1.5 shrink-0">
{menu.category === 1 ? '菜单' : '按钮'} {menu.category === 1 ? '菜单' : '按钮'}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-[11px] text-muted-foreground mt-0.5">
{menu.code && <span>: {menu.code}</span>} {menu.code && <span>: <code className="font-mono">{menu.code}</code></span>}
{menu.permission && <span>: {menu.permission}</span>} {menu.permission && <span>: <code className="font-mono">{menu.permission}</code></span>}
<span className="text-muted-foreground/50">: {menu.sort}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{/* 排序 */}
<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">
{menu.category === 1 && ( {menu.category === 1 && (
<Button <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAddChild(menu.id)}>
variant="ghost" <Plus className="h-3.5 w-3.5" />
size="icon"
className="h-8 w-8"
onClick={() => onAddChild(menu.id)}
>
<Plus className="h-4 w-4" />
</Button> </Button>
)} )}
<Button <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(menu)}>
variant="ghost" <Pencil className="h-3.5 w-3.5" />
size="icon"
className="h-8 w-8"
onClick={() => onEdit(menu)}
>
<Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => onDelete(menu.id)}>
variant="ghost" <Trash2 className="h-3.5 w-3.5" />
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete(menu.id)}
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
{open && hasChildren && (
{/* 子菜单 */} <div className="ml-5 border-l border-border/40 pl-2 mt-0.5 space-y-0.5">
{expanded && hasChildren && (
<div className="border-l border-border ml-6">
{menu.children!.map(child => ( {menu.children!.map(child => (
<MenuTreeNode <MenuNode key={child.id} menu={child} level={level + 1} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
key={child.id}
menu={child}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))} ))}
</div> </div>
)} )}
@@ -191,329 +88,163 @@ export default function MenusPage() {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedMenuId, setSelectedMenuId] = useState<string | null>(null) 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 [isEdit, setIsEdit] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
// 获取所有菜单树
const fetchMenus = async () => { const fetchMenus = async () => {
setLoading(true) setLoading(true)
try { try { const res = await getAllMenuTree({ category: 1, parentId: '0' }); setMenus(res.data || []) }
const res = await getAllMenuTree({ category: 1, parentId: '0' }) catch (e) { console.error(e) } finally { setLoading(false) }
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
} }
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 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) => { const handleEdit = async (menu: SystemMenu) => {
try { try {
const res = await getMenuDetail(menu.id) const res = await getMenuDetail(menu.id)
const detail = res.data const d = res.data
setFormData({ 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 })
id: detail.id, setIsEdit(true); setDialogOpen(true)
parentId: detail.parentId || '0', } catch (e) { console.error(e) }
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 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 () => { const handleSubmit = async () => {
if (!formData.name || !formData.title) { if (!form.name || !form.title) return
return
}
setSubmitting(true) setSubmitting(true)
try { try {
if (isEdit && formData.id) { isEdit && form.id ? await updateMenu(form) : await saveMenu(form)
await updateMenu(formData) setDialogOpen(false); fetchMenus()
} else { } catch (e) { console.error(e) } finally { setSubmitting(false) }
await saveMenu(formData)
}
setDialogOpen(false)
fetchMenus()
} catch (error) {
console.error('保存菜单失败:', error)
} finally {
setSubmitting(false)
} }
const handleDelete = async () => {
if (!selectedMenuId) return
try { await deleteMenu(selectedMenuId); setDeleteDialogOpen(false); fetchMenus() }
catch (e) { console.error(e) }
} }
return ( return (
<div className="space-y-6"> <div className="space-y-4 animate-fadeIn">
{/* 页面标题 */} <PageHeader
<div className="flex items-center justify-between"> icon={<FolderTree className="h-5 w-5 text-white" />}
<div> title="菜单管理"
<h1 className="text-3xl font-bold tracking-tight"></h1> description="管理系统导航菜单与权限节点"
<p className="text-muted-foreground"></p> accentColor="bg-amber-500/25"
</div> actions={
<Button onClick={handleAdd}> <Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md"
<Plus className="mr-2 h-4 w-4" /> onClick={() => { setForm(defaultForm); setIsEdit(false); setDialogOpen(true) }}>
<Plus className="mr-1.5 h-4 w-4" />
</Button> </Button>
</div> }
/>
{/* 菜单树 */} <div className="rounded-2xl border border-border/60 bg-card shadow-soft p-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderTree className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-16"><Loader2 className="h-7 w-7 animate-spin text-primary/50" /></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : menus.length === 0 ? ( ) : 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>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-0.5">
{menus.map(menu => ( {menus.map(menu => (
<MenuTreeNode <MenuNode key={menu.id} menu={menu}
key={menu.id}
menu={menu}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDeleteConfirm} onDelete={id => { setSelectedMenuId(id); setDeleteDialogOpen(true) }}
onAddChild={handleAddChild} onAddChild={pid => { setForm({ ...defaultForm, parentId: pid }); setIsEdit(false); setDialogOpen(true) }}
/> />
))} ))}
</div> </div>
)} )}
</CardContent> </div>
</Card>
{/* 新增/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <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> <DialogHeader>
<DialogTitle>{isEdit ? '编辑菜单' : '新增菜单'}</DialogTitle> <DialogTitle className="text-white text-base flex items-center gap-2.5">
<DialogDescription> <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 ? '修改菜单信息' : '添加新的菜单或按钮权限'} {isEdit ? '编辑菜单' : '新增菜单'}
</DialogDescription> </DialogTitle>
<DialogDescription className="text-white/50 text-xs"></DialogDescription>
</DialogHeader> </DialogHeader>
</div>
<div className="grid gap-4 py-4"> <div className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"></Label>
<Select <Select value={form.parentId} onValueChange={v => setForm({ ...form, parentId: v })}>
value={formData.parentId} <SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择父级" /></SelectTrigger>
onValueChange={v => setFormData({ ...formData, parentId: v })}
>
<SelectTrigger>
<SelectValue placeholder="选择父级" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0"></SelectItem> <SelectItem value="0"></SelectItem>
{flatMenus.map(({ menu, level }) => ( {flatMenus.map(({ menu, level }) => (
<SelectItem key={menu.id} value={menu.id}> <SelectItem key={menu.id} value={menu.id}>{' '.repeat(level)}{menu.title || menu.name}</SelectItem>
{' '.repeat(level)}{menu.title || menu.name}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"></Label>
<Select <Select value={String(form.category)} onValueChange={v => setForm({ ...form, category: Number(v) })}>
value={String(formData.category)} <SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
onValueChange={v => setFormData({ ...formData, category: Number(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1"></SelectItem> <SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem> <SelectItem value="2"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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>
<div className="space-y-1.5">
<div className="grid grid-cols-2 gap-4"> <Label className="text-xs text-muted-foreground"> *</Label>
<div className="space-y-2"> <Input className="h-8 text-sm" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="显示名称" />
<Label> *</Label>
<Input
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="英文标识"
/>
</div> </div>
<div className="space-y-2"> {form.category === 1 && (
<Label> *</Label>
<Input
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="显示名称"
/>
</div>
</div>
{formData.category === 1 && (
<> <>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-1.5">
<div className="space-y-2"> <Label className="text-xs text-muted-foreground"></Label>
<Label></Label> <Input className="h-8 text-sm" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="/path" />
<Input
value={formData.code}
onChange={e => setFormData({ ...formData, code: e.target.value })}
placeholder="/path"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"></Label>
<Select <Select value={form.icon} onValueChange={v => setForm({ ...form, icon: v })}>
value={formData.icon} <SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择图标" /></SelectTrigger>
onValueChange={v => setFormData({ ...formData, icon: v })} <SelectContent>{iconOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}</SelectContent>
>
<SelectTrigger>
<SelectValue placeholder="选择图标" />
</SelectTrigger>
<SelectContent>
{iconOptions.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div>
</> </>
)} )}
<div className="space-y-1.5">
<div className="grid grid-cols-2 gap-4"> <Label className="text-xs text-muted-foreground"></Label>
<div className="space-y-2"> <Input className="h-8 text-sm" value={form.permission} onChange={e => setForm({ ...form, permission: e.target.value })} placeholder="module:action" />
<Label></Label>
<Input
value={formData.permission}
onChange={e => setFormData({ ...formData, permission: e.target.value })}
placeholder="module:action"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"></Label>
<Input <Input className="h-8 text-sm" type="number" value={form.sort} onChange={e => setForm({ ...form, sort: Number(e.target.value) })} />
type="number"
value={formData.sort}
onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })}
/>
</div> </div>
</div> </div>
<div className="space-y-1.5">
<div className="space-y-2"> <Label className="text-xs text-muted-foreground"></Label>
<Label></Label> <Input className="h-8 text-sm" value={form.locale} onChange={e => setForm({ ...form, locale: e.target.value })} placeholder="menu.xxx" />
<Input
value={formData.locale}
onChange={e => setFormData({ ...formData, locale: e.target.value })}
placeholder="menu.xxx"
/>
</div> </div>
</div> </div>
<DialogFooter className="px-5 pb-5 pt-0">
<DialogFooter> <Button variant="outline" className="h-8" onClick={() => setDialogOpen(false)}></Button>
<Button variant="outline" onClick={() => setDialogOpen(false)}> <Button className="h-8 min-w-20" onClick={handleSubmit} disabled={submitting}>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 删除确认对话框 */} <DeleteDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> description="删除菜单将同时删除其所有子菜单和权限配置,此操作不可撤销。"
<DialogContent> onConfirm={handleDelete} />
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+126 -523
View File
@@ -1,137 +1,44 @@
import { useState, useEffect } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
Table, import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
TableBody, import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
TableCell, import { PageHeader, SearchBar, DataTable, Pagination, DeleteDialog } from '@/components/PageUI'
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 { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { import { getRoleList, getRoleDetail, saveRole, updateRole, deleteRole, grantMenu, getAllMenuTree, type SystemRole, type SystemMenu, type GetRoleListParams } from '@/api/system'
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[] { function getAllMenuIds(menus: SystemMenu[]): string[] {
const ids: string[] = [] return menus.flatMap(m => [m.id, ...(m.children ? getAllMenuIds(m.children) : [])])
for (const menu of menus) {
ids.push(menu.id)
if (menu.children) {
ids.push(...getAllMenuIds(menu.children))
}
}
return ids
} }
// 菜单树选择组件 function MenuTree({ menus, selectedIds, onToggle, level = 0 }: {
function MenuTreeSelect({ menus: SystemMenu[]; selectedIds: string[]
menus, onToggle: (id: string, children?: SystemMenu[]) => void; level?: number
selectedIds,
onToggle,
level = 0,
}: {
menus: SystemMenu[]
selectedIds: string[]
onToggle: (id: string, children?: SystemMenu[]) => void
level?: number
}) { }) {
return ( 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 => { {menus.map(menu => {
const hasChildren = menu.children && menu.children.length > 0 const hasChildren = !!menu.children?.length
const isChecked = selectedIds.includes(menu.id) const checked = 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))
return ( return (
<div key={menu.id} className="py-1"> <div key={menu.id} className="py-0.5">
<div className="flex items-center gap-2 py-1 hover:bg-muted/50 rounded px-2"> <label className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-muted/40 cursor-pointer">
<Checkbox <Checkbox
id={`menu-${menu.id}`} checked={checked}
checked={isChecked}
ref={(el) => {
if (el && hasChildren) {
(el as HTMLButtonElement & { indeterminate?: boolean }).indeterminate =
someChildrenSelected && !allChildrenSelected
}
}}
onCheckedChange={() => onToggle(menu.id, menu.children)} onCheckedChange={() => onToggle(menu.id, menu.children)}
/> />
<Label <span className="text-sm flex-1">{menu.title || menu.name}</span>
htmlFor={`menu-${menu.id}`} {menu.permission && <span className="text-[10px] text-muted-foreground">({menu.permission})</span>}
className="flex-1 cursor-pointer text-sm" <Badge variant={menu.category === 1 ? 'outline' : 'secondary'} className="text-[10px] h-4 px-1">
>
<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">
{menu.category === 1 ? '菜单' : '按钮'} {menu.category === 1 ? '菜单' : '按钮'}
</Badge> </Badge>
</div> </label>
{hasChildren && ( {hasChildren && <MenuTree menus={menu.children!} selectedIds={selectedIds} onToggle={onToggle} level={level + 1} />}
<MenuTreeSelect
menus={menu.children!}
selectedIds={selectedIds}
onToggle={onToggle}
level={level + 1}
/>
)}
</div> </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() { export default function RolesPage() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
const [roles, setRoles] = useState<SystemRole[]>([]) const [roles, setRoles] = useState<SystemRole[]>([])
const [menus, setMenus] = useState<SystemMenu[]>([]) const [menus, setMenus] = useState<SystemMenu[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [searchParams, setSearchParams] = useState<GetRoleListParams>({ const [sp, setSp] = useState<GetRoleListParams>({ current: 1, pageSize: 10, keyword: '' })
current: 1,
pageSize: 10,
keyword: '',
})
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [menuDialogOpen, setMenuDialogOpen] = useState(false) const [menuDialogOpen, setMenuDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedRole, setSelectedRole] = useState<SystemRole | null>(null) const [selectedRole, setSelectedRole] = useState<SystemRole | null>(null)
const [selectedMenuIds, setSelectedMenuIds] = useState<string[]>([]) const [selectedMenuIds, setSelectedMenuIds] = useState<string[]>([])
const [formData, setFormData] = useState<RoleFormData>(defaultFormData) const [form, setForm] = useState<RoleForm>(defaultForm)
const [isEdit, setIsEdit] = useState(false) const [isEdit, setIsEdit] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -163,484 +69,181 @@ export default function RolesPage() {
const canDelete = hasPermission('role:delete') const canDelete = hasPermission('role:delete')
const canGrant = hasPermission('role:grant') const canGrant = hasPermission('role:grant')
// 获取角色列表
const fetchRoles = async () => { const fetchRoles = async () => {
setLoading(true) setLoading(true)
try { try { const res = await getRoleList(sp); setRoles(res.data?.list || []); setTotal(res.data?.total || 0) }
const res = await getRoleList(searchParams) catch (e) { console.error(e) } finally { setLoading(false) }
setRoles(res.data?.list || [])
setTotal(res.data?.total || 0)
} catch (error) {
console.error('获取角色列表失败:', error)
} 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) => { const handleEdit = async (role: SystemRole) => {
try { try {
const res = await getRoleDetail(role.id) const res = await getRoleDetail(role.id)
const detail = res.data const d = res.data
setFormData({ setForm({ id: d.id, name: d.name || '', code: d.code || '', sort: d.sort || 0 })
id: detail.id, setIsEdit(true); setDialogOpen(true)
name: detail.name || '', } catch (e) { console.error(e) }
code: detail.code || '',
sort: detail.sort || 0,
})
setIsEdit(true)
setDialogOpen(true)
} catch (error) {
console.error('获取角色详情失败:', error)
} }
}
// 处理授权菜单
const handleGrantMenu = async (role: SystemRole) => { const handleGrantMenu = async (role: SystemRole) => {
setSelectedRole(role) setSelectedRole(role)
// 获取角色已有的菜单权限 try { const res = await getRoleDetail(role.id); setSelectedMenuIds(res.data.menus?.map((m: SystemMenu) => m.id) || []) }
try { catch { setSelectedMenuIds([]) } finally { setMenuDialogOpen(true) }
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)
} }
}
// 切换菜单选择
const handleMenuToggle = (menuId: string, children?: SystemMenu[]) => { const handleMenuToggle = (menuId: string, children?: SystemMenu[]) => {
setSelectedMenuIds(prev => { setSelectedMenuIds(prev => {
const isSelected = prev.includes(menuId) const has = prev.includes(menuId)
let newIds = isSelected let next = has ? prev.filter(id => id !== menuId) : [...prev, menuId]
? prev.filter(id => id !== menuId) if (children?.length) {
: [...prev, menuId]
// 如果有子菜单,同时选中/取消子菜单
if (children && children.length > 0) {
const childIds = getAllMenuIds(children) const childIds = getAllMenuIds(children)
if (isSelected) { next = has ? next.filter(id => !childIds.includes(id)) : [...new Set([...next, ...childIds])]
newIds = newIds.filter(id => !childIds.includes(id))
} else {
newIds = [...new Set([...newIds, ...childIds])]
} }
} return next
return newIds
}) })
} }
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 () => { const handleSubmitGrant = async () => {
if (!selectedRole) return if (!selectedRole) return
setSubmitting(true) setSubmitting(true)
try { try { await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds }); setMenuDialogOpen(false); fetchRoles() }
await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds }) catch (e) { console.error(e) } finally { setSubmitting(false) }
setMenuDialogOpen(false)
fetchRoles()
} catch (error) {
console.error('授权菜单失败:', error)
} finally {
setSubmitting(false)
} }
}
// 处理删除确认
const handleDeleteConfirm = (role: SystemRole) => {
setSelectedRole(role)
setDeleteDialogOpen(true)
}
// 执行删除
const handleDelete = async () => { const handleDelete = async () => {
if (!selectedRole) return if (!selectedRole) return
try { await deleteRole([selectedRole.id]); setDeleteDialogOpen(false); fetchRoles() }
try { catch (e) { console.error(e) }
await deleteRole([selectedRole.id])
setDeleteDialogOpen(false)
setSelectedRole(null)
fetchRoles()
} catch (error) {
console.error('删除角色失败:', error)
} }
}
// 提交表单
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 ( return (
<div className="space-y-6"> <div className="space-y-4 animate-fadeIn">
{/* 页面标题 */} <PageHeader
<div className="flex items-center justify-between"> icon={<Shield className="h-5 w-5 text-white" />}
<div> title="角色管理"
<h1 className="text-3xl font-bold tracking-tight"></h1> description="管理系统角色与菜单权限分配"
<p className="text-muted-foreground"></p> accentColor="bg-violet-500/25"
</div> actions={canWrite ? (
{canWrite && ( <Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md"
<Button onClick={handleAdd}> onClick={() => { setForm(defaultForm); setIsEdit(false); setDialogOpen(true) }}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-1.5 h-4 w-4" />
</Button> </Button>
)} ) : undefined}
</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"
/> />
</div>
</div>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 角色列表 */} <SearchBar value={sp.keyword || ''} onChange={v => setSp(p => ({ ...p, keyword: v }))} onSearch={() => setSp(p => ({ ...p, current: 1 }))} placeholder="搜索角色名称或编码..." />
<Card>
<CardHeader> <DataTable loading={loading} empty={!loading && roles.length === 0} emptyText="暂无角色数据">
<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>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent bg-muted/30">
<TableHead></TableHead> <TableHead className="pl-5"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="pr-5 text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{roles.length === 0 ? ( {roles.map(role => (
<TableRow> <TableRow key={role.id} className="hover:bg-muted/20">
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground"> <TableCell className="pl-5">
<div className="flex items-center gap-3">
</TableCell> <div className="h-8 w-8 rounded-lg bg-violet-500/10 flex items-center justify-center">
</TableRow> <Shield className="h-4 w-4 text-violet-600" />
) : (
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" />
</div> </div>
<span className="font-medium">{role.name}</span> <span className="font-medium text-sm">{role.name}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{role.code}</code></TableCell>
<code className="text-sm bg-muted px-2 py-0.5 rounded"> <TableCell className="text-sm text-muted-foreground">{role.sort}</TableCell>
{role.code} <TableCell className="text-xs text-muted-foreground">{role.createdAtStr || role.createdAt}</TableCell>
</code> <TableCell className="pr-5 text-right">
</TableCell>
<TableCell>{role.sort}</TableCell>
<TableCell className="text-muted-foreground">
{role.createdAtStr || role.createdAt}
</TableCell>
<TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" className="h-7 w-7"><MoreHorizontal className="h-4 w-4" /></Button>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-36">
{canWrite && ( {canWrite && <DropdownMenuItem onClick={() => handleEdit(role)}><Pencil className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>}
<DropdownMenuItem onClick={() => handleEdit(role)}> {canGrant && <DropdownMenuItem onClick={() => handleGrantMenu(role)}><Key className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>}
<Pencil className="mr-2 h-4 w-4" /> {canDelete && <DropdownMenuItem className="text-destructive" onClick={() => { setSelectedRole(role); setDeleteDialogOpen(true) }}><Trash2 className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>}
</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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
)}
</TableBody> </TableBody>
</Table> </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}> <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> <DialogHeader>
<DialogTitle>{isEdit ? '编辑角色' : '添加角色'}</DialogTitle> <DialogTitle className="text-white text-base flex items-center gap-2.5">
<DialogDescription> <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 ? '修改角色信息' : '创建新的角色'} {isEdit ? '编辑角色' : '新增角色'}
</DialogDescription> </DialogTitle>
<DialogDescription className="text-white/50 text-xs">{isEdit ? '修改角色信息' : '创建一个新角色'}</DialogDescription>
</DialogHeader> </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>
<div className="space-y-2"> <div className="p-6 space-y-4">
<Label> *</Label> <div className="space-y-1.5">
<Input <Label className="text-xs text-muted-foreground"> *</Label>
value={formData.code} <Input className="h-8 text-sm" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:管理员、运营" />
onChange={e => setFormData({ ...formData, code: e.target.value })}
placeholder="如:admin、operator"
disabled={isEdit}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"> *</Label>
<Input <Input className="h-8 text-sm" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="如:admin、operator" disabled={isEdit} />
type="number" </div>
value={formData.sort} <div className="space-y-1.5">
onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })} <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> </div>
<DialogFooter> <DialogFooter className="px-6 pb-5 pt-0">
<Button variant="outline" onClick={() => setDialogOpen(false)}> <Button variant="outline" className="h-8" onClick={() => setDialogOpen(false)}></Button>
<Button className="h-8 min-w-20" onClick={handleSubmit} disabled={submitting}>
</Button> {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 授权菜单对话框 */} {/* 授权菜单 */}
<Dialog open={menuDialogOpen} onOpenChange={setMenuDialogOpen}> <Dialog open={menuDialogOpen} onOpenChange={setMenuDialogOpen}>
<DialogContent className="max-w-2xl max-h-[85vh]"> <DialogContent className="max-w-lg rounded-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle className="flex items-center gap-2 text-base"><Key className="h-4 w-4 text-violet-600" /></DialogTitle>
<DialogDescription> <DialogDescription className="text-xs">{selectedRole?.name}访</DialogDescription>
"{selectedRole?.name}"
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4 max-h-[50vh] overflow-y-auto border rounded-lg p-4"> <div className="rounded-xl border border-border/50 max-h-64 overflow-y-auto p-3">
{menus.length === 0 ? ( {menus.length ? <MenuTree menus={menus} selectedIds={selectedMenuIds} onToggle={handleMenuToggle} /> : <p className="text-center text-muted-foreground text-sm py-8"></p>}
<div className="text-center text-muted-foreground py-8">
</div> </div>
) : ( <div className="flex items-center justify-between text-xs text-muted-foreground">
<MenuTreeSelect <span> <strong className="text-foreground">{selectedMenuIds.length}</strong> </span>
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 gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setSelectedMenuIds(getAllMenuIds(menus))}></Button>
variant="outline" <Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setSelectedMenuIds([])}></Button>
size="sm"
onClick={() => setSelectedMenuIds(getAllMenuIds(menus))}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedMenuIds([])}
>
</Button>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setMenuDialogOpen(false)}> <Button variant="outline" className="h-8" onClick={() => setMenuDialogOpen(false)}></Button>
<Button className="h-8" onClick={handleSubmitGrant} disabled={submitting}>
</Button> {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存权限'}
<Button onClick={handleSubmitGrant} disabled={submitting}>
{submitting ? '保存中...' : '确定'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 删除确认对话框 */} <DeleteDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} description={`确定删除角色「${selectedRole?.name}」吗?此操作无法撤销。`} onConfirm={handleDelete} />
<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>
</div> </div>
) )
} }
+133 -457
View File
@@ -1,71 +1,20 @@
import { useState, useEffect } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 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 { 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 { useAuth } from '@/contexts/AuthContext'
import { import { getUserList, getUserDetail, saveUser, updateUser, deleteUser, getRoleList, grantRole, type SystemUser, type SystemRole, type GetUserListParams } from '@/api/system'
getUserList,
getUserDetail,
saveUser,
updateUser,
deleteUser,
getRoleList,
grantRole,
type SystemUser,
type SystemRole,
type GetUserListParams,
} from '@/api/system'
interface UserFormData { interface UserForm { id?: string; account: string; name: string; nickName: string; phone: string; password?: string }
id?: string const defaultForm: UserForm = { account: '', name: '', nickName: '', phone: '', password: '' }
account: string
name: string
nickName: string
phone: string
password?: string
}
const defaultFormData: UserFormData = {
account: '',
name: '',
nickName: '',
phone: '',
password: '',
}
export default function UsersPage() { export default function UsersPage() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
@@ -73,17 +22,13 @@ export default function UsersPage() {
const [roles, setRoles] = useState<SystemRole[]>([]) const [roles, setRoles] = useState<SystemRole[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [searchParams, setSearchParams] = useState<GetUserListParams>({ const [sp, setSp] = useState<GetUserListParams>({ current: 1, pageSize: 10, keyword: '' })
current: 1,
pageSize: 10,
keyword: '',
})
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [roleDialogOpen, setRoleDialogOpen] = useState(false) const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedUser, setSelectedUser] = useState<SystemUser | null>(null) const [selectedUser, setSelectedUser] = useState<SystemUser | null>(null)
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]) const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([])
const [formData, setFormData] = useState<UserFormData>(defaultFormData) const [form, setForm] = useState<UserForm>(defaultForm)
const [isEdit, setIsEdit] = useState(false) const [isEdit, setIsEdit] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -91,484 +36,215 @@ export default function UsersPage() {
const canDelete = hasPermission('user:delete') const canDelete = hasPermission('user:delete')
const canGrant = hasPermission('user:grant') const canGrant = hasPermission('user:grant')
// 获取用户列表
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true) 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 { try {
const res = await getUserList(searchParams) const res = await getUserDetail(u.id)
setUsers(res.data?.list || []) const d = res.data
setTotal(res.data?.total || 0) setForm({ id: d.id, account: d.account || '', name: d.name || '', nickName: d.nickName || '', phone: d.phone || '' })
} catch (error) { setIsEdit(true); setDialogOpen(true)
console.error('获取用户列表失败:', error) } catch (e) { console.error(e) }
} finally {
setLoading(false)
} }
const handleGrantRole = (u: SystemUser) => {
setSelectedUser(u); setSelectedRoleIds(u.roles?.map(r => r.id) || []); setRoleDialogOpen(true)
} }
const handleSubmit = async () => {
// 获取角色列表 if (!form.account || !form.name) return
const fetchRoles = async () => { setSubmitting(true)
try { try {
const res = await getRoleList({ current: 1, pageSize: 100 }) isEdit && form.id ? await updateUser(form) : await saveUser({ ...form, clientId: 'pc' })
setRoles(res.data?.list || []) setDialogOpen(false); fetchUsers()
} catch (error) { } catch (e) { console.error(e) } finally { setSubmitting(false) }
console.error('获取角色列表失败:', error)
} }
}
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 () => { const handleSubmitGrant = async () => {
if (!selectedUser) return if (!selectedUser) return
setSubmitting(true) setSubmitting(true)
try { try { await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds }); setRoleDialogOpen(false); fetchUsers() }
await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds }) catch (e) { console.error(e) } finally { setSubmitting(false) }
setRoleDialogOpen(false)
fetchUsers()
} catch (error) {
console.error('分配角色失败:', error)
} finally {
setSubmitting(false)
} }
}
// 处理删除确认
const handleDeleteConfirm = (user: SystemUser) => {
setSelectedUser(user)
setDeleteDialogOpen(true)
}
// 执行删除
const handleDelete = async () => { const handleDelete = async () => {
if (!selectedUser) return if (!selectedUser) return
try { await deleteUser([selectedUser.id]); setDeleteDialogOpen(false); fetchUsers() }
try { catch (e) { console.error(e) }
await deleteUser([selectedUser.id])
setDeleteDialogOpen(false)
setSelectedUser(null)
fetchUsers()
} catch (error) {
console.error('删除用户失败:', error)
} }
}
// 提交表单
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 ( return (
<div className="space-y-6"> <div className="space-y-4 animate-fadeIn">
{/* 页面标题 */} <PageHeader
<div className="flex items-center justify-between"> icon={<Users className="h-5 w-5 text-white" />}
<div> title="用户管理"
<h1 className="text-3xl font-bold tracking-tight"></h1> description="管理后台系统账号与角色分配"
<p className="text-muted-foreground"></p> accentColor="bg-blue-500/25"
</div> actions={canWrite ? (
{canWrite && ( <Button size="sm" className="h-9 bg-white text-slate-900 font-semibold hover:bg-white/90 shadow-md" onClick={handleAdd}>
<Button onClick={handleAdd}> <Plus className="mr-1.5 h-4 w-4" />
<Plus className="mr-2 h-4 w-4" />
</Button> </Button>
)} ) : undefined}
</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"
/> />
</div>
</div>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 用户列表 */} <SearchBar
<Card> value={sp.keyword || ''}
<CardHeader> onChange={v => setSp(p => ({ ...p, keyword: v }))}
<CardTitle className="flex items-center gap-2"> onSearch={() => setSp(p => ({ ...p, current: 1 }))}
<Users className="h-5 w-5" /> placeholder="搜索账号、姓名或手机号..."
/>
<Badge variant="secondary" className="ml-2">{total}</Badge>
</CardTitle> <DataTable loading={loading} empty={!loading && users.length === 0} emptyText="暂无用户数据">
</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>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent bg-muted/30">
<TableHead></TableHead> <TableHead className="pl-5"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="pr-5 text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.length === 0 ? ( {users.map(user => (
<TableRow> <TableRow key={user.id} className="hover:bg-muted/20">
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground"> <TableCell className="pl-5">
</TableCell>
</TableRow>
) : (
users.map(user => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8 ring-1 ring-border/50">
<AvatarImage src={user.avatar?.url} alt={user.name} /> <AvatarImage src={user.avatar?.url} />
<AvatarFallback>{(user.name || user.account)?.charAt(0).toUpperCase()}</AvatarFallback> <AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
{(user.name || user.account)?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
<div> <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 && ( {user.nickName && user.name !== user.nickName && (
<p className="text-xs text-muted-foreground">{user.nickName}</p> <p className="text-xs text-muted-foreground">{user.nickName}</p>
)} )}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="font-mono text-sm">{user.account}</TableCell> <TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{user.account}</code></TableCell>
<TableCell>{user.phone || '-'}</TableCell> <TableCell className="text-sm text-muted-foreground">{user.phone || ''}</TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{user.roles?.length ? ( {user.roles?.length
user.roles.map(role => ( ? user.roles.map(r => <Badge key={r.id} variant="secondary" className="text-xs">{r.name}</Badge>)
<Badge key={role.id} variant="secondary">{role.name}</Badge> : <span className="text-xs text-muted-foreground"></span>
)) }
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">{user.createdAtStr || user.createdAt}</TableCell>
{user.createdAtStr || user.createdAt} <TableCell className="pr-5 text-right">
</TableCell>
<TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" className="h-7 w-7"><MoreHorizontal className="h-4 w-4" /></Button>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-36">
{canWrite && ( {canWrite && <DropdownMenuItem onClick={() => handleEdit(user)}><Pencil className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>}
<DropdownMenuItem onClick={() => handleEdit(user)}> {canGrant && <DropdownMenuItem onClick={() => handleGrantRole(user)}><Key className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>}
<Pencil className="mr-2 h-4 w-4" /> {canDelete && <DropdownMenuItem className="text-destructive" onClick={() => { setSelectedUser(user); setDeleteDialogOpen(true) }}><Trash2 className="mr-2 h-3.5 w-3.5" /></DropdownMenuItem>}
</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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
)}
</TableBody> </TableBody>
</Table> </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>
{/* 分页 */} {/* 新增/编辑 Dialog */}
<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}> <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> <DialogHeader>
<DialogTitle>{isEdit ? '编辑用户' : '添加用户'}</DialogTitle> <DialogTitle className="text-white text-base flex items-center gap-2.5">
<DialogDescription> <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 ? '修改用户信息' : '创建新的系统用户'} {isEdit ? '编辑用户' : '新增用户'}
</DialogDescription> </DialogTitle>
<DialogDescription className="text-white/50 text-xs">{isEdit ? '修改用户信息' : '创建新的后台系统用户'}</DialogDescription>
</DialogHeader> </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>
<div className="space-y-2"> <div className="p-6 space-y-4">
<Label> *</Label> <div className="grid grid-cols-2 gap-3">
<Input <div className="space-y-1.5">
value={formData.name} <Label className="text-xs text-muted-foreground"> *</Label>
onChange={e => setFormData({ ...formData, name: e.target.value })} <Input className="h-8 text-sm" value={form.account} onChange={e => setForm({ ...form, account: e.target.value })} placeholder="登录账号" disabled={isEdit} />
placeholder="用户姓名"
/>
</div> </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>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-1.5">
<div className="space-y-2"> <Label className="text-xs text-muted-foreground"></Label>
<Label></Label> <Input className="h-8 text-sm" value={form.nickName} onChange={e => setForm({ ...form, nickName: e.target.value })} placeholder="用户昵称" />
<Input
value={formData.nickName}
onChange={e => setFormData({ ...formData, nickName: e.target.value })}
placeholder="用户昵称"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"></Label>
<Input <Input className="h-8 text-sm" value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} placeholder="手机号码" />
value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
placeholder="手机号码"
/>
</div> </div>
</div> </div>
{!isEdit && ( {!isEdit && (
<div className="space-y-2"> <div className="space-y-1.5">
<Label></Label> <Label className="text-xs text-muted-foreground"></Label>
<Input <Input className="h-8 text-sm" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder="初始密码" />
type="password"
value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })}
placeholder="初始密码"
/>
</div> </div>
)} )}
</div> </div>
<DialogFooter> <DialogFooter className="px-6 pb-5 pt-0">
<Button variant="outline" onClick={() => setDialogOpen(false)}> <Button variant="outline" className="h-8" onClick={() => setDialogOpen(false)}></Button>
<Button className="h-8 min-w-20" onClick={handleSubmit} disabled={submitting}>
</Button> {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 分配角色对话框 */} {/* 分配角色 Dialog */}
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}> <Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent> <DialogContent className="max-w-sm rounded-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle className="flex items-center gap-2 text-base"><Key className="h-4 w-4 text-primary" /></DialogTitle>
<DialogDescription> <DialogDescription className="text-xs">{selectedUser?.name || selectedUser?.account}</DialogDescription>
"{selectedUser?.name || selectedUser?.account}"
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="space-y-1.5 max-h-56 overflow-y-auto rounded-xl border border-border/50 p-3">
<div className="space-y-3 max-h-64 overflow-y-auto">
{roles.map(role => ( {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 <Checkbox
id={`role-${role.id}`} id={`r-${role.id}`}
checked={selectedRoleIds.includes(role.id)} checked={selectedRoleIds.includes(role.id)}
onCheckedChange={checked => { onCheckedChange={checked => checked
if (checked) { ? setSelectedRoleIds([...selectedRoleIds, role.id])
setSelectedRoleIds([...selectedRoleIds, role.id]) : setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
} else {
setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
} }
}}
/> />
<Label htmlFor={`role-${role.id}`} className="flex-1 cursor-pointer"> <div>
<p className="font-medium">{role.name}</p> <p className="text-sm font-medium">{role.name}</p>
{role.code && ( {role.code && <p className="text-xs text-muted-foreground">{role.code}</p>}
<p className="text-xs text-muted-foreground">{role.code}</p>
)}
</Label>
</div> </div>
</label>
))} ))}
</div> </div>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}> <Button variant="outline" className="h-8" onClick={() => setRoleDialogOpen(false)}></Button>
<Button className="h-8" onClick={handleSubmitGrant} disabled={submitting}>
</Button> {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '确定'}
<Button onClick={handleSubmitGrant} disabled={submitting}>
{submitting ? '保存中...' : '确定'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 删除确认对话框 */} <DeleteDialog
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> open={deleteDialogOpen}
<DialogContent> onOpenChange={setDeleteDialogOpen}
<DialogHeader> description={`确定删除用户「${selectedUser?.name || selectedUser?.account}」吗?此操作无法撤销。`}
<DialogTitle></DialogTitle> onConfirm={handleDelete}
<DialogDescription> />
"{selectedUser?.name || selectedUser?.account}"
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }