diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 65c8d970..8423c7fc 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -219,15 +219,15 @@ export function getMaxUploadSizeBytes() { * 之后任何不重签的地方过一会儿必然打不开(客服看到裂图),要每一处读取都记得重签才不会出错。 * 现在链接就是稳定的普通链接(对象 key 带随机段,不可枚举),存下来过多久都能看。 */ -export function buildFileUrl(objectKey: string) { - return buildUnsignedFileUrl(normalizeObjectKey(objectKey)) +export function buildFileUrl(objectKey: string, variant = '') { + return buildUnsignedFileUrl(normalizeObjectKey(objectKey), variant) } /** 把库里历史存的(带签名/带有效期的)链接归一化成普通链接;非文件链接原样返回。 */ -export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') { +export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '', variant = '') { const rawUrl = String(value || '').trim() const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim() - return objectKey ? buildFileUrl(objectKey) : rawUrl + return objectKey ? buildFileUrl(objectKey, variant) : rawUrl } /** @deprecated 用 refreshStoredFileUrl;两者现在完全等价(都只做归一化,不再重签)。 */ @@ -299,8 +299,10 @@ function mapUploadedFile( } } -function buildUnsignedFileUrl(objectKey: string) { - return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}` +function buildUnsignedFileUrl(objectKey: string, variant = '') { + const normalizedVariant = normalizeVariant(variant) + const variantQuery = normalizedVariant ? `&variant=${encodeURIComponent(normalizedVariant)}` : '' + return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}${variantQuery}` } export function extractObjectKeyFromFileUrl(value: string) { diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index a149dd8d..2d23aef4 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -25,6 +25,7 @@ import { isWorkOrderSharingFilled, mapFinanceRequest, mapWorkOrderAdmin, + mapWorkOrderForHall, mapWorkOrderForWorker, mapWorkerUser, resolveWorkerPermissions, @@ -324,13 +325,14 @@ async function publishWorkOrderSnapshots(input: { scopes: ['worker_hall'], operation: hallVisible ? 'upsert' : 'remove', data: hallVisible - ? { - ...mapWorkOrderForWorker(workOrder, { depositFreeAmount: 0 }), - requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), - // 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。 - hallQueuedAt: workOrder.hall_queued_at || null, - sharingProgress: { joinedQuantity, pendingSubmissionCount }, - } + ? mapWorkOrderForHall( + workOrder, + { depositFreeAmount: 0 }, + { + // 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。 + sharingProgress: { joinedQuantity, pendingSubmissionCount }, + }, + ) : { workOrderId }, broadcastWorkers: true, }) diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index f186cbad..dcac8b18 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -68,7 +68,7 @@ export async function listAdminWorkerLevels() { export async function listAdminWorkCategories() { await ensureWorkerPlatformDefaults() - return { items: (await listAllWorkCategories()).map(mapWorkCategory) } + return { items: (await listAllWorkCategories()).map((category) => mapWorkCategory(category)) } } async function generateUniqueCategoryKey(): Promise { diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 6b9162d3..3ac8325d 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -86,6 +86,7 @@ export { mapCollectLookupOrder, mapWorkCategory, mapWorkOrderAdmin, + mapWorkOrderForHall, mapWorkOrderForWorker, mapWorkOrderMaterial, mapWorkOrderPublic, diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index a6a7975d..5199250d 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -32,14 +32,19 @@ import { } from './work-order-material-mappers.js' import { resolveRequirementFields } from './work-order-requirement-fields.js' -export function mapWorkCategory(category: WorkCategoryRow | null | undefined) { +export function mapWorkCategory( + category: WorkCategoryRow | null | undefined, + options: { iconVariant?: string } = {}, +) { if (!category) return null return { categoryId: Number(category.id), categoryKey: category.category_key, name: category.name, parentId: category.parent_id ? Number(category.parent_id) : null, - iconUrl: category.icon_url ? refreshStoredFileUrl(category.icon_url) : null, + iconUrl: category.icon_url + ? refreshStoredFileUrl(category.icon_url, '', options.iconVariant || '') + : null, sortOrder: Number(category.sort_order || 100), status: category.status, createdAt: category.created_at, @@ -495,6 +500,95 @@ export function mapWorkOrderForWorker( } } +/** + * 抢单大厅专用轻量映射。 + * + * 大厅一次会加载 100+ 单,详情字段(material 原图、requirement、acceptance、 + * capabilities、取消事件等)在卡片上完全用不到,却占了单条 4KB 中的 3KB 以上。 + * 这里只保留卡片、搜索和实时增量所需的字段,并保留最小 material 供关键词匹配。 + */ +export function mapWorkOrderForHall( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, + options: { + myShare?: WorkOrderShareRow | null + sharingProgress?: { joinedQuantity: number; pendingSubmissionCount: number } + } = {}, +) { + // 大厅卡片展示的是母单剩余份数对应的金额/标题,不能按当前打手个人份额重算; + // myShare 只用于「已拼 N 份」和按钮状态。 + const mapped = mapWorkOrderForWorker(workOrder, permissions) + const material = isPlainRecord(mapped.material) ? mapped.material : {} + const collect = isPlainRecord(material.collect) ? material.collect : {} + const fields = isPlainRecord(collect.fields) ? collect.fields : {} + const keywordFields: Record = {} + for (const key of ['gameId', 'gameNickname', 'gameAccount', 'roleName']) { + const value = String(fields[key] ?? '').trim() + if (value) keywordFields[key] = value + } + const gift = 'gift' in mapped && mapped.gift ? mapped.gift : null + const myShare = options.myShare + return { + workOrderId: mapped.workOrderId, + workOrderNo: mapped.workOrderNo, + platformOrderId: mapped.platformOrderId, + productName: mapped.productName, + categoryId: mapped.categoryId, + categoryName: mapped.categoryName, + parentCategoryId: workOrder.parent_category_id ? Number(workOrder.parent_category_id) : null, + parentCategoryName: String(workOrder.parent_category_name || ''), + // 大厅展示的是「还能不能抢」,用库里的生命周期状态,不能把已有人拼单的母单映射成 in_progress。 + status: String(workOrder.status || ''), + acceptanceMode: mapped.acceptanceMode, + ...(gift + ? { + gift: { + phase: gift.phase, + rawPhase: gift.rawPhase, + cooldownHours: gift.cooldownHours, + cooldownMinutes: gift.cooldownMinutes, + friendAddedAt: gift.friendAddedAt, + giftAvailableAt: gift.giftAvailableAt, + }, + } + : {}), + sharing: mapped.sharing, + sharingProgress: options.sharingProgress || mapped.sharingProgress, + rewardAmount: mapped.rewardAmount, + requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), + depositFreeAmount: mapped.depositFreeAmount, + freezeDepositAmount: mapped.freezeDepositAmount, + timeoutMinutes: mapped.timeoutMinutes, + timeoutPolicy: mapped.timeoutPolicy, + deadlineAt: mapped.deadlineAt, + publishedAt: workOrder.published_at || workOrder.hall_queued_at || workOrder.created_at || null, + pinnedAt: workOrder.pinned_at, + hallQueuedAt: workOrder.hall_queued_at || null, + myShare: myShare + ? { + shareId: Number(myShare.id), + workOrderId: Number(myShare.work_order_id), + workerId: Number(myShare.worker_id), + quantity: Number(myShare.quantity || 0), + unitReward: Number(myShare.unit_reward || 0), + shareReward: Number(myShare.share_reward || 0), + shareDeposit: Number(myShare.share_deposit || 0), + status: myShare.status, + } + : null, + // 关键词搜索依赖这些字段;不返回完整 material,避免把 1~2KB 的 JSON 和图片数组带进大厅。 + material: { + collect: { fields: keywordFields }, + gameId: String(material.gameId ?? ''), + gameNickname: String(material.gameNickname ?? ''), + }, + } +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */ export function resolveWorkerShareProductName(productName: unknown, quantity: number): string { const text = String(productName || '').trim() diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index dfae9a6c..f51a741c 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -11,8 +11,7 @@ import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js' import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { mapWorkCategory, - mapWorkOrderForWorker, - mapWorkOrderShare, + mapWorkOrderForHall, normalizeOptionalId, resolveWorkerPermissions, } from './mappers.js' @@ -53,6 +52,8 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const { items, total } = await listWorkOrders({ page, pageSize, + // 无限滚动只需要第一页的总数;后续页再 COUNT 一遍候选集纯属重复开销。 + includeTotal: page === 1, statuses: [WORK_ORDER_STATUS.OPEN], keyword, categoryId: categoryId || 0, @@ -62,9 +63,18 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work excludeFilledSharing: true, pinnedFirst: true, }) - const categories = (await listWorkCategories()).map(mapWorkCategory) + // 分类是全量配置,只在第一页返回;前端另有 /hall/categories 接口。 + // 图标走 thumb 变体(缺失时服务端自动回退原图),避免 42px 图标下载 200~700KB 原图。 + const categories = + page === 1 + ? (await listWorkCategories()).map((category) => + mapWorkCategory(category, { iconVariant: 'thumb' }), + ) + : [] + // 非拼单页不必再查拼单表。 + const hasSharingOrders = items.some((item) => item.sharing_enabled === true) const orderIds = items.map((item) => Number(item.id)) - const shares = await listWorkOrderSharesByOrderIds(orderIds) + const shares = hasSharingOrders ? await listWorkOrderSharesByOrderIds(orderIds) : [] const myShareByOrderId = new Map( shares .filter( @@ -87,27 +97,23 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work if (share.status === 'joined') progress.pendingSubmissionCount += 1 sharingProgressByOrderId.set(orderId, progress) } - const isInternalWorker = String(worker.worker_type || '').trim() === 'internal' return { items: items.map((item) => { - const myShare = myShareByOrderId.get(Number(item.id)) - const mappedOrder = mapWorkOrderForWorker(item, permissions) - return { - ...mappedOrder, - publishedAt: isInternalWorker - ? item.published_at || item.hall_queued_at || item.created_at || null - : null, - myShare: myShare ? mapWorkOrderShare(myShare) : null, - sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || { + const orderId = Number(item.id) + return mapWorkOrderForHall(item, permissions, { + myShare: myShareByOrderId.get(orderId) || null, + sharingProgress: sharingProgressByOrderId.get(orderId) || { joinedQuantity: 0, pendingSubmissionCount: 0, }, - } + }) }), pagination: { page, pageSize, total }, categories, showSubcategories: hallConfig.showSubcategories, - allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null, + allIconUrl: hallConfig.allIconUrl + ? refreshStoredFileUrl(hallConfig.allIconUrl, '', 'thumb') + : null, visibleDelaySeconds, } }) @@ -116,11 +122,15 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work export async function getWorkerHallCategories(session: WorkerSession) { requireActiveWorkerSession(session) const hallConfig = getWorkerHallConfig() - const categories = (await listWorkCategories()).map(mapWorkCategory) + const categories = (await listWorkCategories()).map((category) => + mapWorkCategory(category, { iconVariant: 'thumb' }), + ) return { categories, showSubcategories: hallConfig.showSubcategories, - allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null, + allIconUrl: hallConfig.allIconUrl + ? refreshStoredFileUrl(hallConfig.allIconUrl, '', 'thumb') + : null, } } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 92899afc..e2d839e3 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -37,6 +37,7 @@ import { normalizeRequirementFields, normalizeWorkerFreezeReason, mapWorkOrderForWorker, + mapWorkOrderForHall, mapWorkOrderAdmin, mapWorkOrderSharingWorkers, mapFinanceRequest, @@ -1720,6 +1721,53 @@ test('mapWorkOrderForWorker reflects the personal first-view state', () => { assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined) }) +test('mapWorkOrderForHall 只返回大厅卡片字段,并保留大厅生命周期状态', () => { + const workOrder = buildWorkOrderRow({ + status: 'open', + product_name: '指挥官秘钥15个', + reward_amount: 10_500, + required_deposit_amount: 2_000, + sharing_enabled: true, + sharing_total_quantity: 15, + sharing_unit_reward: 700, + active_share_quantity: 1, + material_json: { + source: { orderId: 1, extra: 'x'.repeat(500) }, + collect: { + fields: { + gameId: '123456', + gameNickname: '测试昵称', + gameAccount: 'acc', + roleName: 'role', + }, + screenshots: [{ url: 'https://example.com/a.png' }], + }, + }, + requirement_json: { fields: [{ key: 'gameId', label: '游戏编号' }] }, + acceptance_json: { files: [{ url: 'https://example.com/b.png' }] }, + }) + const share = buildWorkOrderShareRow() + const mapped = mapWorkOrderForHall(workOrder, resolveWorkerPermissions(buildWorkerUserRow()), { + myShare: share, + sharingProgress: { joinedQuantity: 1, pendingSubmissionCount: 0 }, + }) + + assert.equal(mapped.status, 'open') + assert.equal(mapped.productName, '指挥官秘钥15个', '大厅标题不能按个人份额改成 2 个') + assert.equal(mapped.rewardAmount, 10_500, '大厅报酬按母单剩余总额展示') + assert.equal(mapped.myShare?.quantity, 2) + assert.deepEqual(mapped.material.collect.fields, { + gameId: '123456', + gameNickname: '测试昵称', + gameAccount: 'acc', + roleName: 'role', + }) + assert.equal('requirement' in mapped, false) + assert.equal('acceptance' in mapped, false) + assert.equal('capabilities' in mapped, false) + assert.ok(JSON.stringify(mapped).length < 1500, '大厅单条快照应保持轻量') +}) + test('mapWorkOrderForWorker 使用个人拼单份数、金额、状态和接单时间', () => { const workOrder = buildWorkOrderRow({ product_name: '指挥官秘钥15个', diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 3fa0f6e5..2e9d5e3e 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -33,7 +33,7 @@ import { Tooltip, Typography, } from 'antd' -import { useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { Link } from 'react-router' import { WorkerContactModal } from '@/components/worker/WorkerContactModal' @@ -59,6 +59,7 @@ import { isGrabConfirmEnabled, setGrabConfirmEnabled as setGrabConfirmEnabledStorage, } from '@/utils/worker-grab-confirm' +import { patchWorkerWorkOrderInfiniteEnvelope } from '@/utils/worker-realtime-work-orders' import { resolveHallDepositAmount, resolveHallProductName, @@ -91,18 +92,9 @@ export default function WorkerHallPage() { const [sharingQuantity, setSharingQuantity] = useState(1) const [submittingSharing, setSubmittingSharing] = useState(false) const [failedImageKeys, setFailedImageKeys] = useState>({}) - const [confirmOpen, setConfirmOpen] = useState(false) const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled) - const [nowMs, setNowMs] = useState(() => Date.now()) const loadMoreRef = useRef(null) - useEffect(() => { - const timer = setInterval(() => { - setNowMs(Date.now()) - }, 30_000) - return () => clearInterval(timer) - }, []) - const categoriesQuery = useQuery({ queryKey: ['worker-hall-categories'], queryFn: () => fetchWorkerHallCategories(), @@ -122,11 +114,11 @@ export default function WorkerHallPage() { pageSize: HALL_PAGE_SIZE, categoryId: effectiveCategoryId || undefined, }), - getNextPageParam: (lastPage) => { + getNextPageParam: (lastPage, allPages) => { const pagination = lastPage.data.pagination - return pagination.page * pagination.pageSize < pagination.total - ? pagination.page + 1 - : undefined + // 只有第一页会做 COUNT;后续页用第一页的 total 判断是否还有下一页。 + const total = Number(allPages[0]?.data.pagination.total || pagination.total || 0) + return pagination.page * pagination.pageSize < total ? pagination.page + 1 : undefined }, placeholderData: keepPreviousData, staleTime: 2_000, @@ -278,7 +270,29 @@ export default function WorkerHallPage() { ordersQuery.isFetchingNextPage, ]) - async function refreshAll() { + const removeHallOrderLocally = useCallback( + (workOrderId: number) => { + queryClient.setQueriesData({ queryKey: ['worker-hall-orders'] }, (current: unknown) => + patchWorkerWorkOrderInfiniteEnvelope(current, { + operation: 'remove', + entityId: workOrderId, + data: { workOrderId }, + }), + ) + }, + [queryClient], + ) + + const refreshAfterMutation = useCallback(async () => { + // 抢单/拼单只改变「我的订单」和钱包,不再把已加载的 5~7 页大厅全部重拉一遍。 + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['worker-profile'] }), + queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }), + queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }), + ]) + }, [queryClient]) + + const refreshAll = useCallback(async () => { await Promise.all([ ordersQuery.refetch(), queryClient.invalidateQueries({ queryKey: ['worker-hall-categories'] }), @@ -287,58 +301,81 @@ export default function WorkerHallPage() { queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }), queryClient.invalidateQueries({ queryKey: ['worker-hall-leaderboard'] }), ]) - } + }, [ordersQuery.refetch, queryClient]) function toggleGrabConfirm(checked: boolean) { setGrabConfirmEnabled(checked) setGrabConfirmEnabledStorage(checked) } - function requestGrab(order: WorkOrder) { - if (!grabConfirmEnabled) { - void submitGrab(order) - return - } - modal.confirm({ - title: '确认抢单', - icon: , - content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney( - resolveHallRewardAmount(order), - )}`, - okText: '确认抢单', - cancelText: '取消', - onOk: () => submitGrab(order), - }) - } + const submitGrab = useCallback( + async (order: WorkOrder) => { + setGrabbingId(order.workOrderId) + try { + await grabWorkerOrder(order.workOrderId) + message.success('抢单成功,已进入我的订单') + removeHallOrderLocally(order.workOrderId) + await refreshAfterMutation() + } catch (error) { + message.error(error instanceof Error ? error.message : '抢单失败') + } finally { + setGrabbingId(0) + } + }, + [message, refreshAfterMutation, removeHallOrderLocally], + ) - async function submitGrab(order: WorkOrder) { - setGrabbingId(order.workOrderId) - try { - await grabWorkerOrder(order.workOrderId) - message.success('抢单成功,已进入我的订单') - await refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '抢单失败') - } finally { - setGrabbingId(0) - } - } + const requestGrab = useCallback( + (order: WorkOrder) => { + if (!grabConfirmEnabled) { + void submitGrab(order) + return + } + modal.confirm({ + title: '确认抢单', + icon: , + content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney( + resolveHallRewardAmount(order), + )}`, + okText: '确认抢单', + cancelText: '取消', + onOk: () => submitGrab(order), + }) + }, + [grabConfirmEnabled, modal, submitGrab], + ) - function openSharingModal(order: WorkOrder) { + const openSharingModal = useCallback((order: WorkOrder) => { setSharingOrder(order) setSharingQuantity(1) - } + }, []) async function submitSharing() { if (!sharingOrder) return setSubmittingSharing(true) try { - await joinWorkerSharingOrder(sharingOrder.workOrderId, { + const result = await joinWorkerSharingOrder(sharingOrder.workOrderId, { quantity: sharingQuantity, }) message.success('拼单成功,已进入我的订单') + const currentProgress = sharingOrder.sharingProgress + queryClient.setQueriesData({ queryKey: ['worker-hall-orders'] }, (current: unknown) => + patchWorkerWorkOrderInfiniteEnvelope(current, { + operation: 'upsert', + entityId: sharingOrder.workOrderId, + data: { + ...sharingOrder, + myShare: { quantity: Number(result.data.share?.quantity || sharingQuantity) }, + sharingProgress: { + joinedQuantity: + Number(currentProgress?.joinedQuantity || 0) + Number(sharingQuantity || 0), + pendingSubmissionCount: Number(currentProgress?.pendingSubmissionCount || 0) + 1, + }, + }, + }), + ) setSharingOrder(null) - await refreshAll() + await refreshAfterMutation() } catch (error) { message.error(error instanceof Error ? error.message : '拼单失败') } finally { @@ -620,118 +657,18 @@ export default function WorkerHallPage() { } /> ) : ( - orders.map((order) => { - const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) - const productName = resolveHallProductName(order) || '未命名商品' - const sharingRemaining = resolveSharingRemaining(order) - - return ( -
- {/* 卡片头部: 分类 + 拼单/时限标签 + 订单号 */} -
-
- {order.pinnedAt ? ( - } - className="worker-hall-mobile-tag" - > - 置顶 - - ) : null} - {order.categoryName ? ( - - {order.parentCategoryName - ? `${order.parentCategoryName} · ${order.categoryName}` - : order.categoryName} - - ) : null} - {isInternalWorker && order.publishedAt ? ( - - } - className="worker-hall-published-tag" - > - {formatPublishedElapsedTime(order.publishedAt, nowMs)} - - - ) : null} - {order.sharing?.enabled ? ( - - 拼单 {order.sharingProgress?.joinedQuantity || 0}/ - {order.sharing.totalQuantity} · 剩{resolveSharingRemaining(order)}份 - - ) : null} - {Number(order.timeoutMinutes || 0) > 0 ? ( - } - className="worker-hall-mobile-tag" - > - 限时{order.timeoutMinutes}分 - - ) : null} -
- - {getDisplayOrderNo(order)} - -
- - {/* 商品标题与保证金说明 */} -
- - {productName} - -
- 所需保证金: {formatMoney(resolveHallDepositAmount(order))} - {order.sharing?.enabled ? ( - · 拼单单价 {formatMoney(order.sharing.unitReward)}/份 - ) : null} -
-
- - {/* 底部报酬金额与抢单按钮 */} -
-
- 接单报酬 - - {formatMoney(resolveHallRewardAmount(order))} - -
- -
- {order.sharing?.enabled && - (sharingRemaining > 0 || order.myShare) && - !disabledReason ? ( - - ) : null} - {sharingRemaining > 0 || !order.sharing?.enabled ? ( - - ) : null} -
-
-
- ) - }) + orders.map((order) => ( + + )) )} {loadMoreIndicator} @@ -1058,132 +995,18 @@ export default function WorkerHallPage() { ) : ( <>
- {orders.map((order) => { - const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) - const productName = resolveHallProductName(order) || '未命名商品' - const sharingRemaining = resolveSharingRemaining(order) - const depositAmount = resolveHallDepositAmount(order) - const rewardAmount = resolveHallRewardAmount(order) - - return ( -
-
-
- {order.pinnedAt ? ( - } - className="worker-hall-pinned-tag" - > - 置顶 - - ) : null} - - {order.parentCategoryName - ? `${order.parentCategoryName} · ${order.categoryName}` - : order.categoryName || '默认分类'} - - {isInternalWorker && order.publishedAt ? ( - - } - className="worker-hall-published-tag" - > - {formatPublishedElapsedTime(order.publishedAt, nowMs)} - - - ) : null} - {order.sharing?.enabled ? 拼单 : null} - {Number(order.timeoutMinutes || 0) > 0 ? ( - }> - 限时 {order.timeoutMinutes}m - - ) : null} -
- - {getDisplayOrderNo(order)} - -
- -
- - {productName} - -
- - {order.sharing?.enabled ? ( -
- - 单价 {formatMoney(order.sharing.unitReward)} · 进度{' '} - {order.sharingProgress?.joinedQuantity || 0}/ - {order.sharing.totalQuantity} · 剩 {resolveSharingRemaining(order)} 份 - -
- ) : null} - -
-
- 接单报酬 -
- {formatMoney(rewardAmount)} -
-
-
- {depositAmount <= 0 ? ( - - 免保证金 - - ) : ( - - 保证金 {formatMoney(depositAmount)} - - )} -
-
- - {disabledReason ? ( - - {disabledReason} - - ) : null} - -
-
- {order.sharing?.enabled && - (sharingRemaining > 0 || order.myShare) && - !disabledReason ? ( - - ) : null} - {sharingRemaining > 0 || !order.sharing?.enabled ? ( - - ) : null} -
-
-
- ) - })} + {orders.map((order) => ( + + ))}
{loadMoreIndicator} @@ -1326,6 +1149,287 @@ export default function WorkerHallPage() { ) } +type HallCardProps = { + order: WorkOrder + worker: WorkerUser | undefined + remainingSlots: number | null + isInternalWorker: boolean + grabbing: boolean + onGrab: (order: WorkOrder) => void + onOpenSharing: (order: WorkOrder) => void +} + +/** + * 已加载 100+ 单时,任何一条实时变更都会替换大厅缓存中的一条订单。 + * 卡片拆成 memo 组件后,只有真正变化的那条会重渲染,其余卡片直接复用。 + */ +const HallDesktopCard = memo(function HallDesktopCard({ + order, + worker, + remainingSlots, + isInternalWorker, + grabbing, + onGrab, + onOpenSharing, +}: HallCardProps) { + const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) + const productName = resolveHallProductName(order) || '未命名商品' + const sharingRemaining = resolveHallRemainingQuantity(order) + const depositAmount = resolveHallDepositAmount(order) + const rewardAmount = resolveHallRewardAmount(order) + + return ( +
+
+
+ {order.pinnedAt ? ( + } className="worker-hall-pinned-tag"> + 置顶 + + ) : null} + + {order.parentCategoryName + ? `${order.parentCategoryName} · ${order.categoryName}` + : order.categoryName || '默认分类'} + + {isInternalWorker && order.publishedAt ? ( + + ) : null} + {order.sharing?.enabled ? 拼单 : null} + {Number(order.timeoutMinutes || 0) > 0 ? ( + }> + 限时 {order.timeoutMinutes}m + + ) : null} +
+ + {getDisplayOrderNo(order)} + +
+ +
+ + {productName} + +
+ + {order.sharing?.enabled ? ( +
+ + 单价 {formatMoney(order.sharing.unitReward)} · 进度{' '} + {order.sharingProgress?.joinedQuantity || 0}/{order.sharing.totalQuantity} · 剩{' '} + {sharingRemaining} 份 + +
+ ) : null} + +
+
+ 接单报酬 +
{formatMoney(rewardAmount)}
+
+
+ {depositAmount <= 0 ? ( + + 免保证金 + + ) : ( + 保证金 {formatMoney(depositAmount)} + )} +
+
+ + {disabledReason ? ( + + {disabledReason} + + ) : null} + +
+
+ {order.sharing?.enabled && (sharingRemaining > 0 || order.myShare) && !disabledReason ? ( + + ) : null} + {sharingRemaining > 0 || !order.sharing?.enabled ? ( + + ) : null} +
+
+
+ ) +}) + +const HallMobileCard = memo(function HallMobileCard({ + order, + worker, + remainingSlots, + isInternalWorker, + grabbing, + onGrab, + onOpenSharing, +}: HallCardProps) { + const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) + const productName = resolveHallProductName(order) || '未命名商品' + const sharingRemaining = resolveHallRemainingQuantity(order) + + return ( +
+
+
+ {order.pinnedAt ? ( + } className="worker-hall-mobile-tag"> + 置顶 + + ) : null} + {order.categoryName ? ( + + {order.parentCategoryName + ? `${order.parentCategoryName} · ${order.categoryName}` + : order.categoryName} + + ) : null} + {isInternalWorker && order.publishedAt ? ( + + ) : null} + {order.sharing?.enabled ? ( + + 拼单 {order.sharingProgress?.joinedQuantity || 0}/{order.sharing.totalQuantity} · 剩 + {sharingRemaining}份 + + ) : null} + {Number(order.timeoutMinutes || 0) > 0 ? ( + } className="worker-hall-mobile-tag"> + 限时{order.timeoutMinutes}分 + + ) : null} +
+ {getDisplayOrderNo(order)} +
+ +
+ + {productName} + +
+ 所需保证金: {formatMoney(resolveHallDepositAmount(order))} + {order.sharing?.enabled ? ( + · 拼单单价 {formatMoney(order.sharing.unitReward)}/份 + ) : null} +
+
+ +
+
+ 接单报酬 + + {formatMoney(resolveHallRewardAmount(order))} + +
+ +
+ {order.sharing?.enabled && (sharingRemaining > 0 || order.myShare) && !disabledReason ? ( + + ) : null} + {sharingRemaining > 0 || !order.sharing?.enabled ? ( + + ) : null} +
+
+
+ ) +}) + +const HALL_CLOCK_INTERVAL_MS = 30_000 +let hallClockNow = Date.now() +let hallClockTimer: ReturnType | null = null +const hallClockListeners = new Set<() => void>() + +function subscribeHallClock(listener: () => void) { + hallClockListeners.add(listener) + if (!hallClockTimer) { + hallClockNow = Date.now() + hallClockTimer = setInterval(() => { + hallClockNow = Date.now() + for (const current of hallClockListeners) current() + }, HALL_CLOCK_INTERVAL_MS) + } + return () => { + hallClockListeners.delete(listener) + if (hallClockListeners.size === 0 && hallClockTimer) { + clearInterval(hallClockTimer) + hallClockTimer = null + } + } +} + +function getHallClockSnapshot() { + return hallClockNow +} + +/** 发布时间每 30 秒刷新一次,但只重渲染这个 Tag,不再带着整页 100+ 卡片一起渲染。 */ +const HallPublishedTag = memo(function HallPublishedTag({ + publishedAt, + className, +}: { + publishedAt: string + className?: string +}) { + const nowMs = useSyncExternalStore(subscribeHallClock, getHallClockSnapshot, getHallClockSnapshot) + return ( + } + className={className} + title={`发布时间: ${formatDateTime(publishedAt)}`} + > + {formatPublishedElapsedTime(publishedAt, nowMs)} + + ) +}) + function getCategoryGradient(name: string, id: number): string { const gradients = [ 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index b0dbb020..5d3b89b7 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -1131,6 +1131,9 @@ justify-content: space-between; gap: 10px; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + /* 100+ 单时跳过屏幕外卡片的布局与绘制,滚动到附近再渲染。 */ + content-visibility: auto; + contain-intrinsic-size: auto 240px; } .worker-hall-card:hover { @@ -2599,6 +2602,8 @@ body { flex-direction: column; gap: 10px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); + content-visibility: auto; + contain-intrinsic-size: auto 190px; } .worker-hall-mobile-card-top { diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 2fa19f5f..01c414f3 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -127,7 +127,15 @@ export function patchWorkerWorkOrderListEnvelope( (entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id, ) if (index >= 0) { - nextItems = items.map((entry, itemIndex) => (itemIndex === index ? event.data : entry)) + // 大厅广播是轻量快照,可能不含 myShare 等个性化字段;就地合并而不是整行替换。 + nextItems = items.map((entry, itemIndex) => + itemIndex === index + ? { + ...(isRecord(entry) ? entry : {}), + ...(isRecord(event.data) ? event.data : {}), + } + : entry, + ) } else if (insert) { nextItems = [event.data, ...items] } else { diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 192984b6..41cade1e 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -65,6 +65,28 @@ test('按工单号搜到的行,实时事件来了也不能被删掉,并且 assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') }) +test('大厅轻量快照就地更新时保留 myShare 等个性化字段', () => { + const envelope = buildEnvelope([ + buildRow({ status: 'open', myShare: { quantity: 2 }, categoryName: '密钥' }), + ]) + const updated = patchWorkerWorkOrderListEnvelope( + envelope, + { + operation: 'upsert', + data: { workOrderId: 9001, status: 'open', productName: '新商品名' }, + }, + false, + ) + const item = itemsOf(updated)[0] as { + productName?: string + myShare?: { quantity?: number } + categoryName?: string + } + assert.equal(item.productName, '新商品名') + assert.equal(item.myShare?.quantity, 2, '广播不带 myShare 时不能把已拼份额覆盖掉') + assert.equal(item.categoryName, '密钥') +}) + test('不可证真的关键词下,快照只能就地更新:不插入、不删除', () => { const envelope = buildEnvelope([buildRow()]) // 关键词没命中前端已知字段(unknown):既不该把它当"不匹配"删掉