import { CheckCircleOutlined, AuditOutlined, AccountBookOutlined, ApartmentOutlined, BellOutlined, BugOutlined, DashboardOutlined, FileSearchOutlined, LogoutOutlined, LineChartOutlined, MenuFoldOutlined, MenuUnfoldOutlined, OrderedListOutlined, SafetyCertificateOutlined, SettingOutlined, ShopOutlined, SoundOutlined, TeamOutlined, TrophyOutlined, UnorderedListOutlined, UserOutlined, WalletOutlined, } 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, getAdminDisplayName, getAdminRole, getAdminToken, getAdminTokenExpiresAt, getAdminUsername, setAdminDisplayName, setAdminPermissions, } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { evaluateAdminWorkOrderRealtimeMatch, parseAdminWorkOrderListFilters, patchAdminWorkOrderListItems, shouldPrependAdminWorkOrder, } from '@/utils/admin-realtime-work-orders' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import dayjs from '@/lib/dayjs' 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 || []) setAdminDisplayName(session.user.displayName || session.user.username) } }, [adminSessionQuery.data]) const displayName = adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username 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/sales-profit', icon: , label: '销售毛利' }, { key: '/admin/cost-center', icon: , label: '商品与成本' }, { key: '/admin/fund', icon: , label: '钱包管理' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, ] : [ { key: '/admin/dashboard', icon: , label: '概览' }, { key: '/admin/orders', icon: , label: '订单' }, { key: '/admin/fund', icon: , label: role === 'support' ? '我的钱包' : '钱包管理', }, ...(role === 'support' ? [] : [ { key: '/admin/finance', icon: , label: '财务' }, { key: '/admin/sales-profit', icon: , label: '销售毛利', }, { key: '/admin/cost-center', icon: , label: '商品与成本', }, ]), { key: '/admin/tasks', icon: , label: '任务' }, { key: '/admin/manual-fulfillment', icon: , label: '人工履约', }, { key: '/admin/worker-platform', icon: , label: '接单平台' }, { key: '/admin/kuaishou-industry', icon: , label: '电子凭证', }, { key: '/admin/douyin-mashida-certificates', 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 ? (
{displayName} {displayName !== username ? ( @{username} ) : null} {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 for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['admin-worker-platform-orders'], })) { const filters = parseAdminWorkOrderListFilters(queryKey) if (!filters) { // 读不懂这个 key(旧结构 / 别的调用方):不猜、不插、不移除,交给服务端下一次拉取。 continue } // 三态判定:match 才允许插入顶部;mismatch 才允许移除;unknown 只原地更新已存在的行。 // 服务端的 operation 交给判定函数处理:显式单号检索下连 remove 都不能删行 // (旧写法在这里直接把 remove 判成 mismatch,绕过了单号护栏)。 const verdict = evaluateAdminWorkOrderRealtimeMatch(item, filters, { operation: event.operation, }) if (verdict === 'unknown') { queryClient.setQueryData( queryKey, patchAdminWorkOrderListItems(current, item, { remove: false, insert: false }), ) continue } const items = isRecord(current) && isRecord(current.data) && Array.isArray(current.data.items) ? (current.data.items as unknown[]) : [] const insert = verdict === 'match' && event.operation === 'upsert' && filters.page === 1 && shouldPrependAdminWorkOrder(items, item) queryClient.setQueryData( queryKey, patchAdminWorkOrderListItems(current, item, { remove: verdict === 'mismatch', insert, }), ) } } 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 paidAt?: unknown createdAt?: unknown provider?: unknown shopName?: unknown itemTitle?: unknown itemSummary?: 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 pageSize = Math.max(1, Number(params.pageSize || 20) || 20) const keyword = String(params.keyword || '').trim() const matchesFilters = matchesAdminOrderKeyword(item, keyword) && matchesAdminOrderProvider(item, String(params.provider || '').trim()) && (!String(params.payStatus || '').trim() || String(item.payStatus || '') === String(params.payStatus || '').trim()) && // 有关键词时不限日期;否则实时插入也要落在当前日期筛选内。 (keyword ? true : matchesAdminOrderDateRange(item, params)) const shouldInsert = event.operation === 'upsert' && page === 1 && matchesFilters && !params.skuCode const operation = event.operation === 'remove' || !matchesFilters ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, patchAdminOrderListEnvelope(current, { ...event, operation }, shouldInsert, pageSize), ) } } function getOrderSortTimestamp(item: unknown): number { if (!item || typeof item !== 'object') return 0 const record = item as Record const timeStr = String(record.paidAt || record.createdAt || '').trim() if (!timeStr) return 0 const t = dayjs(timeStr).valueOf() return Number.isFinite(t) ? t : 0 } function sortAdminOrdersDesc(items: unknown[]): unknown[] { return [...items].sort((a, b) => { const timeA = getOrderSortTimestamp(a) const timeB = getOrderSortTimestamp(b) if (timeB !== timeA) { return timeB - timeA } const idA = Number((a as Record)?.orderId || 0) const idB = Number((b as Record)?.orderId || 0) return idB - idA }) } function patchAdminOrderListEnvelope( current: unknown, event: RealtimeEvent, insert = false, pageSize = 20, ) { 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 existed = items.some((entry) => Number((entry as { orderId?: unknown })?.orderId) === id) let nextItems = event.operation === 'remove' ? items.filter((entry) => Number((entry as { orderId?: unknown })?.orderId) !== id) : upsertById(items, event.data, 'orderId', insert) // 严格保持按支付/创建时间倒序排列,避免后台同步历史单(如 15/16 号)时盲目插在第 1 行 nextItems = sortAdminOrdersDesc(nextItems) if (insert && nextItems.length > pageSize) { nextItems = nextItems.slice(0, pageSize) } const pagination = isRecord(current.data.pagination) ? current.data.pagination : null let nextPagination = pagination if (pagination && insert && !existed) { nextPagination = { ...pagination, total: Number(pagination.total || 0) + 1, } } else if (pagination && event.operation === 'remove' && existed) { nextPagination = { ...pagination, total: Math.max(0, Number(pagination.total || 0) - 1), } } return { ...current, data: { ...current.data, items: nextItems, ...(nextPagination ? { pagination: nextPagination } : {}), }, } } 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 matchesAdminOrderKeyword( item: { platformOrderId?: unknown shopName?: unknown itemTitle?: unknown itemSummary?: unknown }, keyword: string, ) { const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return true return [item.platformOrderId, item.shopName, item.itemTitle, item.itemSummary] .map((value) => String(value || '').toLowerCase()) .some((value) => value.includes(normalizedKeyword)) } function matchesAdminOrderProvider(item: { provider?: unknown }, provider: string) { const normalized = provider.trim().toLowerCase() if (!normalized) return true const value = String(item.provider || '').toLowerCase() if (normalized === 'pdd') return value === 'pdd' if (normalized === '91' || normalized === '91kaquan' || normalized === 'open_91') { return ['91kaquan', 'open_91', '91'].includes(value) } if (normalized === 'kuaishou' || normalized === 'kuaishou-industry') { return value.startsWith('kuaishou') } return value === normalized } function matchesAdminOrderDateRange( item: { paidAt?: unknown; createdAt?: unknown }, params: Record, ) { const dateFrom = String(params.dateFrom || '') .trim() .slice(0, 10) const dateTo = String(params.dateTo || '') .trim() .slice(0, 10) if (!dateFrom && !dateTo) return true const raw = String(item.paidAt || item.createdAt || '').trim() if (!raw) return false const day = dayjs(raw).format('YYYY-MM-DD') if (dateFrom && day < dateFrom) return false if (dateTo && day > dateTo) return false return true } 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/cost-center')) return '/admin/cost-center' 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 '客服' }