427 lines
15 KiB
TypeScript
427 lines
15 KiB
TypeScript
'use client'
|
|
|
|
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 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 tabIndicatorClass = theme === 'dark' ? 'bg-indigo-300' : 'bg-indigo-600'
|
|
const tabIndicatorLineClass = 'absolute bottom-0 left-3 right-3 h-[2px] rounded-full'
|
|
const tabFocusRingClass =
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900'
|
|
const { data: session, status } = useSession()
|
|
const sessionUser = getSessionUser(session)
|
|
const router = useRouter()
|
|
const [activeTab, setActiveTab] = useState<AdminTab>('users')
|
|
|
|
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">
|
|
|
|
{/* 管理导航使用统一导航样式 */}
|
|
<nav role="tablist" aria-label="管理功能导航" className={cn('flex space-x-1 mb-8 overflow-x-auto')}>
|
|
{/* 监控仪表板已移除,统一通过头像下拉菜单访问 */}
|
|
<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,
|
|
tabFocusRingClass
|
|
)}
|
|
>
|
|
<span className="relative z-10">用户管理</span>
|
|
{activeTab === 'users' && (
|
|
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, 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,
|
|
tabFocusRingClass
|
|
)}
|
|
>
|
|
<span className="relative z-10">架构管理</span>
|
|
{activeTab === 'departments' && (
|
|
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, 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,
|
|
tabFocusRingClass
|
|
)}
|
|
>
|
|
<span className="relative z-10">模板管理</span>
|
|
{activeTab === 'templates' && (
|
|
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, 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,
|
|
tabFocusRingClass
|
|
)}
|
|
>
|
|
<span className="relative z-10">分类管理</span>
|
|
{activeTab === 'categories' && (
|
|
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, 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,
|
|
tabFocusRingClass
|
|
)}
|
|
>
|
|
<span className="relative z-10">邀请管理</span>
|
|
{activeTab === 'invitations' && (
|
|
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, 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,
|
|
tabFocusRingClass
|
|
)}
|
|
>
|
|
<span className="relative z-10">素材管理</span>
|
|
{activeTab === 'materials' && (
|
|
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
|
|
)}
|
|
</button>
|
|
</nav>
|
|
|
|
{/* 模板管理 */}
|
|
{activeTab === 'templates' && (
|
|
<TabErrorBoundary tabKey="templates" onRetry={fetchData}>
|
|
<TemplateManager showToast={showToast} />
|
|
</TabErrorBoundary>
|
|
)}
|
|
|
|
{/* 素材管理 */}
|
|
{activeTab === 'materials' && (
|
|
<TabErrorBoundary tabKey="materials">
|
|
<MaterialsTab showToast={showToast} />
|
|
</TabErrorBoundary>
|
|
)}
|
|
|
|
{/* 分类管理 */}
|
|
{activeTab === 'categories' && (
|
|
<TabErrorBoundary tabKey="categories" onRetry={fetchData}>
|
|
<CategoriesTab showToast={showToast} onDataChange={fetchData} />
|
|
</TabErrorBoundary>
|
|
)}
|
|
|
|
{/* 邀请管理 */}
|
|
{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>
|
|
)
|
|
}
|