import { ContainerOutlined, DollarCircleOutlined, LogoutOutlined, SafetyCertificateOutlined, ShoppingOutlined, UserOutlined, } from '@ant-design/icons' import { App, Button, Layout, Menu, Typography } from 'antd' import { useQuery, useQueryClient } from '@tanstack/react-query' import type { MenuProps } from 'antd' import { useEffect, useMemo, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router' import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker' import { useIsMobile } from '@/utils/use-is-mobile' import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders' import { compareWorkerHallOrderRows, evaluateWorkerKeywordVerdict, patchWorkerHallPersonalShares, patchWorkerWorkOrderInfiniteEnvelope, patchWorkerWorkOrderListEnvelope, resolveWorkerMyOrderRealtimeVerdict, } from '@/utils/worker-realtime-work-orders' import { clearWorkerSession, expireWorkerSession, getWorkerSessionExpiryDelay, getWorkerStatus, getWorkerToken, getWorkerTokenExpiresAt, getWorkerUsername, isWorkerSessionExpiryKey, isWorkerSessionStorageKey, } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' import WorkerAnnouncementPopup from '@/components/worker/WorkerAnnouncementPopup' import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' const menuItems: MenuProps['items'] = [ { key: '/hall', icon: , label: '抢单大厅' }, { key: '/orders', icon: , label: '我的订单' }, { key: '/orders/after-sales', icon: , label: '售后问题单' }, { key: '/consignment', icon: , label: '我要寄卖' }, { key: '/profile', icon: , label: '个人中心' }, ] const tabItems = [ { key: '/hall', icon: ShoppingOutlined, label: '抢单大厅' }, { key: '/orders', icon: ContainerOutlined, label: '我的订单' }, { key: '/consignment', icon: DollarCircleOutlined, label: '我要寄卖' }, { key: '/profile', icon: UserOutlined, label: '个人中心' }, ] export default function WorkerLayout() { const navigate = useNavigate() const location = useLocation() const isMobile = useIsMobile() const { message, modal } = App.useApp() const queryClient = useQueryClient() const username = getWorkerUsername() || '打手' const status = getWorkerStatus() const currentKey = resolveSelectedKey(location.pathname) const profileQuery = useQuery({ queryKey: ['worker-profile'], queryFn: () => fetchWorkerProfile(), retry: false, staleTime: 10_000, }) const worker = profileQuery.data?.data.worker const afterSalesQuery = useQuery({ queryKey: ['worker-after-sales-cases'], queryFn: fetchWorkerAfterSalesCases, retry: false, staleTime: 10_000, }) const afterSalesPendingCount = (afterSalesQuery.data?.data.items || []).filter( (item) => item.status === 'awaiting_worker_response', ).length const dynamicMenuItems: MenuProps['items'] = useMemo( () => [ { key: '/hall', icon: , label: '抢单大厅' }, { key: '/orders', icon: , label: '我的订单' }, { key: '/orders/after-sales', icon: , label: ( 售后问题单 {afterSalesPendingCount > 0 ? ( {afterSalesPendingCount} ) : null} ), }, { key: '/consignment', icon: , label: '我要寄卖' }, { key: '/profile', icon: , label: '个人中心' }, ], [afterSalesPendingCount], ) const [sessionExpiresAt, setSessionExpiresAt] = useState(() => getWorkerTokenExpiresAt()) useEffect(() => { const navigateToLogin = () => { if (window.location.pathname !== '/') { void navigate('/', { replace: true }) } } const handleSessionExpired = () => navigateToLogin() const handleSessionRenewed = () => setSessionExpiresAt(getWorkerTokenExpiresAt()) const handleStorage = (event: StorageEvent) => { if (!isWorkerSessionStorageKey(event.key)) return if (event.newValue === null) { expireWorkerSession() navigateToLogin() return } if (isWorkerSessionExpiryKey(event.key)) { handleSessionRenewed() } } window.addEventListener('worker-session-expired', handleSessionExpired) window.addEventListener('worker-session-renewed', handleSessionRenewed) window.addEventListener('storage', handleStorage) return () => { window.removeEventListener('worker-session-expired', handleSessionExpired) window.removeEventListener('worker-session-renewed', handleSessionRenewed) window.removeEventListener('storage', handleStorage) } }, [navigate]) useEffect(() => { const delay = getWorkerSessionExpiryDelay(sessionExpiresAt) if (delay === null) return const timer = window.setTimeout(() => { expireWorkerSession() }, delay) return () => window.clearTimeout(timer) }, [sessionExpiresAt]) useRealtimeEvents({ url: '/api/v1/worker/realtime', token: getWorkerToken(), onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient), // 补发窗口溢出(断线太久):历史事件已不连续,逐条套旧快照会把列表写回旧值, // 这里整体失效,让列表接口重新给权威数据。 onResync: () => { void queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }) void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) void queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }) void queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }) void queryClient.invalidateQueries({ queryKey: ['worker-finance-requests'] }) void queryClient.invalidateQueries({ queryKey: ['worker-profile'] }) }, // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { expireWorkerSession() }, }) async function submitLogout() { try { await logoutWorker() } catch { // 接单端退出为无状态操作,本地清理优先。 } finally { clearWorkerSession() message.success('已退出登录') void navigate('/', { replace: true }) } } function confirmLogout() { modal.confirm({ title: '确认退出登录', content: '退出后需要重新登录才能继续抢单和处理订单。', okText: '确认退出', cancelText: '取消', okButtonProps: { danger: true }, onOk: submitLogout, }) } if (isMobile) { return (
锤
王大锤接单
) } return (
锤
王大锤电竞 打手工作台
{(worker?.displayName || username).slice(0, 1)}
{worker?.displayName || username} ● {formatWorkerStatus(status)}
{worker?.level?.name ? (
{worker.level.name} {worker.wallet ? ( ¥{(Number(worker.wallet.availableAmount || 0) / 100).toFixed(2)} ) : null}
) : null}
navigate(String(key))} />
) } function applyWorkerRealtimeEvent( event: RealtimeEvent, queryClient: ReturnType, ) { if (event.type === 'work_order.changed' && isRecord(event.data)) { const data = event.data if (event.scopes.includes('worker_my_orders')) { patchWorkerMyOrderCaches(queryClient, { ...event, data }) // 大厅行上的个人份额只信任「我的订单」快照:大厅广播是通用快照,已剔除这些字段。 // 否则后台撤掉我的份额后,大厅按钮会一直停在「再拼一次」。 const hallWorkOrderId = Number(data.workOrderId) || Number(event.entityId) || 0 if (hallWorkOrderId > 0 && 'myShare' in data && 'myShares' in data) { for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['worker-hall-orders'], })) { queryClient.setQueryData( queryKey, patchWorkerHallPersonalShares( current, hallWorkOrderId, data.myShare ?? null, data.myShares ?? [], ), ) } } void queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }) } if (event.scopes.includes('worker_hall')) { patchWorkerHallCaches(queryClient, { ...event, data }) } } if (event.type === 'worker_wallet.changed' && isRecord(event.data)) { const walletData = event.data queryClient.setQueryData(['worker-profile'], (current: unknown) => { if (!isRecord(current) || !isRecord(current.data) || !isRecord(current.data.worker)) return current return { ...current, data: { ...current.data, worker: { ...current.data.worker, wallet: walletData.wallet || walletData }, }, } }) } if (event.type === 'worker_finance_request.changed' && isRecord(event.data)) { const data = event.data queryClient.setQueriesData({ queryKey: ['worker-finance-requests'] }, (current: unknown) => patchFinanceRequestListEnvelope(current, { ...event, data }), ) } } function patchWorkerMyOrderCaches( queryClient: ReturnType, event: RealtimeEvent, ) { for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['worker-my-orders'], })) { const status = String(queryKey[1] || '') const keyword = String(queryKey[2] || '') const page = Number(queryKey[3] || 1) const item = event.data as { status?: unknown productName?: unknown platformOrderId?: unknown } // 状态可证伪:不符才允许删行;关键词不可证伪(后端搜的字段比前端多,含工单号), // 找不到时只判 unknown —— 不删也不插,就地更新,避免"按工单号搜索时行被删空"。 // 显式单号检索时连状态也不可证伪:后端按单号查时已忽略状态页签, // 这里再按状态删行就会让搜到的那一单在流转后消失(详见判定函数注释)。 const verdict = resolveWorkerMyOrderRealtimeVerdict({ item, statusFilter: status, keyword, operation: event.operation, page, explicitOrderNo: isExplicitOrderNoSearch(keyword), }) queryClient.setQueryData( queryKey, patchWorkerWorkOrderListEnvelope( current, { ...event, operation: verdict.operation }, verdict.shouldInsert, ), ) } } /** 新订单广播最多推迟入缓存的时长;超过则不占定时器,交给列表刷新自然带出。 */ const HALL_BROADCAST_MAX_SCHEDULE_MS = 60_000 /** 等待可见延迟到期的挂起插入;期间收到下架事件需取消,避免把已下架订单插回缓存。 */ const pendingHallUpsertTimers = new Map>() function patchWorkerHallCaches( queryClient: ReturnType, event: RealtimeEvent, ) { if (event.operation === 'refresh') { // 仅大厅全局配置变化走全量刷新;不清缓存,旧列表保持展示避免误点。 void queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }) return } const workOrderId = Number(event.entityId || 0) const pendingTimer = pendingHallUpsertTimers.get(workOrderId) if (pendingTimer) { clearTimeout(pendingTimer) pendingHallUpsertTimers.delete(workOrderId) } if (event.operation === 'remove') { applyWorkerHallCachePatch(queryClient, event) return } // 服务端广播无法按打手等级过滤可见延迟;未到解锁时间的订单推迟入缓存,与列表接口和抢单校验对齐。 const delayMs = resolveHallVisibilityDelayMs(queryClient, event.data, event.occurredAt) if (delayMs > 0) { if (delayMs <= HALL_BROADCAST_MAX_SCHEDULE_MS) { const timer = setTimeout(() => { pendingHallUpsertTimers.delete(workOrderId) applyWorkerHallCachePatch(queryClient, event) }, delayMs) pendingHallUpsertTimers.set(workOrderId, timer) } return } applyWorkerHallCachePatch(queryClient, event) } function applyWorkerHallCachePatch( queryClient: ReturnType, event: RealtimeEvent, ) { const workerSnapshot = applyWorkerDepositRule(queryClient, event.data) const item = workerSnapshot as { status?: unknown categoryId?: unknown productName?: unknown platformOrderId?: unknown } for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['worker-hall-orders'], })) { const keyword = String(queryKey[1] || '') const categoryId = Number(queryKey[2] || 0) const matchesCategory = !categoryId || categoryId === Number(item.categoryId || 0) // 大厅是"必须是 open"且分类可证伪:不符就该移出大厅;关键词仍按不可证真处理。 const provableMatch = String(item.status || '') === 'open' && matchesCategory const keywordVerdict = evaluateWorkerKeywordVerdict(item, keyword) const shouldInsert = event.operation === 'upsert' && provableMatch && keywordVerdict === 'match' const operation = event.operation === 'remove' || !provableMatch ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, patchWorkerWorkOrderInfiniteEnvelope( current, { ...event, data: workerSnapshot, operation }, shouldInsert, // 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额; // 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。 ['myShare', 'myShares'], // 大厅展示顺序是「置顶优先,再按工单号倒序」(与列表 SQL 的 // `pinned_at DESC NULLS LAST, id DESC` 同口径)。广播补丁只做就地覆盖 // 或顶部插入,不重排缓存,这里传入排序比较器,保证新插入的行 // 和快照里带 pinnedAt 的行按大厅口径归位,不会跑到置顶行前面。 compareWorkerHallOrderRows, ), ) } } /** 订单入厅时间 + 当前打手等级的可见延迟尚未到期时,返回需要推迟的毫秒数。 */ function resolveHallVisibilityDelayMs( queryClient: ReturnType, data: unknown, occurredAt: string | undefined, ): number { if (!isRecord(data)) return 0 const queuedAtMs = Date.parse(String(data.hallQueuedAt || '')) if (!Number.isFinite(queuedAtMs)) return 0 const visibleDelaySeconds = readWorkerHallVisibleDelaySeconds(queryClient) if (visibleDelaySeconds <= 0) return 0 const unlockAtMs = queuedAtMs + visibleDelaySeconds * 1000 // 入厅时间是服务器时钟,用事件的服务器时间戳校正浏览器钟差:快钟提前展示会重现“看得见抢不了”。 const occurredAtMs = occurredAt ? Date.parse(occurredAt) : Number.NaN const serverNowMs = Number.isFinite(occurredAtMs) ? occurredAtMs : Date.now() const clockSkewMs = Date.now() - serverNowMs return unlockAtMs - (Date.now() - clockSkewMs) } /** 大厅列表响应自带当前打手的 visibleDelaySeconds,从已缓存分页读取,无需额外接口。 */ function readWorkerHallVisibleDelaySeconds(queryClient: ReturnType): number { for (const [, cached] of queryClient.getQueriesData({ queryKey: ['worker-hall-orders'] })) { const pages = (cached as { pages?: unknown } | undefined)?.pages if (!Array.isArray(pages)) continue for (const page of pages) { const delaySeconds = Number( (page as { data?: { visibleDelaySeconds?: unknown } } | null)?.data?.visibleDelaySeconds || 0, ) if (delaySeconds > 0) return delaySeconds } } return 0 } function applyWorkerDepositRule(queryClient: ReturnType, data: unknown) { if (!isRecord(data)) return data const profile = queryClient.getQueryData(['worker-profile']) const depositFreeAmount = isRecord(profile) && isRecord(profile.data) && isRecord(profile.data.worker) ? Number( (profile.data.worker.level as { permissions?: { depositFreeAmount?: unknown } } | null) ?.permissions?.depositFreeAmount || 0, ) : 0 const requiredDepositAmount = Number(data.requiredDepositAmount || 0) return { ...data, depositFreeAmount, freezeDepositAmount: Math.max(0, requiredDepositAmount - depositFreeAmount), } } 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) => { if (itemIndex !== index) return item if (!isRecord(item) || !isRecord(nextItem)) return nextItem // 大厅广播是公共快照,合并后保留当前打手私有的 myShare 等字段。 return { ...item, ...nextItem } }) } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/orders/after-sales')) return '/orders/after-sales' if (pathname.startsWith('/orders')) return '/orders' if (pathname.startsWith('/consignment')) return '/consignment' if (pathname.startsWith('/profile')) return '/profile' return '/hall' } function formatWorkerStatus(status: string) { if (status === 'active') return '已启用' if (status === 'pending_review') return '待处理' if (status === 'rejected') return '已冻结' if (status === 'disabled') return '已停用' return '未登录' }