import { ContainerOutlined, LogoutOutlined, SafetyCertificateOutlined, ShoppingOutlined, UserOutlined, } from '@ant-design/icons' import { App, Button, Layout, Menu, Typography } from 'antd' import { useQueryClient } from '@tanstack/react-query' import type { MenuProps } from 'antd' import { Outlet, useLocation, useNavigate } from 'react-router' import { logoutWorker } from '@/services/worker' import { useIsMobile } from '@/utils/use-is-mobile' import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/worker-auth' import { getWorkerToken } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' 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: '/profile', icon: , label: '个人中心' }, ] const tabItems = [ { key: '/hall', icon: ShoppingOutlined, label: '抢单大厅' }, { key: '/orders', icon: ContainerOutlined, 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) useRealtimeEvents({ url: '/api/v1/worker/realtime', token: getWorkerToken(), onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient), }) 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 (
王大锤 接单平台
{username} {formatWorkerStatus(status)}
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 }) 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 } const matchesStatus = !status || status === String(item.status || '') const matchesKeyword = matchesWorkOrderKeyword(item, keyword) const matches = matchesStatus && matchesKeyword const shouldInsert = event.operation === 'upsert' && page === 1 && matches const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, patchWorkOrderListEnvelope(current, { ...event, operation }, 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) const matches = String(item.status || '') === 'open' && matchesCategory && matchesWorkOrderKeyword(item, keyword) const shouldInsert = event.operation === 'upsert' && matches const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, patchInfiniteWorkOrderEnvelope( current, { ...event, data: workerSnapshot, operation }, shouldInsert, ), ) } } /** 订单入厅时间 + 当前打手等级的可见延迟尚未到期时,返回需要推迟的毫秒数。 */ 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, freezeDepositAmount: Math.max(0, requiredDepositAmount - depositFreeAmount), } } function matchesWorkOrderKeyword( item: { productName?: unknown; platformOrderId?: unknown; material?: unknown }, keyword: string, ) { const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return true const material = isRecord(item.material) ? item.material : {} const collect = isRecord(material.collect) ? material.collect : {} const fields = isRecord(collect.fields) ? collect.fields : {} return [ item.productName, item.platformOrderId, fields.gameId, fields.gameNickname, fields.gameAccount, fields.roleName, material.gameId, material.gameNickname, ] .map((value) => String(value || '').toLowerCase()) .some((value) => value.includes(normalizedKeyword)) } function patchWorkOrderListEnvelope(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 { 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, event.data, 'workOrderId', insert) return { ...current, data: { ...current.data, items: nextItems } } } function patchInfiniteWorkOrderEnvelope(current: unknown, event: RealtimeEvent, insert = false) { if (!isRecord(current) || !Array.isArray(current.pages)) return current return { ...current, pages: current.pages.map((page: unknown, index) => patchWorkOrderListEnvelope(page, event, insert && index === 0), ), } } 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('/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 '未登录' }