flnxi-Wu/app/admin/page.tsx
WuYongJie 8705b9846a
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗样式系统与分类管理优化
- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
2025-11-25 09:37:00 +08:00

556 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
// 强制设置全局标记,确保新代码已加载(在页面加载时立即执行)
if (typeof window !== 'undefined') {
;(window as any).__ADMIN_PAGE_LOADED__ = true
;(window as any).__ADMIN_PAGE_VERSION__ = '2025-11-23-v4'
console.log('[AdminPage] 🚀 页面已加载,版本:', (window as any).__ADMIN_PAGE_VERSION__)
}
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import TemplateManager from './_components/TemplateManager'
import AppearanceSettingsPanel from './_components/AppearanceSettingsPanel'
import MonitorTab from './_components/MonitorTab'
import CategoriesTab from './_components/CategoriesTab'
import InvitationsTab from './_components/InvitationsTab'
import MaterialsTab from './_components/MaterialsTab'
import DepartmentsTab from './_components/DepartmentsTab'
import UsersTab from './_components/UsersTab'
import TabErrorBoundary from './_components/TabErrorBoundary'
import LoadingState from './_components/ui/LoadingState'
import ErrorState from './_components/ui/ErrorState'
import EmptyState from './_components/ui/EmptyState'
import { getDepartmentCollator } from '@/lib/departments'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { animations, textAnimations } from '@/lib/ui-animations'
import type { User, AdminTab, Department, SessionUser, ApiErrorResponse, ApiSuccessResponse } from './_components/types'
import { apiRequest, parseErrorResponse, getErrorMessage } from './_utils/api'
import { validateDepartmentName, validateEmail, validatePassword } from './_utils/validators'
import usePermissions from './_hooks/usePermissions'
import { useToast } from './_hooks/useToast'
import { useDataFetch } from './_hooks/useDataFetch'
import { useUserOperations } from './_hooks/useUserOperations'
import { useDepartmentOperations } from './_hooks/useDepartmentOperations'
// 类型断言函数:安全地获取 SessionUser
function getSessionUser(session: { user?: unknown } | null): SessionUser | null {
if (!session?.user) return null
const user = session.user as SessionUser
return user
}
export default function AdminPage() {
const styles = useThemeStyles()
const theme = useTheme()
const { data: session, status } = useSession()
const sessionUser = getSessionUser(session)
const router = useRouter()
const [activeTab, setActiveTab] = useState<AdminTab>('monitor')
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const hasLoadedOnceRef = useRef(false)
const { toast, showToast, dismissToast } = useToast()
const [departments, setDepartments] = useState<Department[]>([])
const departmentCollator = useMemo(() => getDepartmentCollator(), [])
const { loadUsersData, fetchDepartments, fetchTabData } = useDataFetch({
showToast,
departmentCollator,
setUsers,
setDepartments,
})
const userLookup = useMemo(() => {
const map = new Map<string, User>()
users.forEach((user) => map.set(user.id, user))
return map
}, [users])
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
const isSuperAdmin = sessionUser?.isSuperAdmin
if (status === 'authenticated' && sessionUser?.role !== 'ADMIN' && !isSuperAdmin) {
router.push('/')
return
}
}, [status, session, router])
const fetchData = useCallback(async () => {
const blockUI = !hasLoadedOnceRef.current
if (activeTab === 'templates' || activeTab === 'monitor') {
if (blockUI) {
setLoading(false)
hasLoadedOnceRef.current = true
}
return
}
if (blockUI) {
setLoading(true)
}
try {
await fetchTabData(activeTab)
} catch (error) {
console.error('Error fetching data:', error)
} finally {
if (blockUI) {
setLoading(false)
hasLoadedOnceRef.current = true
}
}
}, [activeTab, fetchTabData])
useEffect(() => {
if (status === 'authenticated') {
fetchData()
}
}, [status, fetchData])
const handleTransferSuperAdmin = async (targetEmail: string, password: string) => {
const emailValidation = validateEmail(targetEmail)
if (!emailValidation.valid) {
showToast(emailValidation.error || '请提供有效的目标用户邮箱', 'error')
return false
}
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
showToast(passwordValidation.error || '请输入密码', 'error')
return false
}
return transferSuperAdmin(targetEmail, password)
}
// 用户层级信息,用于处理上下级/权限等逻辑
const findDepartmentManagers = useCallback(
(departmentName: string | null | undefined) => {
if (!departmentName) return null
const result = users.filter(
(candidate) => candidate.managedDepartment && candidate.managedDepartment === departmentName
)
return result.length > 0 ? result : null
},
[users]
)
const { currentUserId, currentIsSuperAdmin, canManageUser } = usePermissions({
sessionUser,
users,
departments,
})
const { updateUser, deleteUser, toggleDownload, toggleActive, setRole, transferSuperAdmin } = useUserOperations({
users,
setUsers,
showToast,
fetchData,
findDepartmentManagers,
})
const { createDepartment, deleteDepartment, renameDepartment } = useDepartmentOperations({
departments,
setDepartments,
showToast,
fetchDepartments,
loadUsersData,
})
const handleUpdateUser = updateUser
const handleDeleteUser = deleteUser
const handleToggleDownload = toggleDownload
const handleToggleActive = toggleActive
const handleSetRole = setRole
if (status === 'loading' || loading) {
return <LoadingState message="加载中..." className="min-h-screen" />
}
return (
<div className={cn('min-h-screen', theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50')}>
<Header session={session} />
{/* Toast通知 */}
{toast && (
<div
role="status"
aria-live="polite"
className={`fixed top-6 left-1/2 -translate-x-1/2 z-[100] px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 ${
toast.type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'
} animate-fade-in`}
>
<span>{toast.message}</span>
<button
onClick={dismissToast}
className="p-1 rounded-full hover:bg-white/20 transition-colors"
aria-label="关闭提示"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
router.push('/')
}
}}
className={cn(
'px-4 py-2 border rounded-lg transition-all flex items-center gap-2 shadow-sm hover:shadow-md font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
theme === 'dark'
? 'bg-slate-800 text-white border-slate-600 hover:bg-slate-700'
: 'bg-white text-slate-900 border-slate-300 hover:bg-slate-50'
)}
title="返回主页"
aria-label="返回主页"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回主页
</button>
<h1 className={cn('text-3xl font-bold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>系统管理</h1>
</div>
</div>
{/* Tabs - Apple风格动画(使用统一导航样式) */}
<nav role="tablist" aria-label="管理功能导航" className={cn('flex space-x-1 border-b mb-8 overflow-x-auto', theme === 'dark' ? 'border-slate-800' : 'border-slate-200')}>
{/* 监控仪表板 - 只有管理员及以上权限才能看到 */}
{sessionUser?.role === 'ADMIN' && (
<button
type="button"
role="tab"
aria-selected={activeTab === 'monitor'}
aria-controls="monitor-panel"
id="monitor-tab"
onClick={() => setActiveTab('monitor')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('monitor')
}
}}
className={cn(
activeTab === 'monitor' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">监控仪表板</span>
{activeTab === 'monitor' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
)}
<button
type="button"
role="tab"
aria-selected={activeTab === 'users'}
aria-controls="users-panel"
id="users-tab"
onClick={() => setActiveTab('users')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('users')
}
}}
className={cn(
activeTab === 'users' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">用户管理</span>
{activeTab === 'users' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'departments'}
aria-controls="departments-panel"
id="departments-tab"
onClick={() => setActiveTab('departments')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('departments')
}
}}
className={cn(
activeTab === 'departments' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">架构管理</span>
{activeTab === 'departments' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'templates'}
aria-controls="templates-panel"
id="templates-tab"
onClick={() => setActiveTab('templates')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('templates')
}
}}
className={cn(
activeTab === 'templates' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">模板管理</span>
{activeTab === 'templates' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'categories'}
aria-controls="categories-panel"
id="categories-tab"
onClick={() => setActiveTab('categories')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('categories')
}
}}
className={cn(
activeTab === 'categories' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">分类管理</span>
{activeTab === 'categories' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
{/* 邀请管理 - 只有管理员及以上权限才能看到 */}
{sessionUser?.role === 'ADMIN' && (
<button
type="button"
role="tab"
aria-selected={activeTab === 'invitations'}
aria-controls="invitations-panel"
id="invitations-tab"
onClick={() => setActiveTab('invitations')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('invitations')
}
}}
className={cn(
activeTab === 'invitations' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">邀请管理</span>
{activeTab === 'invitations' && (
<span className={cn('absolute bottom-0 left-0 right-0 h-0.5 rounded-full', theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500', animations.slideInFromLeft)} />
)}
</button>
)}
<button
type="button"
role="tab"
aria-selected={activeTab === 'materials'}
aria-controls="materials-panel"
id="materials-tab"
onClick={() => setActiveTab('materials')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('materials')
}
}}
className={cn(
activeTab === 'materials' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">素材管理</span>
{activeTab === 'materials' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'settings'}
aria-controls="settings-panel"
id="settings-tab"
onClick={() => setActiveTab('settings')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('settings')
}
}}
className={cn(
activeTab === 'settings' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">外观设置</span>
{activeTab === 'settings' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
</nav>
{/* Template Management */}
{activeTab === 'templates' && (
<TabErrorBoundary tabKey="templates" onRetry={fetchData}>
<TemplateManager showToast={showToast} />
</TabErrorBoundary>
)}
{activeTab === 'settings' && (
<TabErrorBoundary tabKey="settings">
<AppearanceSettingsPanel sessionUser={sessionUser} variant="embedded" />
</TabErrorBoundary>
)}
{/* Materials Management */}
{activeTab === 'materials' && (
<TabErrorBoundary tabKey="materials">
<MaterialsTab showToast={showToast} />
</TabErrorBoundary>
)}
{/* Categories Management */}
{activeTab === 'categories' && (
<TabErrorBoundary tabKey="categories" onRetry={fetchData}>
<CategoriesTab showToast={showToast} onDataChange={fetchData} />
</TabErrorBoundary>
)}
{/* Invitation Management */}
{activeTab === 'invitations' && (
<TabErrorBoundary tabKey="invitations">
<InvitationsTab showToast={showToast} />
</TabErrorBoundary>
)}
{/* 架构管理 */}
{activeTab === 'departments' && (
<TabErrorBoundary tabKey="departments" onRetry={fetchData}>
<DepartmentsTab
departments={departments}
onCreateDepartment={async (name) => { await createDepartment(name) }}
onRenameDepartment={async (deptId, newName) => { await renameDepartment(deptId, newName) }}
onDeleteDepartment={async (id, name) => { await deleteDepartment(id, name) }}
showToast={showToast}
/>
</TabErrorBoundary>
)}
{/* Old Categories Management - 已迁移到 CategoriesTab - 备份代码已删除 */}
{activeTab === 'users' && (
<TabErrorBoundary tabKey="users" onRetry={fetchData}>
<div role="tabpanel" id="users-panel" aria-labelledby="users-tab">
<UsersTab
users={users}
departments={departments}
currentUserId={currentUserId}
currentUserEmail={sessionUser?.email}
currentIsSuperAdmin={currentIsSuperAdmin}
canManageUser={canManageUser}
onToggleActive={handleToggleActive}
onToggleDownload={handleToggleDownload}
onSetRole={handleSetRole}
onUpdateUser={handleUpdateUser}
onDeleteUser={handleDeleteUser}
onTransferSuperAdmin={handleTransferSuperAdmin}
/>
</div>
</TabErrorBoundary>
)}
{/* 监控仪表板 */}
{activeTab === 'monitor' && (
<TabErrorBoundary tabKey="monitor" onRetry={fetchData}>
<div role="tabpanel" id="monitor-panel" aria-labelledby="monitor-tab">
<MonitorTab showToast={showToast} />
</div>
</TabErrorBoundary>
)}
</main>
{/* 创建邀请链接模态框 */}
</div>
)
}