import { AuditOutlined, AccountBookOutlined, BellOutlined, BugOutlined, DashboardOutlined, FileSearchOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, OrderedListOutlined, SafetyCertificateOutlined, SettingOutlined, ShopOutlined, SoundOutlined, TeamOutlined, TrophyOutlined, UnorderedListOutlined, UserOutlined, } from '@ant-design/icons' import { App, Badge, Button, Dropdown, Layout, List, Menu, Popover, Space, Tooltip, Typography, } from 'antd' import type { MenuProps } from 'antd' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router' import { fetchAdminSession, fetchAdminNotifications, fetchDevMockStatus, logoutAdmin, type AdminNotification, } from '@/services/admin' import { clearAdminSession, getAdminRole, getAdminToken, getAdminTokenExpiresAt, getAdminUsername, setAdminPermissions, } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed' const NOTIFICATION_SOUND_KEY = 'admin-notification-sound-enabled' export default function AdminLayout() { const navigate = useNavigate() const location = useLocation() const { message } = App.useApp() const queryClient = useQueryClient() const [collapsed, setCollapsed] = useState( () => window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1', ) const role = getAdminRole() const username = getAdminUsername() || 'admin' const expiresAt = getAdminTokenExpiresAt() const [soundEnabled, setSoundEnabled] = useState( () => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1', ) const adminSessionQuery = useQuery({ queryKey: ['admin-session'], queryFn: fetchAdminSession, retry: false, }) useEffect(() => { const session = adminSessionQuery.data?.data if (session?.authenticated && session.user) { setAdminPermissions(session.user.permissions || []) } }, [adminSessionQuery.data]) const devMockStatusQuery = useQuery({ queryKey: ['admin-dev-mock-status'], queryFn: () => fetchDevMockStatus(), enabled: role !== 'finance', retry: false, }) const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled) const notificationsQuery = useQuery({ queryKey: ['admin-notifications'], queryFn: fetchAdminNotifications, enabled: role !== 'finance', retry: false, }) const notifications = notificationsQuery.data?.data.items || [] const pendingNotificationCount = Number(notificationsQuery.data?.data.pendingCount || 0) useRealtimeEvents({ url: '/api/v1/admin/realtime', token: role === 'finance' ? '' : getAdminToken(), onEvent: (event) => applyAdminRealtimeEvent(event, queryClient), // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { clearAdminSession() void navigate('/admin/login', { replace: true, state: { from: location } }) }, }) const menuItems = useMemo(() => { const operationItems: MenuProps['items'] = role === 'finance' ? [ { key: '/admin/finance', icon: , label: '财务' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, ] : [ { key: '/admin/dashboard', icon: , label: '概览' }, { key: '/admin/orders', icon: , label: '订单' }, ...(role === 'support' ? [] : [{ key: '/admin/finance', icon: , label: '财务' }]), { key: '/admin/tasks', icon: , label: '任务' }, { key: '/admin/worker-platform', icon: , label: '接单平台' }, { key: '/admin/kuaishou-industry', icon: , label: '电子凭证', }, { key: '/admin/cloudtentacles-records', icon: , label: '发货记录', }, ] const items: MenuProps['items'] = [ { key: 'group-operation', type: 'group', label: '运营', children: operationItems, }, ] if (devMockEnabled) { items.push({ key: 'group-dev', type: 'group', label: '开发', children: [{ key: '/admin/dev-mock', icon: , label: '开发 Mock' }], }) } if (role === 'admin') { items.push( { key: 'group-config', type: 'group', label: '配置', children: [ { key: '/admin/users', icon: , label: '后台用户' }, { key: '/admin/platform-shops', icon: , label: '平台配置' }, { key: '/admin/platform-fulfillment', icon: , label: '履约配置' }, ], }, { key: 'group-system', type: 'group', label: '系统', children: [{ key: '/admin/audit-logs', icon: , label: '审计日志' }], }, ) } return items }, [devMockEnabled, role]) async function submitLogout() { try { await logoutAdmin() } catch { // 服务端撤销失败时仍清理本地登录态,避免当前设备继续显示为已登录。 } finally { clearAdminSession() message.success('已退出后台') void navigate('/admin/login', { replace: true }) } } function toggleCollapsed() { const next = !collapsed setCollapsed(next) window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0') } function toggleNotificationSound() { const next = !soundEnabled setSoundEnabled(next) window.localStorage.setItem(NOTIFICATION_SOUND_KEY, next ? '1' : '0') if (next) { playNotificationSound('default') message.success('通知声音已开启') } else { message.info('通知声音已关闭') } } function openNotification(notification: AdminNotification) { const actionPath = buildNotificationActionPath(notification) if (actionPath) { void navigate(actionPath) } } const notificationContent = (
{notifications.length === 0 ? ( 暂无待处理事项 ) : ( ( openNotification(notification)} >
{notification.title} {notification.priority === 'high' || notification.priority === 'urgent' ? ( 紧急 ) : null} {notification.reminderCount > 0 ? ( 已催 {notification.reminderCount} 次 ) : null} {notification.body} {formatAdminDateTime(notification.updatedAt)}
)} /> )}
) const selectedKey = resolveSelectedKey(location.pathname) return (
{collapsed ? 'OS' : '运营后台'} {!collapsed ? 订单与交付管理 : null}
{!collapsed ? (
{username} {roleLabel(role)} {expiresAt ? ( {formatAdminDateTime(expiresAt)} 到期 ) : null}
) : null} navigate(String(key))} />
) } function applyAdminRealtimeEvent( event: RealtimeEvent, queryClient: ReturnType, ) { if (event.type === 'admin_notification.changed' && isRecord(event.data)) { queryClient.setQueryData(['admin-notifications'], (current: unknown) => { if (!isRecord(current) || !isRecord(current.data)) return current const currentData = current.data as { items?: unknown[]; pendingCount?: number } const items = Array.isArray(currentData.items) ? currentData.items : [] const notification = event.data as Record const notificationId = Number(notification.notificationId || event.entityId) const nextItems = event.operation === 'remove' ? items.filter( (item) => Number((item as { notificationId?: unknown })?.notificationId) !== notificationId, ) : upsertById(items, notification, 'notificationId', true) return { ...current, data: { ...currentData, items: nextItems, pendingCount: event.operation === 'remove' ? Math.max(0, Number(currentData.pendingCount || 0) - 1) : currentData.pendingCount, }, } }) } if (event.type === 'work_order.changed' && event.scopes.includes('admin_work_orders')) { // 拼单明细是独立查询,工单列表快照更新时也要同步刷新当前打开的明细。 void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-order-sharing', event.entityId], }) void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-after-sales-cases'] }) if (isRecord(event.data)) { const data = event.data patchAdminWorkOrderCaches(queryClient, { ...event, data }) } else { void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }) } } if ( event.type === 'worker_finance_request.changed' && event.scopes.includes('admin_worker_finance') && isRecord(event.data) ) { const data = event.data queryClient.setQueriesData( { queryKey: ['admin-worker-platform-finance-requests'] }, (current: unknown) => patchFinanceRequestListEnvelope(current, { ...event, data }), ) } if ( event.type === 'order.changed' && event.scopes.includes('admin_orders') && isRecord(event.data) ) { const payload = event.data if (isRecord(payload.order)) { patchAdminOrderCaches(queryClient, { ...event, data: payload.order }) } if (isRecord(payload.dashboard)) { queryClient.setQueryData(['admin-dashboard-summary'], (current: unknown) => { if (!isRecord(current)) return current return { ...current, data: payload.dashboard } }) } if (Array.isArray(payload.tasks)) { patchAdminTaskCaches(queryClient, payload.tasks) } } } function patchAdminWorkOrderCaches( queryClient: ReturnType, event: RealtimeEvent, ) { const item = event.data as { workOrderId?: unknown status?: unknown productName?: unknown platformOrderId?: unknown worker?: { workerId?: unknown } | null } for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['admin-worker-platform-orders'], })) { const statuses = String(queryKey[1] || '') .split(',') .filter(Boolean) const keyword = String(queryKey[2] || '') const workerId = Number(queryKey[3] || 0) const workerLevelIds = String(queryKey[4] || '') .split(',') .map((value) => Number(value)) .filter(Boolean) const targetWorkOrderId = Number(queryKey[5] || 0) const page = Number(queryKey[6] || 1) // 等级筛选的匹配逻辑只在服务端(接单打手或拼单参与者等级),快照不含等级, // 无法在前端复现:已有行视为匹配(拉取时已通过服务端过滤),但不再插入新行。 const levelFilterActive = workerLevelIds.length > 0 const matches = (!statuses.length || statuses.includes(String(item.status || ''))) && matchesWorkOrderKeyword(item, keyword) && (!workerId || workerId === Number(item.worker?.workerId || 0)) && (!targetWorkOrderId || targetWorkOrderId === Number(item.workOrderId || 0)) const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !levelFilterActive const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert), ) } } function patchAdminTaskCaches(queryClient: ReturnType, tasks: unknown[]) { queryClient.setQueriesData({ queryKey: ['admin-tasks'] }, (current: unknown) => { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current } const items = current.data.items as unknown[] const nextItems = items.map((item) => { const taskId = Number((item as { taskId?: unknown }).taskId || 0) return ( tasks.find((task) => Number((task as { taskId?: unknown }).taskId || 0) === taskId) || item ) }) return { ...current, data: { ...current.data, items: nextItems } } }) } function patchAdminOrderCaches( queryClient: ReturnType, event: RealtimeEvent, ) { const item = event.data as { orderId?: unknown; platformOrderId?: unknown; payStatus?: unknown } for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['admin-orders'] })) { const params = isRecord(queryKey[1]) ? queryKey[1] : {} const page = Number(params.page || 1) const matches = (!String(params.platformOrderId || '').trim() || String(item.platformOrderId || '') === String(params.platformOrderId || '').trim()) && (!String(params.payStatus || '').trim() || String(item.payStatus || '') === String(params.payStatus || '').trim()) const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !params.skuCode const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, patchAdminOrderListEnvelope(current, { ...event, operation }, shouldInsert), ) } } function patchAdminOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current } const id = Number((event.data as { orderId?: unknown }).orderId || event.entityId) const items = current.data.items as unknown[] const nextItems = event.operation === 'remove' ? items.filter((entry) => Number((entry as { orderId?: unknown })?.orderId) !== id) : upsertById(items, event.data, 'orderId', insert) return { ...current, data: { ...current.data, items: nextItems } } } function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current } const item = event.data const id = Number((item as { workOrderId?: unknown }).workOrderId || event.entityId) const items = current.data.items as unknown[] const nextItems = event.operation === 'remove' ? items.filter((entry) => Number((entry as { workOrderId?: unknown })?.workOrderId) !== id) : upsertById(items, item, 'workOrderId', insert) return { ...current, data: { ...current.data, items: nextItems } } } function patchFinanceRequestListEnvelope(current: unknown, event: RealtimeEvent) { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current } const id = Number((event.data as { requestId?: unknown }).requestId || event.entityId) const items = current.data.items as unknown[] const nextItems = event.operation === 'remove' ? items.filter((entry) => Number((entry as { requestId?: unknown })?.requestId) !== id) : upsertById(items, event.data, 'requestId') return { ...current, data: { ...current.data, items: nextItems } } } function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert = false) { const id = Number((nextItem as Record)[idKey] || 0) const index = items.findIndex( (item) => Number((item as Record)?.[idKey] || 0) === id, ) if (index < 0) return insert ? [nextItem, ...items] : items return items.map((item, itemIndex) => (itemIndex === index ? nextItem : item)) } function matchesWorkOrderKeyword( item: { productName?: unknown; platformOrderId?: unknown }, keyword: string, ) { const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return true return [item.productName, item.platformOrderId] .map((value) => String(value || '').toLowerCase()) .some((value) => value.includes(normalizedKeyword)) } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } type NotificationAlertKind = | 'withdraw' | 'recharge' | 'acceptance' | 'material' | 'reminder' | 'default' function playNotificationSound(kind: NotificationAlertKind) { try { const audio = new AudioContext() const oscillator = audio.createOscillator() const gain = audio.createGain() oscillator.type = 'sine' const notes: Record = { withdraw: [740, 988], recharge: [587, 740], acceptance: [660, 784], material: [523, 659], reminder: [880, 1047, 1319], default: [880], } const duration = 0.12 const sequence = notes[kind] sequence.forEach((frequency, index) => { oscillator.frequency.setValueAtTime(frequency, audio.currentTime + index * duration) }) gain.gain.setValueAtTime(0.08, audio.currentTime) gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + sequence.length * duration) oscillator.connect(gain) gain.connect(audio.destination) oscillator.start() oscillator.stop(audio.currentTime + sequence.length * duration) oscillator.addEventListener('ended', () => void audio.close()) } catch { // 部分浏览器禁止音频或不支持 Web Audio,不影响待办展示。 } } function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/admin/orders')) return '/admin/orders' if (pathname.startsWith('/admin/finance')) return '/admin/finance' if (pathname.startsWith('/admin/tasks')) return '/admin/tasks' if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform' if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry' if (pathname === '/admin/platform-shops') return '/admin/platform-shops' return pathname } function buildNotificationActionPath(notification: AdminNotification) { if (!notification.entityId) { return notification.actionPath } const [pathname, query = ''] = notification.actionPath.split('?') const searchParams = new URLSearchParams(query) if (notification.entityType === 'work_order') { searchParams.set('tab', 'orders') searchParams.set('workOrderId', String(notification.entityId)) } else if (notification.entityType === 'worker_finance_request') { searchParams.set('tab', 'finance') searchParams.set('financeRequestId', String(notification.entityId)) } else { return notification.actionPath } return `${pathname}?${searchParams.toString()}` } function roleLabel(role: string) { if (role === 'admin') return '管理员' if (role === 'operator') return '普通运营' if (role === 'finance') return '财务' return '客服' }