diff --git a/src/api/ai.ts b/src/api/ai.ts new file mode 100644 index 0000000..d8972ba --- /dev/null +++ b/src/api/ai.ts @@ -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) { + 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') +} diff --git a/src/api/index.ts b/src/api/index.ts index af35795..cb0efdc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,3 +1,4 @@ // 系统相关 API export * from './system' - +// AI 配置相关 API +export * from './ai' diff --git a/src/api/wiki.ts b/src/api/wiki.ts index 6b3d9af..99b0436 100644 --- a/src/api/wiki.ts +++ b/src/api/wiki.ts @@ -97,6 +97,7 @@ export interface Wiki { // 其他 difficulty?: number // 1-5级 isHot?: number // 0否 1是 + isVectorSynced?: number // 0否 1是 relatedWikiIds?: string[] relatedWikis?: Wiki[] @@ -174,3 +175,13 @@ export function uploadWikiImg(data: { id: string; ossIds: string[] }) { export function deleteWiki(ids: string[]) { return post<{ msg: string }>('/wiki/delete', { ids }) } + +// 同步点位到 Qdrant +export function syncWikiQdrant(id: string) { + return post<{ msg: string }>('/wiki/sync-qdrant', { id }) +} + +// 从 Qdrant 删除点位 +export function deleteWikiQdrant(id: string) { + return post<{ msg: string }>('/wiki/delete-qdrant', { id }) +} diff --git a/src/components/PageUI.tsx b/src/components/PageUI.tsx new file mode 100644 index 0000000..f2f4e6f --- /dev/null +++ b/src/components/PageUI.tsx @@ -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 ( +
+
+
+
+
+
+ {icon} +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ {actions &&
{actions}
} +
+ {footer &&
{footer}
} +
+ ) +} + +// ────────────────────────────────────────────── +// 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 ( +
+
+ + onChange(e.target.value)} + onKeyDown={e => e.key === 'Enter' && onSearch()} + /> +
+ {extra} + +
+ ) +} + +// ────────────────────────────────────────────── +// DataTable (wrapper) +// ────────────────────────────────────────────── +interface DataTableProps { + loading: boolean + empty: boolean + emptyText?: string + children: React.ReactNode +} + +export function DataTable({ loading, empty, emptyText = '暂无数据', children }: DataTableProps) { + return ( +
+ {loading ? ( +
+ +
+ ) : empty ? ( +
+
+ +
+

{emptyText}

+
+ ) : ( + children + )} +
+ ) +} + +// ────────────────────────────────────────────── +// 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 ( +
+

{total}

+
+ + {pages.map(p => ( + + ))} + + +
+
+ ) +} + +// ────────────────────────────────────────────── +// 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 ( + + + +
+ +
+ {title} + {description && ( + {description} + )} +
+ + + + +
+
+ ) +} diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index afc8dab..25a70f0 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -20,6 +20,7 @@ import { Search, Bell, ChevronLeft, + Bot, } from 'lucide-react' import { useState, useMemo, useEffect } from 'react' import { useAuth } from '@/contexts/AuthContext' @@ -64,6 +65,8 @@ const iconMap: Record = { 'file-text': , 'folder-tree': , 'folder': , + 'bot': , + 'ai': , } function getIcon(iconName?: string): React.ReactNode { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 64a7469..96df168 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,232 +1,145 @@ -import {Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight} from 'lucide-react' -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card' -import {mockUsers, mockTopics, mockCategories, mockPlants} from '@/data/mockData' +import { Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight, Activity } from 'lucide-react' +import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData' +import { PageHeader } from '@/components/PageUI' const stats = [ - { - title: '用户总数', - value: mockUsers.length, - icon: Users, - change: '+12%', - changeType: 'positive' as const, - color: 'from-blue-500/20 to-blue-500/5', - iconBg: 'bg-blue-500/10 text-blue-600', - }, - { - title: '话题数量', - value: mockTopics.length, - icon: MessageSquare, - change: '+8%', - changeType: 'positive' as const, - color: 'from-violet-500/20 to-violet-500/5', - iconBg: 'bg-violet-500/10 text-violet-600', - }, - { - title: '百科分类', - value: mockCategories.length, - icon: FolderTree, - change: '0%', - changeType: 'neutral' as const, - color: 'from-amber-500/20 to-amber-500/5', - iconBg: 'bg-amber-500/10 text-amber-600', - }, - { - title: '植物百科', - value: mockPlants.length, - icon: Leaf, - change: '+25%', - changeType: 'positive' as const, - color: 'from-emerald-500/20 to-emerald-500/5', - iconBg: 'bg-emerald-500/10 text-emerald-600', - }, + { title: '用户总数', value: mockUsers.length, icon: Users, change: '+12%', pos: true, from: 'from-blue-500/25', iconBg: 'bg-blue-500 text-white' }, + { title: '话题数量', value: mockTopics.length, icon: MessageSquare, change: '+8%', pos: true, from: 'from-violet-500/25', iconBg: 'bg-violet-500 text-white' }, + { title: '百科分类', value: mockCategories.length, icon: FolderTree, change: '0%', pos: false, from: 'from-amber-500/25', iconBg: 'bg-amber-500 text-white' }, + { title: '植物百科', value: mockPlants.length, icon: Leaf, change: '+25%', pos: true, from: 'from-emerald-500/25', iconBg: 'bg-emerald-500 text-white' }, ] const recentActivities = [ - {action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin'}, - {action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor'}, - {action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin'}, - {action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin'}, + { action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin' }, + { action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor' }, + { action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin' }, + { action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin' }, ] export default function DashboardPage() { - return ( -
- {/* Header */} -
-
-

概览

-

欢迎回来 👋

-
-
- {new Date().toLocaleDateString('zh-CN', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' - })} -
-
+ const now = new Date().toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) - {/* Stats Grid */} -
- {stats.map((stat, index) => ( - -
- - - {stat.title} - -
- + return ( +
+ } + title="欢迎回来 👋" + description={now} + accentColor="bg-emerald-500/20" + actions={ +
+ + 系统运行正常 +
+ } + /> + + {/* Stats */} +
+ {stats.map((s, i) => ( +
+
+
+
+
- - -
{stat.value}
-
- - {stat.changeType === 'positive' && } - {stat.change} - - 较上月 -
-
- + {s.pos && ( + + {s.change} + + )} +
+
+

{s.value}

+

{s.title}

+
+
))}
- {/* Content Grid */} -
+ {/* Content */} +
{/* Recent Topics */} - - -
-
- -
- -
- 最新话题 -
- 最近发布的社区话题 -
+
+
+
+
- - -
- {mockTopics.slice(0, 3).map(topic => ( -
- {topic.coverImage && ( - {topic.title} - )} -
-

{topic.title}

-

- {topic.content} -

-
- - - {topic.viewCount} - - {topic.authorName} -
-
-
- ))} -
-
- - - {/* Recent Activities */} - - -
-
- -
- -
- 最近活动 -
- 系统最近的操作记录 -
-
-
- -
- {recentActivities.map((activity, index) => ( -
-
- - {activity.user.charAt(0).toUpperCase()} - -
-
-

- {activity.user}{' '} - {activity.action}{' '} - {activity.target} -

-

{activity.time}

-
-
- ))} -
-
-
-
- - {/* Quick Stats */} - - - -
- -
- 植物百科概览 -
- 按分类统计的植物数量 -
- -
- {mockCategories.map(category => ( -
-
- {category.icon || '🌱'} -
+ 最新话题 + 最近发布 +
+
+ {mockTopics.slice(0, 4).map(topic => ( +
+ {topic.coverImage && ( + {topic.title} + )}
-

{category.name}

-

- {category.children?.length || 0} 个子分类 -

+

{topic.title}

+
+ + {topic.viewCount} + + {topic.authorName} +
))}
- - +
+ + {/* Activities */} +
+
+
+ +
+ 最近活动 + 操作记录 +
+
+ {recentActivities.map((a, i) => ( +
+
+ {a.user.charAt(0).toUpperCase()} +
+
+

+ {a.user} + {a.action} + {a.target} +

+

{a.time}

+
+
+ ))} +
+
+
+ + {/* Plant categories */} +
+
+
+ +
+ 植物百科概览 + 按分类统计 +
+
+ {mockCategories.map(cat => ( +
+
+ {cat.icon || '🌱'} +
+
+

{cat.name}

+

{cat.children?.length || 0} 个子分类

+
+
+ ))} +
+
) } diff --git a/src/pages/plant/wiki/Wiki.tsx b/src/pages/plant/wiki/Wiki.tsx index ab60adc..ec7bf86 100644 --- a/src/pages/plant/wiki/Wiki.tsx +++ b/src/pages/plant/wiki/Wiki.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { PageHeader, DataTable, Pagination } from '@/components/PageUI' import { Plus, Search, Pencil, Trash2, Leaf, Sun, Droplets, Star, Eye, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -29,7 +30,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { getWikiPage, @@ -42,6 +42,8 @@ import { type WikiPageParams, uploadWikiImg, deleteWiki, + syncWikiQdrant, + deleteWikiQdrant, } from '@/api/wiki' import { uploadFile, type SystemOss } from '@/api/system' @@ -251,6 +253,27 @@ export default function PlantsPage() { } } + // 同步向量到 Qdrant + const handleSyncQdrant = async (plant: Wiki) => { + try { + await syncWikiQdrant(plant.id) + fetchPlants() + } catch (err) { + console.error('同步向量失败:', err) + } + } + + // 从 Qdrant 移除向量 + const handleDeleteQdrant = async (plant: Wiki) => { + try { + await deleteWikiQdrant(plant.id) + fetchPlants() + } catch (err) { + console.error('移除向量失败:', err) + } + } + + // 处理删除确认 const handleDeleteConfirm = (plant: Wiki) => { setSelectedPlant(plant) @@ -407,81 +430,57 @@ export default function PlantsPage() { ) } - const totalPages = Math.ceil(total / searchParams.pageSize) - return ( -
- {/* 页面标题 */} -
-
-

植物百科

-

管理植物百科信息

-
-
- {selectedIds.length > 0 && ( - + )} + - )} - -
-
+
+ } + /> - {/* 错误提示 */} {error && ( -
+
{error}
)} - {/* 搜索和过滤 */} - - -
-
- setSearchParams({ ...searchParams, name: e.target.value })} - onKeyDown={e => e.key === 'Enter' && handleSearch()} - /> -
- setSearchParams({ - ...searchParams, - isHot: v === 'all' ? undefined : Number(v), - current: 1 - })} - > - - 全部 - 热门 - 普通 - - - -
-
-
+
+
+ + setSearchParams({ ...searchParams, name: e.target.value })} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + /> +
+ setSearchParams({ ...searchParams, isHot: v === 'all' ? undefined : Number(v), current: 1 })}> + + 全部 + 🔥 热门 + 普通 + + + +
- {/* 植物列表 */} - - - - - 植物列表 - {total} - - - + +
{loading ? (
@@ -499,8 +498,8 @@ export default function PlantsPage() { 植物信息 分类 特性 - 难度 - 状态 + 难度/状态 + 向量同步 操作 @@ -580,13 +579,24 @@ export default function PlantsPage() {
+ {plant.isHot === 1 ? ( + 热门 + ) : ( + 普通 + )} {renderDifficulty(plant.difficulty || 1)} - {plant.isHot === 1 ? ( - 热门 + {plant.isVectorSynced === 1 ? ( +
+ 已同步 + +
) : ( - 普通 +
+ 未同步 + +
)}
@@ -613,74 +623,11 @@ export default function PlantsPage() { )} - - {/* 分页 */} -
-
- 共 {total} 条记录 -
-
- -
- {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 ( - - ) - })} - {totalPages === 0 && ( - - )} -
- - -
-
- - +
+ setSearchParams(s => ({ ...s, current: p }))} + onPageSizeChange={s => setSearchParams(p => ({ ...p, pageSize: s, current: 1 }))} /> +
{/* 新增/编辑对话框 */} diff --git a/src/pages/system/AiConfig.tsx b/src/pages/system/AiConfig.tsx new file mode 100644 index 0000000..e7043af --- /dev/null +++ b/src/pages/system/AiConfig.tsx @@ -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 = { + 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 ( +
+ {icon} + {label} +
+ ) +} + +function FieldRow({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +// ────────────────────────────────────────────── +// Config Card +// ────────────────────────────────────────────── + +function ConfigCard({ + cfg, + onEdit, + onDelete, + onActivate, +}: { + cfg: SysAiConfig + onEdit: () => void + onDelete: () => void + onActivate: () => void +}) { + const isActive = cfg.isActive === 1 + return ( +
+ {/* Active indicator */} + {isActive && ( +
+ + + + + 激活中 +
+ )} + + {/* Header */} +
+
+ +
+
+

{cfg.chatModelName}

+

{cfg.chatProvider || '未设置供应商'}

+
+
+ + {/* Info grid */} +
+ {/* Chat LLM */} +
+
+ + 对话模型 +
+

{cfg.chatModelName}

+

{cfg.chatApiUrl}

+
+ + {/* Embedding */} +
+
+ + 向量模型 +
+

{cfg.embeddingModelName}

+

{cfg.embeddingProvider}

+
+ + {/* Qdrant */} +
+
+ + 向量库 +
+

{cfg.qdrantCollection}

+

{cfg.vectorDimension} 维

+
+
+ + {/* Qdrant URL */} +
+ + {cfg.qdrantUrl || '未设置 Qdrant 地址'} + {cfg.qdrantUrl && } +
+ + {/* Actions */} +
+ {!isActive && ( + + )} + {isActive && ( +
+ + 当前激活 +
+ )} + + +
+ +

+ {cfg.createdAtStr || cfg.createdAt} +

+
+ ) +} + +// ────────────────────────────────────────────── +// Main Page +// ────────────────────────────────────────────── + +export default function AiConfigPage() { + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(true) + const [syncing, setSyncing] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [selectedConfig, setSelectedConfig] = useState(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 ( +
+ + {/* ── Hero Header ── */} +
+ {/* decorative blobs */} +
+
+ +
+
+
+ +
+
+

AI 大模型配置

+

管理 Qdrant 向量库 · 对话模型 · Embedding 模型

+
+
+ +
+ {/* Stats pill */} +
+ + {configs.length} 条配置 + {activeConfig && ( + <> + + + + {activeConfig.chatModelName} + + + )} +
+ + + + +
+
+ + {/* Sync feedback */} + {syncMsg && ( +
+ {syncMsg.type === 'success' + ? + : + } + {syncMsg.text} +
+ )} +
+ + {/* ── Config Cards Grid ── */} + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : configs.length === 0 ? ( + // Empty state +
+
+ +
+

尚未添加任何 AI 配置

+

配置 Qdrant 向量库和大模型地址后,小程序可智能问答植物百科

+ +
+ ) : ( +
+ {configs.map(cfg => ( + handleEdit(cfg)} + onDelete={() => { setSelectedConfig(cfg); setDeleteDialogOpen(true) }} + onActivate={() => handleActivate(cfg)} + /> + ))} +
+ )} + + {/* ── Create/Edit Dialog ── */} + + + {/* Dialog header with gradient */} +
+ + +
+ +
+ {isEdit ? '编辑 AI 配置' : '新增 AI 配置'} +
+ + 对话模型与 Embedding 模型可来自不同供应商,支持 DeepSeek、Qwen、Ollama 等 + +
+
+ +
+ {/* ── Qdrant Section ── */} +
+ } + label="Qdrant 向量库" + color="text-teal-600" + /> +
+ + f('qdrantUrl', e.target.value)} + placeholder="http://localhost:6333" + className="h-8 text-sm bg-white" + /> + + +
+ + f('qdrantApiKey', e.target.value)} + placeholder="留空" + className="h-8 text-sm pl-8 bg-white" + /> +
+
+ + f('qdrantCollection', e.target.value)} + placeholder="plants" + className="h-8 text-sm bg-white" + /> + + + f('vectorDimension', Number(e.target.value))} + className="h-8 text-sm bg-white" + /> + +
+
+ + {/* ── Chat LLM Section ── */} +
+ } + label="对话大模型" + color="text-blue-600" + /> +
+ + f('chatProvider', e.target.value)} + placeholder="deepseek / qwen / local" + className="h-8 text-sm bg-white" + /> + + + f('chatModelName', e.target.value)} + placeholder="deepseek-chat" + className="h-8 text-sm bg-white" + /> + + + f('chatApiUrl', e.target.value)} + placeholder="https://api.deepseek.com/v1" + className="h-8 text-sm bg-white col-span-2" + /> + + +
+ + f('chatApiKey', e.target.value)} + placeholder="sk-... (本地部署可留空)" + className="h-8 text-sm pl-8 bg-white" + /> +
+
+
+
+ + {/* ── Embedding Section ── */} +
+ + {embExpanded && ( +
+ + f('embeddingProvider', e.target.value)} + placeholder="local / qwen / openai" + className="h-8 text-sm bg-white" + /> + + + f('embeddingModelName', e.target.value)} + placeholder="bge-m3" + className="h-8 text-sm bg-white" + /> + + + f('embeddingApiUrl', e.target.value)} + placeholder="http://localhost:11434/v1" + className="h-8 text-sm bg-white" + /> + + +
+ + f('embeddingApiKey', e.target.value)} + placeholder="留空" + className="h-8 text-sm pl-8 bg-white" + /> +
+
+
+ )} +
+
+ + + + + +
+
+ + {/* ── Delete Dialog ── */} + + + +
+ +
+ 确认删除配置? + + 此操作不可撤销。 + {selectedConfig?.isActive === 1 && ( + + ⚠️ 该配置当前处于激活状态,删除后将无激活配置。 + + )} + +
+ + + + +
+
+
+ ) +} diff --git a/src/pages/system/Files.tsx b/src/pages/system/Files.tsx index bd4416f..dec3775 100644 --- a/src/pages/system/Files.tsx +++ b/src/pages/system/Files.tsx @@ -1,493 +1,220 @@ import { useState, useEffect, useRef } from 'react' -import { Upload, Search, MoreHorizontal, Trash2, Copy, Download, Image, FileText, Film, Music, Archive, File } from 'lucide-react' +import { Upload, MoreHorizontal, Trash2, Copy, Download, Image, FileText, Film, Music, Archive, File, LayoutGrid, List, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Badge } from '@/components/ui/badge' -import { - getFileList, - uploadFile, - deleteFile, - type SystemOss, - type GetOssFileListParams, -} from '@/api/system' +import { PageHeader, SearchBar, Pagination } from '@/components/PageUI' +import { getFileList, uploadFile, deleteFile, type SystemOss, type GetOssFileListParams } from '@/api/system' +import { DeleteDialog } from '@/components/PageUI' -// 根据文件后缀获取图标 -function getFileIcon(suffix?: string) { - const ext = suffix?.toLowerCase() - if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext || '')) { - return - } - if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext || '')) { - return - } - if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext || '')) { - return - } - if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) { - return - } - if (['doc', 'docx', 'pdf', 'txt', 'md', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) { - return - } - return +function getIcon(suffix?: string) { + const e = suffix?.toLowerCase() || '' + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(e)) return + if (['mp4', 'avi', 'mov', 'mkv'].includes(e)) return + if (['mp3', 'wav', 'ogg', 'flac'].includes(e)) return + if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return + if (['doc', 'docx', 'pdf', 'txt', 'md', 'xls', 'xlsx'].includes(e)) return + return } +const isImg = (s?: string) => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(s?.toLowerCase() || '') -// 判断是否是图片 -function isImage(suffix?: string) { - return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(suffix?.toLowerCase() || '') +const colorMap: Record = { + jpg: 'bg-pink-500/10 text-pink-600', jpeg: 'bg-pink-500/10 text-pink-600', + png: 'bg-blue-500/10 text-blue-600', gif: 'bg-violet-500/10 text-violet-600', + webp: 'bg-teal-500/10 text-teal-600', svg: 'bg-orange-500/10 text-orange-600', + mp4: 'bg-red-500/10 text-red-600', pdf: 'bg-red-500/10 text-red-600', } +const getIconBg = (s?: string) => colorMap[s?.toLowerCase() || ''] || 'bg-muted text-muted-foreground' export default function FilesPage() { const [files, setFiles] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [uploading, setUploading] = useState(false) - const [searchParams, setSearchParams] = useState({ - current: 1, - pageSize: 20, - keyword: '', - }) + const [sp, setSp] = useState({ current: 1, pageSize: 20, keyword: '' }) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [selectedFile, setSelectedFile] = useState(null) - const [previewDialogOpen, setPreviewDialogOpen] = useState(false) + const [previewOpen, setPreviewOpen] = useState(false) const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') - const fileInputRef = useRef(null) + const fileRef = useRef(null) - // 获取文件列表 const fetchFiles = async () => { setLoading(true) - try { - const res = await getFileList(searchParams) - setFiles(res.data?.list || []) - setTotal(res.data?.total || 0) - } catch (error) { - console.error('获取文件列表失败:', error) - } finally { - setLoading(false) - } + try { const res = await getFileList(sp); setFiles(res.data?.list || []); setTotal(res.data?.total || 0) } + catch (e) { console.error(e) } finally { setLoading(false) } } + useEffect(() => { fetchFiles() }, [sp.current, sp.pageSize, sp.keyword]) - useEffect(() => { - fetchFiles() - }, [searchParams.current, searchParams.pageSize, searchParams.keyword]) - - // 搜索 - 只更新 searchParams,让 useEffect 触发请求 - const handleSearch = () => { - setSearchParams(prev => ({ ...prev, current: 1 })) - } - - // 上传文件 const handleUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - + const file = e.target.files?.[0]; if (!file) return setUploading(true) - try { - await uploadFile(file) - fetchFiles() - } catch (error) { - console.error('上传文件失败:', error) - } finally { - setUploading(false) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } + try { await uploadFile(file); fetchFiles() } + catch (err) { console.error(err) } finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' } } - - // 复制链接 - const handleCopyUrl = (url: string) => { - navigator.clipboard.writeText(url) - // 可以添加 toast 提示 + 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 handleDownload = (file: SystemOss) => { - const link = document.createElement('a') - link.href = file.url - link.download = file.name - link.target = '_blank' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } - - // 预览文件 - const handlePreview = (file: SystemOss) => { - setSelectedFile(file) - setPreviewDialogOpen(true) - } - - // 处理删除确认 - const handleDeleteConfirm = (file: SystemOss) => { - setSelectedFile(file) - setDeleteDialogOpen(true) - } - - // 执行删除 const handleDelete = async () => { if (!selectedFile) return - - try { - await deleteFile([selectedFile.id]) - setDeleteDialogOpen(false) - setSelectedFile(null) - fetchFiles() - } catch (error) { - console.error('删除文件失败:', error) - } + try { await deleteFile([selectedFile.id]); setDeleteDialogOpen(false); fetchFiles() } + catch (e) { console.error(e) } } + const FileMenu = ({ f }: { f: SystemOss }) => ( + + + + + + handleCopy(f.url)}>复制链接 + handleDownload(f)}>下载 + { setSelectedFile(f); setDeleteDialogOpen(true) }}> + 删除 + + + + ) + return ( -
- {/* 页面标题 */} -
-
-

文件管理

-

管理系统上传的所有文件资源

-
-
- - -
+
+ } + title="文件管理" + description="管理系统上传的所有图片与文件资源" + accentColor="bg-blue-500/20" + actions={ + <> + + + + } + /> + + setSp(p => ({ ...p, keyword: v }))} + onSearch={() => setSp(p => ({ ...p, current: 1 }))} + placeholder="搜索文件名..." + extra={ +
+ + +
+ } + /> + +
+ {loading ? ( +
+ ) : files.length === 0 ? ( +
+
+ +
+

暂无文件,点击右上角上传

+
+ ) : viewMode === 'grid' ? ( +
+ {files.map(f => ( +
+
{ setSelectedFile(f); setPreviewOpen(true) }}> + {isImg(f.suffix) && f.url + ? {f.name} + :
{getIcon(f.suffix)}
+ } +
+
+
+

{f.name}

+ {f.suffix?.toUpperCase()} +
+ +
+
+ ))} +
+ ) : ( +
+ {files.map(f => ( +
+
+ {isImg(f.suffix) && f.url + ? {f.name} { setSelectedFile(f); setPreviewOpen(true) }} /> + : getIcon(f.suffix) + } +
+
+

{f.name}

+
+ {f.suffix?.toUpperCase()} + {f.width && f.height && {f.width}×{f.height}} + {f.createdAtStr || f.createdAt} +
+
+

{f.url}

+
+ + + +
+
+ ))} +
+ )} + setSp(s => ({ ...s, current: p }))} + onPageSizeChange={s => setSp(p => ({ ...p, pageSize: s, current: 1 }))} />
- {/* 搜索和过滤 */} - - -
-
- setSearchParams({ ...searchParams, keyword: e.target.value })} - onKeyDown={e => e.key === 'Enter' && handleSearch()} - /> -
- -
- - -
-
-
-
- - {/* 文件列表 */} - - - - - 文件列表 - {total} - - - - {loading ? ( -
-
-
- ) : files.length === 0 ? ( -
- -

暂无文件,点击上传添加

-
- ) : viewMode === 'grid' ? ( - // 网格视图 -
- {files.map(file => ( -
- {/* 预览区域 */} -
handlePreview(file)} - > - {isImage(file.suffix) && file.url ? ( - {file.name} - ) : ( -
- {getFileIcon(file.suffix)} -
- )} -
- - {/* 文件信息 */} -
-

- {file.name} -

-

- {file.suffix?.toUpperCase()} -

-
- - {/* 操作按钮 */} -
- - - - - - handleCopyUrl(file.url)}> - - 复制链接 - - handleDownload(file)}> - - 下载 - - handleDeleteConfirm(file)} - className="text-destructive" - > - - 删除 - - - -
-
- ))} -
- ) : ( - // 列表视图 -
- {files.map(file => ( -
- {/* 图标/缩略图 */} -
- {isImage(file.suffix) && file.url ? ( - {file.name} handlePreview(file)} - /> - ) : ( -
- {getFileIcon(file.suffix)} -
- )} -
- - {/* 文件信息 */} -
-

{file.name}

-
- {file.suffix?.toUpperCase()} - {file.width && file.height && ( - {file.width} × {file.height} - )} - {file.createdAtStr || file.createdAt} -
-
- - {/* URL */} -
- {file.url} -
- - {/* 操作 */} -
- - - -
-
- ))} -
- )} - - {/* 分页 */} -
-
- 共 {total} 条记录 -
-
- -
- {(() => { - const totalPages = Math.ceil(total / searchParams.pageSize) - if (totalPages === 0) { - return - } - 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 ( - - ) - }) - })()} -
- - -
-
-
-
- - {/* 图片预览对话框 */} - - + {/* 图片预览 */} + + - {selectedFile?.name} + {selectedFile?.name} -
- {selectedFile && isImage(selectedFile.suffix) ? ( - {selectedFile.name} - ) : ( -
-
{getFileIcon(selectedFile?.suffix)}
-

无法预览此文件类型

-
- )} +
+ {selectedFile && isImg(selectedFile.suffix) + ? {selectedFile.name} + :
{getIcon(selectedFile?.suffix)}

无法预览此类型

+ }
-
-
+
+
{selectedFile?.suffix?.toUpperCase()} - {selectedFile?.width && selectedFile?.height && ( - {selectedFile.width} × {selectedFile.height} - )} + {selectedFile?.width && {selectedFile.width}×{selectedFile.height}}
-
- -
- {/* 删除确认对话框 */} - - - - 确认删除 - - 确定要删除文件 "{selectedFile?.name}" 吗?此操作不可撤销。 - - - - - - - - +
) } diff --git a/src/pages/system/Menus.tsx b/src/pages/system/Menus.tsx index cf113e8..95c4038 100644 --- a/src/pages/system/Menus.tsx +++ b/src/pages/system/Menus.tsx @@ -1,183 +1,80 @@ import { useState, useEffect } from 'react' -import { Plus, ChevronRight, ChevronDown, Pencil, Trash2, FolderTree } from 'lucide-react' +import { Plus, ChevronRight, ChevronDown, Pencil, Trash2, FolderTree, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' import { cn } from '@/lib/utils' -import { - getAllMenuTree, - getMenuDetail, - saveMenu, - updateMenu, - deleteMenu, - type SystemMenu -} from '@/api/system' +import { PageHeader, DeleteDialog } from '@/components/PageUI' +import { getAllMenuTree, getMenuDetail, saveMenu, updateMenu, deleteMenu, type SystemMenu } from '@/api/system' -// 图标选项 const iconOptions = [ - { value: 'dashboard', label: '仪表盘' }, - { value: 'settings', label: '设置' }, - { value: 'users', label: '用户' }, - { value: 'shield', label: '盾牌' }, - { value: 'menu', label: '菜单' }, - { value: 'folder', label: '文件夹' }, - { value: 'folder-tree', label: '目录树' }, - { value: 'leaf', label: '叶子' }, - { value: 'message', label: '消息' }, - { value: 'book', label: '书籍' }, - { value: 'file-text', label: '文档' }, - { value: 'hash', label: '标签' }, - { value: 'monitor', label: '监控' }, - { value: 'home', label: '首页' }, + { value: 'dashboard', label: '仪表盘' }, { value: 'settings', label: '设置' }, + { value: 'users', label: '用户' }, { value: 'shield', label: '盾牌' }, + { value: 'menu', label: '菜单' }, { value: 'folder', label: '文件夹' }, + { value: 'folder-tree', label: '目录树' }, { value: 'leaf', label: '叶子' }, + { value: 'message', label: '消息' }, { value: 'book', label: '书籍' }, + { value: 'file-text', label: '文档' }, { value: 'hash', label: '标签' }, + { value: 'monitor', label: '监控' }, { value: 'home', label: '首页' }, + { value: 'bot', label: 'AI机器人' }, ] -interface MenuFormData { - id?: string - parentId: string - category: number - name: string - title: string - code: string - permission: string - locale: string - icon: string - sort: number +interface MenuForm { + id?: string; parentId: string; category: number; name: string + title: string; code: string; permission: string; locale: string; icon: string; sort: number } +const defaultForm: MenuForm = { parentId: '0', category: 1, name: '', title: '', code: '', permission: '', locale: '', icon: '', sort: 0 } -const defaultFormData: MenuFormData = { - parentId: '0', - category: 1, - name: '', - title: '', - code: '', - permission: '', - locale: '', - icon: '', - sort: 0, -} - -// 菜单树节点组件 -function MenuTreeNode({ - menu, - level = 0, - onEdit, - onDelete, - onAddChild, -}: { - menu: SystemMenu - level?: number - onEdit: (menu: SystemMenu) => void - onDelete: (id: string) => void - onAddChild: (parentId: string) => void +function MenuNode({ menu, level = 0, onEdit, onDelete, onAddChild }: { + menu: SystemMenu; level?: number + onEdit: (m: SystemMenu) => void; onDelete: (id: string) => void; onAddChild: (pid: string) => void }) { - const [expanded, setExpanded] = useState(level < 2) - const hasChildren = menu.children && menu.children.length > 0 + const [open, setOpen] = useState(level < 2) + const hasChildren = !!menu.children?.length return (
-
0 && 'ml-6' - )} - > - {/* 展开/折叠按钮 */} - - - {/* 图标 */} -
- +
+
- - {/* 名称和信息 */}
- {menu.title || menu.name} - + {menu.title || menu.name} + {menu.category === 1 ? '菜单' : '按钮'}
-
- {menu.code && 路由: {menu.code}} - {menu.permission && 权限: {menu.permission}} +
+ {menu.code && 路由: {menu.code}} + {menu.permission && 权限: {menu.permission}} + 排序: {menu.sort}
- - {/* 排序 */} - 排序: {menu.sort} - - {/* 操作按钮 */} -
+
{menu.category === 1 && ( - )} - -
- - {/* 子菜单 */} - {expanded && hasChildren && ( -
+ {open && hasChildren && ( +
{menu.children!.map(child => ( - + ))}
)} @@ -191,329 +88,163 @@ export default function MenusPage() { const [dialogOpen, setDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [selectedMenuId, setSelectedMenuId] = useState(null) - const [formData, setFormData] = useState(defaultFormData) + const [form, setForm] = useState(defaultForm) const [isEdit, setIsEdit] = useState(false) const [submitting, setSubmitting] = useState(false) - // 获取所有菜单树 const fetchMenus = async () => { setLoading(true) - try { - const res = await getAllMenuTree({ category: 1, parentId: '0' }) - setMenus(res.data || []) - } catch (error) { - console.error('获取菜单失败:', error) - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchMenus() - }, []) - - // 展平菜单树用于父级选择 - const flattenMenus = (menuList: SystemMenu[], level = 0): { menu: SystemMenu; level: number }[] => { - const result: { menu: SystemMenu; level: number }[] = [] - for (const menu of menuList) { - if (menu.category === 1) { // 只有菜单类型才能作为父级 - result.push({ menu, level }) - if (menu.children) { - result.push(...flattenMenus(menu.children, level + 1)) - } - } - } - return result + try { const res = await getAllMenuTree({ category: 1, parentId: '0' }); setMenus(res.data || []) } + catch (e) { console.error(e) } finally { setLoading(false) } } + useEffect(() => { fetchMenus() }, []) + const flattenMenus = (list: SystemMenu[], lvl = 0): { menu: SystemMenu; level: number }[] => + list.flatMap(m => m.category === 1 ? [{ menu: m, level: lvl }, ...(m.children ? flattenMenus(m.children, lvl + 1) : [])] : []) const flatMenus = flattenMenus(menus) - // 处理新增 - const handleAdd = () => { - setFormData(defaultFormData) - setIsEdit(false) - setDialogOpen(true) - } - - // 处理添加子菜单 - const handleAddChild = (parentId: string) => { - setFormData({ ...defaultFormData, parentId }) - setIsEdit(false) - setDialogOpen(true) - } - - // 处理编辑 const handleEdit = async (menu: SystemMenu) => { try { const res = await getMenuDetail(menu.id) - const detail = res.data - setFormData({ - id: detail.id, - parentId: detail.parentId || '0', - category: detail.category || 1, - name: detail.name || '', - title: detail.title || '', - code: detail.code || '', - permission: detail.permission || '', - locale: detail.locale || '', - icon: detail.icon || '', - sort: detail.sort || 0, - }) - setIsEdit(true) - setDialogOpen(true) - } catch (error) { - console.error('获取菜单详情失败:', error) - } + const d = res.data + setForm({ id: d.id, parentId: d.parentId || '0', category: d.category || 1, name: d.name || '', title: d.title || '', code: d.code || '', permission: d.permission || '', locale: d.locale || '', icon: d.icon || '', sort: d.sort || 0 }) + setIsEdit(true); setDialogOpen(true) + } catch (e) { console.error(e) } } - - // 处理删除确认 - const handleDeleteConfirm = (id: string) => { - setSelectedMenuId(id) - setDeleteDialogOpen(true) - } - - // 执行删除 - const handleDelete = async () => { - if (!selectedMenuId) return - - try { - await deleteMenu(selectedMenuId) - setDeleteDialogOpen(false) - setSelectedMenuId(null) - fetchMenus() - } catch (error) { - console.error('删除菜单失败:', error) - } - } - - // 提交表单 const handleSubmit = async () => { - if (!formData.name || !formData.title) { - return - } - + if (!form.name || !form.title) return setSubmitting(true) try { - if (isEdit && formData.id) { - await updateMenu(formData) - } else { - await saveMenu(formData) - } - setDialogOpen(false) - fetchMenus() - } catch (error) { - console.error('保存菜单失败:', error) - } finally { - setSubmitting(false) - } + isEdit && form.id ? await updateMenu(form) : await saveMenu(form) + setDialogOpen(false); fetchMenus() + } catch (e) { console.error(e) } finally { setSubmitting(false) } + } + const handleDelete = async () => { + if (!selectedMenuId) return + try { await deleteMenu(selectedMenuId); setDeleteDialogOpen(false); fetchMenus() } + catch (e) { console.error(e) } } return ( -
- {/* 页面标题 */} -
-
-

菜单管理

-

管理系统菜单和权限配置

-
- +
+ } + title="菜单管理" + description="管理系统导航菜单与权限节点" + accentColor="bg-amber-500/25" + actions={ + + } + /> + +
+ {loading ? ( +
+ ) : menus.length === 0 ? ( +
+ +

暂无菜单,点击右上角新增

+
+ ) : ( +
+ {menus.map(menu => ( + { setSelectedMenuId(id); setDeleteDialogOpen(true) }} + onAddChild={pid => { setForm({ ...defaultForm, parentId: pid }); setIsEdit(false); setDialogOpen(true) }} + /> + ))} +
+ )}
- {/* 菜单树 */} - - - - - 菜单树 - - - - {loading ? ( -
-
-
- ) : menus.length === 0 ? ( -
- 暂无菜单数据,请添加 -
- ) : ( -
- {menus.map(menu => ( - - ))} -
- )} -
-
- - {/* 新增/编辑对话框 */} - - - {isEdit ? '编辑菜单' : '新增菜单'} - - {isEdit ? '修改菜单信息' : '添加新的菜单或按钮权限'} - - - -
-
-
- - setForm({ ...form, parentId: v })}> + 顶级菜单 {flatMenus.map(({ menu, level }) => ( - - {' '.repeat(level)}{menu.title || menu.name} - + {' '.repeat(level)}{menu.title || menu.name} ))}
-
- - setForm({ ...form, category: Number(v) })}> + 菜单 按钮
-
- -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="英文标识" - /> +
+ + setForm({ ...form, name: e.target.value })} placeholder="英文标识" />
-
- - setFormData({ ...formData, title: e.target.value })} - placeholder="显示名称" - /> +
+ + setForm({ ...form, title: e.target.value })} placeholder="显示名称" />
-
- - {formData.category === 1 && ( - <> -
-
- - setFormData({ ...formData, code: e.target.value })} - placeholder="/path" - /> + {form.category === 1 && ( + <> +
+ + setForm({ ...form, code: e.target.value })} placeholder="/path" />
-
- - setForm({ ...form, icon: v })}> + + {iconOptions.map(o => {o.label})}
-
- - )} - -
-
- - setFormData({ ...formData, permission: e.target.value })} - placeholder="module:action" - /> + + )} +
+ + setForm({ ...form, permission: e.target.value })} placeholder="module:action" />
-
- - setFormData({ ...formData, sort: Number(e.target.value) })} - /> +
+ + setForm({ ...form, sort: Number(e.target.value) })} />
- -
- - setFormData({ ...formData, locale: e.target.value })} - placeholder="menu.xxx" - /> +
+ + setForm({ ...form, locale: e.target.value })} placeholder="menu.xxx" />
- - - - +
- {/* 删除确认对话框 */} - - - - 确认删除 - - 删除菜单将同时删除其所有子菜单和权限配置,此操作不可撤销。 - - - - - - - - +
) } diff --git a/src/pages/system/Roles.tsx b/src/pages/system/Roles.tsx index dba491c..dd53798 100644 --- a/src/pages/system/Roles.tsx +++ b/src/pages/system/Roles.tsx @@ -1,137 +1,44 @@ import { useState, useEffect } from 'react' -import { Plus, Search, MoreHorizontal, Pencil, Trash2, Shield, Key } from 'lucide-react' +import { Plus, Pencil, Trash2, Shield, Key, MoreHorizontal, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { PageHeader, SearchBar, DataTable, Pagination, DeleteDialog } from '@/components/PageUI' import { useAuth } from '@/contexts/AuthContext' -import { - getRoleList, - getRoleDetail, - saveRole, - updateRole, - deleteRole, - grantMenu, - getAllMenuTree, - type SystemRole, - type SystemMenu, - type GetRoleListParams, -} from '@/api/system' +import { getRoleList, getRoleDetail, saveRole, updateRole, deleteRole, grantMenu, getAllMenuTree, type SystemRole, type SystemMenu, type GetRoleListParams } from '@/api/system' -interface RoleFormData { - id?: string - name: string - code: string - sort: number -} - -const defaultFormData: RoleFormData = { - name: '', - code: '', - sort: 0, -} - -// 递归获取所有菜单ID function getAllMenuIds(menus: SystemMenu[]): string[] { - const ids: string[] = [] - for (const menu of menus) { - ids.push(menu.id) - if (menu.children) { - ids.push(...getAllMenuIds(menu.children)) - } - } - return ids + return menus.flatMap(m => [m.id, ...(m.children ? getAllMenuIds(m.children) : [])]) } -// 菜单树选择组件 -function MenuTreeSelect({ - menus, - selectedIds, - onToggle, - level = 0, -}: { - menus: SystemMenu[] - selectedIds: string[] - onToggle: (id: string, children?: SystemMenu[]) => void - level?: number +function MenuTree({ menus, selectedIds, onToggle, level = 0 }: { + menus: SystemMenu[]; selectedIds: string[] + onToggle: (id: string, children?: SystemMenu[]) => void; level?: number }) { return ( -
0 ? 'ml-6 border-l pl-4' : ''}> +
0 ? 'ml-5 border-l border-border/40 pl-3' : ''}> {menus.map(menu => { - const hasChildren = menu.children && menu.children.length > 0 - const isChecked = selectedIds.includes(menu.id) - const childIds = hasChildren ? getAllMenuIds(menu.children!) : [] - const allChildrenSelected = childIds.length > 0 && childIds.every(id => selectedIds.includes(id)) - const someChildrenSelected = childIds.some(id => selectedIds.includes(id)) - + const hasChildren = !!menu.children?.length + const checked = selectedIds.includes(menu.id) return ( -
-
+
+
- {hasChildren && ( - - )} + + {hasChildren && }
) })} @@ -139,23 +46,22 @@ function MenuTreeSelect({ ) } +interface RoleForm { id?: string; name: string; code: string; sort: number } +const defaultForm: RoleForm = { name: '', code: '', sort: 0 } + export default function RolesPage() { const { hasPermission } = useAuth() const [roles, setRoles] = useState([]) const [menus, setMenus] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) - const [searchParams, setSearchParams] = useState({ - current: 1, - pageSize: 10, - keyword: '', - }) + const [sp, setSp] = useState({ current: 1, pageSize: 10, keyword: '' }) const [dialogOpen, setDialogOpen] = useState(false) const [menuDialogOpen, setMenuDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [selectedRole, setSelectedRole] = useState(null) const [selectedMenuIds, setSelectedMenuIds] = useState([]) - const [formData, setFormData] = useState(defaultFormData) + const [form, setForm] = useState(defaultForm) const [isEdit, setIsEdit] = useState(false) const [submitting, setSubmitting] = useState(false) @@ -163,484 +69,181 @@ export default function RolesPage() { const canDelete = hasPermission('role:delete') const canGrant = hasPermission('role:grant') - // 获取角色列表 const fetchRoles = async () => { setLoading(true) - try { - const res = await getRoleList(searchParams) - setRoles(res.data?.list || []) - setTotal(res.data?.total || 0) - } catch (error) { - console.error('获取角色列表失败:', error) - } finally { - setLoading(false) - } + try { const res = await getRoleList(sp); setRoles(res.data?.list || []); setTotal(res.data?.total || 0) } + catch (e) { console.error(e) } finally { setLoading(false) } } + useEffect(() => { fetchRoles() }, [sp.current, sp.pageSize, sp.keyword]) + useEffect(() => { getAllMenuTree({ category: 1, parentId: '0' }).then(r => setMenus(r.data || [])).catch(console.error) }, []) - // 获取菜单树 - const fetchMenus = async () => { - try { - const res = await getAllMenuTree({ category: 1, parentId: '0' }) - setMenus(res.data || []) - } catch (error) { - console.error('获取菜单树失败:', error) - } - } - - useEffect(() => { - fetchRoles() - }, [searchParams.current, searchParams.pageSize, searchParams.keyword]) - - // 只在组件挂载时获取菜单树 - useEffect(() => { - fetchMenus() - }, []) - - // 搜索 - 只更新 searchParams,让 useEffect 触发请求 - const handleSearch = () => { - setSearchParams(prev => ({ ...prev, current: 1 })) - } - - // 处理新增 - const handleAdd = () => { - setFormData(defaultFormData) - setIsEdit(false) - setDialogOpen(true) - } - - // 处理编辑 const handleEdit = async (role: SystemRole) => { try { const res = await getRoleDetail(role.id) - const detail = res.data - setFormData({ - id: detail.id, - name: detail.name || '', - code: detail.code || '', - sort: detail.sort || 0, - }) - setIsEdit(true) - setDialogOpen(true) - } catch (error) { - console.error('获取角色详情失败:', error) - } + const d = res.data + setForm({ id: d.id, name: d.name || '', code: d.code || '', sort: d.sort || 0 }) + setIsEdit(true); setDialogOpen(true) + } catch (e) { console.error(e) } } - - // 处理授权菜单 const handleGrantMenu = async (role: SystemRole) => { setSelectedRole(role) - // 获取角色已有的菜单权限 - try { - const res = await getRoleDetail(role.id) - const menuIds = res.data.menus?.map((m: SystemMenu) => m.id) || [] - setSelectedMenuIds(menuIds) - setMenuDialogOpen(true) - } catch (error) { - console.error('获取角色菜单失败:', error) - setSelectedMenuIds([]) - setMenuDialogOpen(true) - } + try { const res = await getRoleDetail(role.id); setSelectedMenuIds(res.data.menus?.map((m: SystemMenu) => m.id) || []) } + catch { setSelectedMenuIds([]) } finally { setMenuDialogOpen(true) } } - - // 切换菜单选择 const handleMenuToggle = (menuId: string, children?: SystemMenu[]) => { setSelectedMenuIds(prev => { - const isSelected = prev.includes(menuId) - let newIds = isSelected - ? prev.filter(id => id !== menuId) - : [...prev, menuId] - - // 如果有子菜单,同时选中/取消子菜单 - if (children && children.length > 0) { + const has = prev.includes(menuId) + let next = has ? prev.filter(id => id !== menuId) : [...prev, menuId] + if (children?.length) { const childIds = getAllMenuIds(children) - if (isSelected) { - newIds = newIds.filter(id => !childIds.includes(id)) - } else { - newIds = [...new Set([...newIds, ...childIds])] - } + next = has ? next.filter(id => !childIds.includes(id)) : [...new Set([...next, ...childIds])] } - - return newIds + return next }) } - - // 提交授权菜单 + const handleSubmit = async () => { + if (!form.name || !form.code) return + setSubmitting(true) + try { + isEdit && form.id ? await updateRole(form) : await saveRole(form) + setDialogOpen(false); fetchRoles() + } catch (e) { console.error(e) } finally { setSubmitting(false) } + } const handleSubmitGrant = async () => { if (!selectedRole) return - setSubmitting(true) - try { - await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds }) - setMenuDialogOpen(false) - fetchRoles() - } catch (error) { - console.error('授权菜单失败:', error) - } finally { - setSubmitting(false) - } + try { await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds }); setMenuDialogOpen(false); fetchRoles() } + catch (e) { console.error(e) } finally { setSubmitting(false) } } - - // 处理删除确认 - const handleDeleteConfirm = (role: SystemRole) => { - setSelectedRole(role) - setDeleteDialogOpen(true) - } - - // 执行删除 const handleDelete = async () => { if (!selectedRole) return - - try { - await deleteRole([selectedRole.id]) - setDeleteDialogOpen(false) - setSelectedRole(null) - fetchRoles() - } catch (error) { - console.error('删除角色失败:', error) - } + try { await deleteRole([selectedRole.id]); setDeleteDialogOpen(false); fetchRoles() } + catch (e) { console.error(e) } } - // 提交表单 - const handleSubmit = async () => { - if (!formData.name || !formData.code) { - return - } - - setSubmitting(true) - try { - if (isEdit && formData.id) { - await updateRole(formData) - } else { - await saveRole(formData) - } - setDialogOpen(false) - fetchRoles() - } catch (error) { - console.error('保存角色失败:', error) - } finally { - setSubmitting(false) - } - } - - const totalPages = Math.ceil(total / searchParams.pageSize) - return ( -
- {/* 页面标题 */} -
-
-

角色管理

-

管理系统角色和权限分配

-
- {canWrite && ( - - )} -
+ ) : undefined} + /> - {/* 搜索和过滤 */} - - -
-
-
- - setSearchParams({ ...searchParams, keyword: e.target.value })} - onKeyDown={e => e.key === 'Enter' && handleSearch()} - className="pl-9" - /> -
-
- -
-
-
+ setSp(p => ({ ...p, keyword: v }))} onSearch={() => setSp(p => ({ ...p, current: 1 }))} placeholder="搜索角色名称或编码..." /> - {/* 角色列表 */} - - - - - 角色列表 - {total} - - - - {loading ? ( -
-
-
- ) : ( - - - - 角色名称 - 角色编码 - 排序 - 创建时间 - 操作 - - - - {roles.length === 0 ? ( - - - 暂无数据 - - - ) : ( - roles.map(role => ( - - -
-
- -
- {role.name} -
-
- - - {role.code} - - - {role.sort} - - {role.createdAtStr || role.createdAt} - - - - - - - - {canWrite && ( - handleEdit(role)}> - - 编辑 - - )} - {canGrant && ( - handleGrantMenu(role)}> - - 授权菜单 - - )} - {canDelete && ( - handleDeleteConfirm(role)} - > - - 删除 - - )} - - - -
- )) - )} -
-
- )} + + + + + 角色名称 + 角色编码 + 排序 + 创建时间 + 操作 + + + + {roles.map(role => ( + + +
+
+ +
+ {role.name} +
+
+ {role.code} + {role.sort} + {role.createdAtStr || role.createdAt} + + + + + + + {canWrite && handleEdit(role)}>编辑} + {canGrant && handleGrantMenu(role)}>授权菜单} + {canDelete && { setSelectedRole(role); setDeleteDialogOpen(true) }}>删除} + + + +
+ ))} +
+
+ setSp(s => ({ ...s, current: p }))} onPageSizeChange={s => setSp(p => ({ ...p, pageSize: s, current: 1 }))} /> +
- {/* 分页 */} -
-
- 共 {total} 条记录 -
-
- -
- {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 ( - - ) - })} - {totalPages === 0 && ( - - )} -
- - -
-
-
-
- - {/* 新增/编辑对话框 */} + {/* 新增/编辑 */} - - - {isEdit ? '编辑角色' : '添加角色'} - - {isEdit ? '修改角色信息' : '创建新的角色'} - - -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="如:管理员、运营" - /> + +
+ + +
+ {isEdit ? '编辑角色' : '新增角色'} +
+ {isEdit ? '修改角色信息' : '创建一个新角色'} +
+
+
+
+ + setForm({ ...form, name: e.target.value })} placeholder="如:管理员、运营" />
-
- - setFormData({ ...formData, code: e.target.value })} - placeholder="如:admin、operator" - disabled={isEdit} - /> +
+ + setForm({ ...form, code: e.target.value })} placeholder="如:admin、operator" disabled={isEdit} />
-
- - setFormData({ ...formData, sort: Number(e.target.value) })} - /> +
+ + setForm({ ...form, sort: Number(e.target.value) })} />
- - - +
- {/* 授权菜单对话框 */} + {/* 授权菜单 */} - + - 授权菜单 - - 为角色 "{selectedRole?.name}" 分配菜单权限 - + 授权菜单 + 为角色「{selectedRole?.name}」配置可访问菜单 -
- {menus.length === 0 ? ( -
- 暂无菜单数据 -
- ) : ( - - )} +
+ {menus.length ? :

暂无菜单数据

}
-
- 已选择 {selectedMenuIds.length} 个菜单 +
+ 已选 {selectedMenuIds.length}
- - + +
- - +
- {/* 删除确认对话框 */} - - - - 确认删除 - - 确定要删除角色 "{selectedRole?.name}" 吗?此操作无法撤销。 - - - - - - - - +
) } diff --git a/src/pages/system/Users.tsx b/src/pages/system/Users.tsx index 3f000e4..3be389c 100644 --- a/src/pages/system/Users.tsx +++ b/src/pages/system/Users.tsx @@ -1,71 +1,20 @@ import { useState, useEffect } from 'react' -import { Plus, Search, MoreHorizontal, Pencil, Trash2, Users, Key } from 'lucide-react' +import { Plus, Pencil, Trash2, Users, Key, MoreHorizontal, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { PageHeader, SearchBar, DataTable, Pagination, DeleteDialog } from '@/components/PageUI' import { useAuth } from '@/contexts/AuthContext' -import { - getUserList, - getUserDetail, - saveUser, - updateUser, - deleteUser, - getRoleList, - grantRole, - type SystemUser, - type SystemRole, - type GetUserListParams, -} from '@/api/system' +import { getUserList, getUserDetail, saveUser, updateUser, deleteUser, getRoleList, grantRole, type SystemUser, type SystemRole, type GetUserListParams } from '@/api/system' -interface UserFormData { - id?: string - account: string - name: string - nickName: string - phone: string - password?: string -} - -const defaultFormData: UserFormData = { - account: '', - name: '', - nickName: '', - phone: '', - password: '', -} +interface UserForm { id?: string; account: string; name: string; nickName: string; phone: string; password?: string } +const defaultForm: UserForm = { account: '', name: '', nickName: '', phone: '', password: '' } export default function UsersPage() { const { hasPermission } = useAuth() @@ -73,17 +22,13 @@ export default function UsersPage() { const [roles, setRoles] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) - const [searchParams, setSearchParams] = useState({ - current: 1, - pageSize: 10, - keyword: '', - }) + const [sp, setSp] = useState({ current: 1, pageSize: 10, keyword: '' }) const [dialogOpen, setDialogOpen] = useState(false) const [roleDialogOpen, setRoleDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [selectedUser, setSelectedUser] = useState(null) const [selectedRoleIds, setSelectedRoleIds] = useState([]) - const [formData, setFormData] = useState(defaultFormData) + const [form, setForm] = useState(defaultForm) const [isEdit, setIsEdit] = useState(false) const [submitting, setSubmitting] = useState(false) @@ -91,484 +36,215 @@ export default function UsersPage() { const canDelete = hasPermission('user:delete') const canGrant = hasPermission('user:grant') - // 获取用户列表 const fetchUsers = async () => { setLoading(true) + try { const res = await getUserList(sp); setUsers(res.data?.list || []); setTotal(res.data?.total || 0) } + catch (e) { console.error(e) } finally { setLoading(false) } + } + useEffect(() => { fetchUsers() }, [sp.current, sp.pageSize, sp.keyword]) + useEffect(() => { getRoleList({ current: 1, pageSize: 100 }).then(r => setRoles(r.data?.list || [])).catch(console.error) }, []) + + const handleAdd = () => { setForm(defaultForm); setIsEdit(false); setDialogOpen(true) } + const handleEdit = async (u: SystemUser) => { try { - const res = await getUserList(searchParams) - setUsers(res.data?.list || []) - setTotal(res.data?.total || 0) - } catch (error) { - console.error('获取用户列表失败:', error) - } finally { - setLoading(false) - } + const res = await getUserDetail(u.id) + const d = res.data + setForm({ id: d.id, account: d.account || '', name: d.name || '', nickName: d.nickName || '', phone: d.phone || '' }) + setIsEdit(true); setDialogOpen(true) + } catch (e) { console.error(e) } } - - // 获取角色列表 - const fetchRoles = async () => { + const handleGrantRole = (u: SystemUser) => { + setSelectedUser(u); setSelectedRoleIds(u.roles?.map(r => r.id) || []); setRoleDialogOpen(true) + } + const handleSubmit = async () => { + if (!form.account || !form.name) return + setSubmitting(true) try { - const res = await getRoleList({ current: 1, pageSize: 100 }) - setRoles(res.data?.list || []) - } catch (error) { - console.error('获取角色列表失败:', error) - } + isEdit && form.id ? await updateUser(form) : await saveUser({ ...form, clientId: 'pc' }) + setDialogOpen(false); fetchUsers() + } catch (e) { console.error(e) } finally { setSubmitting(false) } } - - useEffect(() => { - fetchUsers() - }, [searchParams.current, searchParams.pageSize, searchParams.keyword]) - - // 只在组件挂载时获取角色列表 - useEffect(() => { - fetchRoles() - }, []) - - // 搜索 - 只更新 searchParams,让 useEffect 触发请求 - const handleSearch = () => { - setSearchParams(prev => ({ ...prev, current: 1 })) - } - - // 处理新增 - const handleAdd = () => { - setFormData(defaultFormData) - setIsEdit(false) - setDialogOpen(true) - } - - // 处理编辑 - const handleEdit = async (user: SystemUser) => { - try { - const res = await getUserDetail(user.id) - const detail = res.data - setFormData({ - id: detail.id, - account: detail.account || '', - name: detail.name || '', - nickName: detail.nickName || '', - phone: detail.phone || '', - }) - setIsEdit(true) - setDialogOpen(true) - } catch (error) { - console.error('获取用户详情失败:', error) - } - } - - // 处理分配角色 - const handleGrantRole = (user: SystemUser) => { - setSelectedUser(user) - setSelectedRoleIds(user.roles?.map(r => r.id) || []) - setRoleDialogOpen(true) - } - - // 提交分配角色 const handleSubmitGrant = async () => { if (!selectedUser) return - setSubmitting(true) - try { - await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds }) - setRoleDialogOpen(false) - fetchUsers() - } catch (error) { - console.error('分配角色失败:', error) - } finally { - setSubmitting(false) - } + try { await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds }); setRoleDialogOpen(false); fetchUsers() } + catch (e) { console.error(e) } finally { setSubmitting(false) } } - - // 处理删除确认 - const handleDeleteConfirm = (user: SystemUser) => { - setSelectedUser(user) - setDeleteDialogOpen(true) - } - - // 执行删除 const handleDelete = async () => { if (!selectedUser) return - - try { - await deleteUser([selectedUser.id]) - setDeleteDialogOpen(false) - setSelectedUser(null) - fetchUsers() - } catch (error) { - console.error('删除用户失败:', error) - } + try { await deleteUser([selectedUser.id]); setDeleteDialogOpen(false); fetchUsers() } + catch (e) { console.error(e) } } - // 提交表单 - const handleSubmit = async () => { - if (!formData.account || !formData.name) { - return - } - - setSubmitting(true) - try { - if (isEdit && formData.id) { - await updateUser(formData) - } else { - await saveUser({ ...formData, clientId: 'pc' }) - } - setDialogOpen(false) - fetchUsers() - } catch (error) { - console.error('保存用户失败:', error) - } finally { - setSubmitting(false) - } - } - - const totalPages = Math.ceil(total / searchParams.pageSize) - return ( -
- {/* 页面标题 */} -
-
-

用户管理

-

管理系统用户和权限

-
- {canWrite && ( - - )} -
+ ) : undefined} + /> - {/* 搜索和过滤 */} - - -
-
-
- - setSearchParams({ ...searchParams, keyword: e.target.value })} - onKeyDown={e => e.key === 'Enter' && handleSearch()} - className="pl-9" - /> -
-
- -
-
-
+ setSp(p => ({ ...p, keyword: v }))} + onSearch={() => setSp(p => ({ ...p, current: 1 }))} + placeholder="搜索账号、姓名或手机号..." + /> - {/* 用户列表 */} - - - - - 用户列表 - {total} - - - - {loading ? ( -
-
-
- ) : ( - - - - 用户 - 账号 - 手机号 - 角色 - 创建时间 - 操作 - - - - {users.length === 0 ? ( - - - 暂无数据 - - - ) : ( - users.map(user => ( - - -
- - - {(user.name || user.account)?.charAt(0).toUpperCase()} - -
-

{user.name || user.nickName}

- {user.nickName && user.name !== user.nickName && ( -

{user.nickName}

- )} -
-
-
- {user.account} - {user.phone || '-'} - -
- {user.roles?.length ? ( - user.roles.map(role => ( - {role.name} - )) - ) : ( - 未分配 - )} -
-
- - {user.createdAtStr || user.createdAt} - - - - - - - - {canWrite && ( - handleEdit(user)}> - - 编辑 - - )} - {canGrant && ( - handleGrantRole(user)}> - - 分配角色 - - )} - {canDelete && ( - handleDeleteConfirm(user)} - > - - 删除 - - )} - - - -
- )) - )} -
-
- )} - - {/* 分页 */} -
-
- 共 {total} 条记录 -
-
- -
- {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 + + + + + 用户 + 账号 + 手机号 + 角色 + 创建时间 + 操作 + + + + {users.map(user => ( + + +
+ + + + {(user.name || user.account)?.charAt(0).toUpperCase()} + + +
+

{user.name || user.nickName}

+ {user.nickName && user.name !== user.nickName && ( +

{user.nickName}

+ )} +
+
+
+ {user.account} + {user.phone || '—'} + +
+ {user.roles?.length + ? user.roles.map(r => {r.name}) + : 未分配 } - } - return ( - - ) - })} - {totalPages === 0 && ( - - )} -
- - - - - - + +
+ {user.createdAtStr || user.createdAt} + + + + + + + {canWrite && handleEdit(user)}>编辑} + {canGrant && handleGrantRole(user)}>分配角色} + {canDelete && { setSelectedUser(user); setDeleteDialogOpen(true) }}>删除} + + + +
+ ))} +
+
+ setSp(s => ({ ...s, current: p }))} onPageSizeChange={s => setSp(p => ({ ...p, pageSize: s, current: 1 }))} /> +
- {/* 新增/编辑对话框 */} + {/* 新增/编辑 Dialog */} - - - {isEdit ? '编辑用户' : '添加用户'} - - {isEdit ? '修改用户信息' : '创建新的系统用户'} - - -
-
-
- - setFormData({ ...formData, account: e.target.value })} - placeholder="登录账号" - disabled={isEdit} - /> + +
+ + +
+ {isEdit ? '编辑用户' : '新增用户'} +
+ {isEdit ? '修改用户信息' : '创建新的后台系统用户'} +
+
+
+
+
+ + setForm({ ...form, account: e.target.value })} placeholder="登录账号" disabled={isEdit} />
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="用户姓名" - /> +
+ + setForm({ ...form, name: e.target.value })} placeholder="用户姓名" />
-
-
-
- - setFormData({ ...formData, nickName: e.target.value })} - placeholder="用户昵称" - /> +
+ + setForm({ ...form, nickName: e.target.value })} placeholder="用户昵称" />
-
- - setFormData({ ...formData, phone: e.target.value })} - placeholder="手机号码" - /> +
+ + setForm({ ...form, phone: e.target.value })} placeholder="手机号码" />
{!isEdit && ( -
- - setFormData({ ...formData, password: e.target.value })} - placeholder="初始密码" - /> +
+ + setForm({ ...form, password: e.target.value })} placeholder="初始密码" />
)}
- - - +
- {/* 分配角色对话框 */} + {/* 分配角色 Dialog */} - + - 分配角色 - - 为用户 "{selectedUser?.name || selectedUser?.account}" 分配角色 - + 分配角色 + 为用户「{selectedUser?.name || selectedUser?.account}」分配角色 -
-
- {roles.map(role => ( -
- { - if (checked) { - setSelectedRoleIds([...selectedRoleIds, role.id]) - } else { - setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id)) - } - }} - /> - +
+ {roles.map(role => ( +
+ + ))}
- - +
- {/* 删除确认对话框 */} - - - - 确认删除 - - 确定要删除用户 "{selectedUser?.name || selectedUser?.account}" 吗?此操作无法撤销。 - - - - - - - - +
) }