Merge remote-tracking branch 'gitea-aliyun/main' into dev

This commit is contained in:
yml2213
2026-10-25 11:14:12 +08:00
11 changed files with 617 additions and 321 deletions
@@ -219,15 +219,15 @@ export function getMaxUploadSizeBytes() {
* 之后任何不重签的地方过一会儿必然打不开(客服看到裂图),要每一处读取都记得重签才不会出错。 * 之后任何不重签的地方过一会儿必然打不开(客服看到裂图),要每一处读取都记得重签才不会出错。
* 现在链接就是稳定的普通链接(对象 key 带随机段,不可枚举),存下来过多久都能看。 * 现在链接就是稳定的普通链接(对象 key 带随机段,不可枚举),存下来过多久都能看。
*/ */
export function buildFileUrl(objectKey: string) { export function buildFileUrl(objectKey: string, variant = '') {
return buildUnsignedFileUrl(normalizeObjectKey(objectKey)) return buildUnsignedFileUrl(normalizeObjectKey(objectKey), variant)
} }
/** 把库里历史存的(带签名/带有效期的)链接归一化成普通链接;非文件链接原样返回。 */ /** 把库里历史存的(带签名/带有效期的)链接归一化成普通链接;非文件链接原样返回。 */
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') { export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '', variant = '') {
const rawUrl = String(value || '').trim() const rawUrl = String(value || '').trim()
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim() const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
return objectKey ? buildFileUrl(objectKey) : rawUrl return objectKey ? buildFileUrl(objectKey, variant) : rawUrl
} }
/** @deprecated 用 refreshStoredFileUrl;两者现在完全等价(都只做归一化,不再重签)。 */ /** @deprecated 用 refreshStoredFileUrl;两者现在完全等价(都只做归一化,不再重签)。 */
@@ -299,8 +299,10 @@ function mapUploadedFile(
} }
} }
function buildUnsignedFileUrl(objectKey: string) { function buildUnsignedFileUrl(objectKey: string, variant = '') {
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}` 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) { export function extractObjectKeyFromFileUrl(value: string) {
@@ -25,6 +25,7 @@ import {
isWorkOrderSharingFilled, isWorkOrderSharingFilled,
mapFinanceRequest, mapFinanceRequest,
mapWorkOrderAdmin, mapWorkOrderAdmin,
mapWorkOrderForHall,
mapWorkOrderForWorker, mapWorkOrderForWorker,
mapWorkerUser, mapWorkerUser,
resolveWorkerPermissions, resolveWorkerPermissions,
@@ -324,13 +325,14 @@ async function publishWorkOrderSnapshots(input: {
scopes: ['worker_hall'], scopes: ['worker_hall'],
operation: hallVisible ? 'upsert' : 'remove', operation: hallVisible ? 'upsert' : 'remove',
data: hallVisible data: hallVisible
? { ? mapWorkOrderForHall(
...mapWorkOrderForWorker(workOrder, { depositFreeAmount: 0 }), workOrder,
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), { depositFreeAmount: 0 },
// 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。 {
hallQueuedAt: workOrder.hall_queued_at || null, // 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。
sharingProgress: { joinedQuantity, pendingSubmissionCount }, sharingProgress: { joinedQuantity, pendingSubmissionCount },
} },
)
: { workOrderId }, : { workOrderId },
broadcastWorkers: true, broadcastWorkers: true,
}) })
@@ -69,7 +69,7 @@ export async function listAdminWorkerLevels() {
export async function listAdminWorkCategories() { export async function listAdminWorkCategories() {
await ensureWorkerPlatformDefaults() await ensureWorkerPlatformDefaults()
return { items: (await listAllWorkCategories()).map(mapWorkCategory) } return { items: (await listAllWorkCategories()).map((category) => mapWorkCategory(category)) }
} }
async function generateUniqueCategoryKey(): Promise<string> { async function generateUniqueCategoryKey(): Promise<string> {
@@ -86,6 +86,7 @@ export {
mapCollectLookupOrder, mapCollectLookupOrder,
mapWorkCategory, mapWorkCategory,
mapWorkOrderAdmin, mapWorkOrderAdmin,
mapWorkOrderForHall,
mapWorkOrderForWorker, mapWorkOrderForWorker,
mapWorkOrderMaterial, mapWorkOrderMaterial,
mapWorkOrderPublic, mapWorkOrderPublic,
@@ -32,14 +32,19 @@ import {
} from './work-order-material-mappers.js' } from './work-order-material-mappers.js'
import { resolveRequirementFields } from './work-order-requirement-fields.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 if (!category) return null
return { return {
categoryId: Number(category.id), categoryId: Number(category.id),
categoryKey: category.category_key, categoryKey: category.category_key,
name: category.name, name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null, 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), sortOrder: Number(category.sort_order || 100),
status: category.status, status: category.status,
createdAt: category.created_at, 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<string, string> = {}
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */ /** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */
export function resolveWorkerShareProductName(productName: unknown, quantity: number): string { export function resolveWorkerShareProductName(productName: unknown, quantity: number): string {
const text = String(productName || '').trim() const text = String(productName || '').trim()
@@ -11,8 +11,7 @@ import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
import { import {
mapWorkCategory, mapWorkCategory,
mapWorkOrderForWorker, mapWorkOrderForHall,
mapWorkOrderShare,
normalizeOptionalId, normalizeOptionalId,
resolveWorkerPermissions, resolveWorkerPermissions,
} from './mappers.js' } from './mappers.js'
@@ -53,6 +52,8 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const { items, total } = await listWorkOrders({ const { items, total } = await listWorkOrders({
page, page,
pageSize, pageSize,
// 无限滚动只需要第一页的总数;后续页再 COUNT 一遍候选集纯属重复开销。
includeTotal: page === 1,
statuses: [WORK_ORDER_STATUS.OPEN], statuses: [WORK_ORDER_STATUS.OPEN],
keyword, keyword,
categoryId: categoryId || 0, categoryId: categoryId || 0,
@@ -62,9 +63,18 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
excludeFilledSharing: true, excludeFilledSharing: true,
pinnedFirst: 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 orderIds = items.map((item) => Number(item.id))
const shares = await listWorkOrderSharesByOrderIds(orderIds) const shares = hasSharingOrders ? await listWorkOrderSharesByOrderIds(orderIds) : []
const myShareByOrderId = new Map( const myShareByOrderId = new Map(
shares shares
.filter( .filter(
@@ -87,27 +97,23 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
if (share.status === 'joined') progress.pendingSubmissionCount += 1 if (share.status === 'joined') progress.pendingSubmissionCount += 1
sharingProgressByOrderId.set(orderId, progress) sharingProgressByOrderId.set(orderId, progress)
} }
const isInternalWorker = String(worker.worker_type || '').trim() === 'internal'
return { return {
items: items.map((item) => { items: items.map((item) => {
const myShare = myShareByOrderId.get(Number(item.id)) const orderId = Number(item.id)
const mappedOrder = mapWorkOrderForWorker(item, permissions) return mapWorkOrderForHall(item, permissions, {
return { myShare: myShareByOrderId.get(orderId) || null,
...mappedOrder, sharingProgress: sharingProgressByOrderId.get(orderId) || {
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)) || {
joinedQuantity: 0, joinedQuantity: 0,
pendingSubmissionCount: 0, pendingSubmissionCount: 0,
}, },
} })
}), }),
pagination: { page, pageSize, total }, pagination: { page, pageSize, total },
categories, categories,
showSubcategories: hallConfig.showSubcategories, showSubcategories: hallConfig.showSubcategories,
allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null, allIconUrl: hallConfig.allIconUrl
? refreshStoredFileUrl(hallConfig.allIconUrl, '', 'thumb')
: null,
visibleDelaySeconds, visibleDelaySeconds,
} }
}) })
@@ -116,11 +122,15 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
export async function getWorkerHallCategories(session: WorkerSession) { export async function getWorkerHallCategories(session: WorkerSession) {
requireActiveWorkerSession(session) requireActiveWorkerSession(session)
const hallConfig = getWorkerHallConfig() const hallConfig = getWorkerHallConfig()
const categories = (await listWorkCategories()).map(mapWorkCategory) const categories = (await listWorkCategories()).map((category) =>
mapWorkCategory(category, { iconVariant: 'thumb' }),
)
return { return {
categories, categories,
showSubcategories: hallConfig.showSubcategories, showSubcategories: hallConfig.showSubcategories,
allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null, allIconUrl: hallConfig.allIconUrl
? refreshStoredFileUrl(hallConfig.allIconUrl, '', 'thumb')
: null,
} }
} }
@@ -37,6 +37,7 @@ import {
normalizeRequirementFields, normalizeRequirementFields,
normalizeWorkerFreezeReason, normalizeWorkerFreezeReason,
mapWorkOrderForWorker, mapWorkOrderForWorker,
mapWorkOrderForHall,
mapWorkOrderAdmin, mapWorkOrderAdmin,
mapWorkOrderSharingWorkers, mapWorkOrderSharingWorkers,
mapFinanceRequest, mapFinanceRequest,
@@ -1720,6 +1721,53 @@ test('mapWorkOrderForWorker reflects the personal first-view state', () => {
assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined) 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 使用个人拼单份数、金额、状态和接单时间', () => { test('mapWorkOrderForWorker 使用个人拼单份数、金额、状态和接单时间', () => {
const workOrder = buildWorkOrderRow({ const workOrder = buildWorkOrderRow({
product_name: '指挥官秘钥15个', product_name: '指挥官秘钥15个',
+390 -286
View File
@@ -33,7 +33,7 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from 'antd' } from 'antd'
import { useEffect, useRef, useState } from 'react' import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { Link } from 'react-router' import { Link } from 'react-router'
import { WorkerContactModal } from '@/components/worker/WorkerContactModal' import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
@@ -59,6 +59,7 @@ import {
isGrabConfirmEnabled, isGrabConfirmEnabled,
setGrabConfirmEnabled as setGrabConfirmEnabledStorage, setGrabConfirmEnabled as setGrabConfirmEnabledStorage,
} from '@/utils/worker-grab-confirm' } from '@/utils/worker-grab-confirm'
import { patchWorkerWorkOrderInfiniteEnvelope } from '@/utils/worker-realtime-work-orders'
import { import {
resolveHallDepositAmount, resolveHallDepositAmount,
resolveHallProductName, resolveHallProductName,
@@ -91,18 +92,9 @@ export default function WorkerHallPage() {
const [sharingQuantity, setSharingQuantity] = useState(1) const [sharingQuantity, setSharingQuantity] = useState(1)
const [submittingSharing, setSubmittingSharing] = useState(false) const [submittingSharing, setSubmittingSharing] = useState(false)
const [failedImageKeys, setFailedImageKeys] = useState<Record<string | number, boolean>>({}) const [failedImageKeys, setFailedImageKeys] = useState<Record<string | number, boolean>>({})
const [confirmOpen, setConfirmOpen] = useState(false)
const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled) const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled)
const [nowMs, setNowMs] = useState(() => Date.now())
const loadMoreRef = useRef<HTMLDivElement | null>(null) const loadMoreRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const timer = setInterval(() => {
setNowMs(Date.now())
}, 30_000)
return () => clearInterval(timer)
}, [])
const categoriesQuery = useQuery({ const categoriesQuery = useQuery({
queryKey: ['worker-hall-categories'], queryKey: ['worker-hall-categories'],
queryFn: () => fetchWorkerHallCategories(), queryFn: () => fetchWorkerHallCategories(),
@@ -122,11 +114,11 @@ export default function WorkerHallPage() {
pageSize: HALL_PAGE_SIZE, pageSize: HALL_PAGE_SIZE,
categoryId: effectiveCategoryId || undefined, categoryId: effectiveCategoryId || undefined,
}), }),
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage, allPages) => {
const pagination = lastPage.data.pagination const pagination = lastPage.data.pagination
return pagination.page * pagination.pageSize < pagination.total // 只有第一页会做 COUNT;后续页用第一页的 total 判断是否还有下一页。
? pagination.page + 1 const total = Number(allPages[0]?.data.pagination.total || pagination.total || 0)
: undefined return pagination.page * pagination.pageSize < total ? pagination.page + 1 : undefined
}, },
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 2_000, staleTime: 2_000,
@@ -278,7 +270,29 @@ export default function WorkerHallPage() {
ordersQuery.isFetchingNextPage, 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([ await Promise.all([
ordersQuery.refetch(), ordersQuery.refetch(),
queryClient.invalidateQueries({ queryKey: ['worker-hall-categories'] }), 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-my-orders-overview'] }),
queryClient.invalidateQueries({ queryKey: ['worker-hall-leaderboard'] }), queryClient.invalidateQueries({ queryKey: ['worker-hall-leaderboard'] }),
]) ])
} }, [ordersQuery.refetch, queryClient])
function toggleGrabConfirm(checked: boolean) { function toggleGrabConfirm(checked: boolean) {
setGrabConfirmEnabled(checked) setGrabConfirmEnabled(checked)
setGrabConfirmEnabledStorage(checked) setGrabConfirmEnabledStorage(checked)
} }
function requestGrab(order: WorkOrder) { const submitGrab = useCallback(
if (!grabConfirmEnabled) { async (order: WorkOrder) => {
void submitGrab(order) setGrabbingId(order.workOrderId)
return try {
} await grabWorkerOrder(order.workOrderId)
modal.confirm({ message.success('抢单成功,已进入我的订单')
title: '确认抢单', removeHallOrderLocally(order.workOrderId)
icon: <QuestionCircleOutlined />, await refreshAfterMutation()
content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney( } catch (error) {
resolveHallRewardAmount(order), message.error(error instanceof Error ? error.message : '抢单失败')
)}`, } finally {
okText: '确认抢单', setGrabbingId(0)
cancelText: '取消', }
onOk: () => submitGrab(order), },
}) [message, refreshAfterMutation, removeHallOrderLocally],
} )
async function submitGrab(order: WorkOrder) { const requestGrab = useCallback(
setGrabbingId(order.workOrderId) (order: WorkOrder) => {
try { if (!grabConfirmEnabled) {
await grabWorkerOrder(order.workOrderId) void submitGrab(order)
message.success('抢单成功,已进入我的订单') return
await refreshAll() }
} catch (error) { modal.confirm({
message.error(error instanceof Error ? error.message : '抢单失败') title: '确认抢单',
} finally { icon: <QuestionCircleOutlined />,
setGrabbingId(0) 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) setSharingOrder(order)
setSharingQuantity(1) setSharingQuantity(1)
} }, [])
async function submitSharing() { async function submitSharing() {
if (!sharingOrder) return if (!sharingOrder) return
setSubmittingSharing(true) setSubmittingSharing(true)
try { try {
await joinWorkerSharingOrder(sharingOrder.workOrderId, { const result = await joinWorkerSharingOrder(sharingOrder.workOrderId, {
quantity: sharingQuantity, quantity: sharingQuantity,
}) })
message.success('拼单成功,已进入我的订单') 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) setSharingOrder(null)
await refreshAll() await refreshAfterMutation()
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '拼单失败') message.error(error instanceof Error ? error.message : '拼单失败')
} finally { } finally {
@@ -620,118 +657,18 @@ export default function WorkerHallPage() {
} }
/> />
) : ( ) : (
orders.map((order) => { orders.map((order) => (
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) <HallMobileCard
const productName = resolveHallProductName(order) || '未命名商品' key={order.workOrderId}
const sharingRemaining = resolveSharingRemaining(order) order={order}
worker={worker}
return ( remainingSlots={remainingSlots}
<article key={order.workOrderId} className="worker-hall-mobile-card"> isInternalWorker={isInternalWorker}
{/* 卡片头部: 分类 + 拼单/时限标签 + 订单号 */} grabbing={grabbingId === order.workOrderId}
<div className="worker-hall-mobile-card-top"> onGrab={requestGrab}
<div className="worker-hall-mobile-card-tags"> onOpenSharing={openSharingModal}
{order.pinnedAt ? ( />
<Tag ))
color="gold"
icon={<PushpinOutlined />}
className="worker-hall-mobile-tag"
>
置顶
</Tag>
) : null}
{order.categoryName ? (
<Tag color="blue" className="worker-hall-mobile-tag">
{order.parentCategoryName
? `${order.parentCategoryName} · ${order.categoryName}`
: order.categoryName}
</Tag>
) : null}
{isInternalWorker && order.publishedAt ? (
<Tooltip title={`发布时间: ${formatDateTime(order.publishedAt)}`}>
<Tag
icon={<ClockCircleOutlined />}
className="worker-hall-published-tag"
>
{formatPublishedElapsedTime(order.publishedAt, nowMs)}
</Tag>
</Tooltip>
) : null}
{order.sharing?.enabled ? (
<Tag color="purple" className="worker-hall-mobile-tag">
拼单 {order.sharingProgress?.joinedQuantity || 0}/
{order.sharing.totalQuantity} · 剩{resolveSharingRemaining(order)}份
</Tag>
) : null}
{Number(order.timeoutMinutes || 0) > 0 ? (
<Tag
color="orange"
icon={<ClockCircleOutlined />}
className="worker-hall-mobile-tag"
>
限时{order.timeoutMinutes}分
</Tag>
) : null}
</div>
<span className="worker-hall-mobile-card-orderno">
{getDisplayOrderNo(order)}
</span>
</div>
{/* 商品标题与保证金说明 */}
<div className="worker-hall-mobile-card-main">
<strong className="worker-hall-mobile-card-title" title={productName}>
{productName}
</strong>
<div className="worker-hall-mobile-card-meta">
<span>所需保证金: {formatMoney(resolveHallDepositAmount(order))}</span>
{order.sharing?.enabled ? (
<span> · 拼单单价 {formatMoney(order.sharing.unitReward)}/份</span>
) : null}
</div>
</div>
{/* 底部报酬金额与抢单按钮 */}
<div className="worker-hall-mobile-card-footer">
<div className="worker-hall-mobile-card-price-group">
<span className="worker-hall-mobile-card-price-label">接单报酬</span>
<strong className="worker-hall-mobile-card-price-val">
{formatMoney(resolveHallRewardAmount(order))}
</strong>
</div>
<div className="worker-hall-mobile-card-btn-group">
{order.sharing?.enabled &&
(sharingRemaining > 0 || order.myShare) &&
!disabledReason ? (
<Button
type="primary"
size="middle"
className="worker-hall-sharing-button"
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
onClick={() => openSharingModal(order)}
>
{order.myShare
? `已拼 ${order.myShare.quantity} 份`
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="middle"
className="worker-hall-mobile-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => requestGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
)
})
)} )}
{loadMoreIndicator} {loadMoreIndicator}
@@ -1058,132 +995,18 @@ export default function WorkerHallPage() {
) : ( ) : (
<> <>
<div className="worker-hall-card-grid"> <div className="worker-hall-card-grid">
{orders.map((order) => { {orders.map((order) => (
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) <HallDesktopCard
const productName = resolveHallProductName(order) || '未命名商品' key={order.workOrderId}
const sharingRemaining = resolveSharingRemaining(order) order={order}
const depositAmount = resolveHallDepositAmount(order) worker={worker}
const rewardAmount = resolveHallRewardAmount(order) remainingSlots={remainingSlots}
isInternalWorker={isInternalWorker}
return ( grabbing={grabbingId === order.workOrderId}
<article key={order.workOrderId} className="worker-hall-card"> onGrab={requestGrab}
<div className="worker-hall-card-header"> onOpenSharing={openSharingModal}
<div className="worker-hall-card-tags"> />
{order.pinnedAt ? ( ))}
<Tag
color="gold"
icon={<PushpinOutlined />}
className="worker-hall-pinned-tag"
>
置顶
</Tag>
) : null}
<Tag className="worker-hall-category-tag">
{order.parentCategoryName
? `${order.parentCategoryName} · ${order.categoryName}`
: order.categoryName || '默认分类'}
</Tag>
{isInternalWorker && order.publishedAt ? (
<Tooltip title={`发布时间: ${formatDateTime(order.publishedAt)}`}>
<Tag
icon={<ClockCircleOutlined />}
className="worker-hall-published-tag"
>
{formatPublishedElapsedTime(order.publishedAt, nowMs)}
</Tag>
</Tooltip>
) : null}
{order.sharing?.enabled ? <Tag color="purple">拼单</Tag> : null}
{Number(order.timeoutMinutes || 0) > 0 ? (
<Tag color="orange" icon={<ClockCircleOutlined />}>
限时 {order.timeoutMinutes}m
</Tag>
) : null}
</div>
<span
className="worker-hall-card-orderno"
title={`订单号:${getDisplayOrderNo(order)}`}
>
{getDisplayOrderNo(order)}
</span>
</div>
<div className="worker-hall-card-body">
<strong className="worker-hall-card-title-text" title={productName}>
{productName}
</strong>
</div>
{order.sharing?.enabled ? (
<div className="worker-hall-card-sharing">
<Typography.Text type="secondary">
单价 {formatMoney(order.sharing.unitReward)} · 进度{' '}
{order.sharingProgress?.joinedQuantity || 0}/
{order.sharing.totalQuantity} · 剩 {resolveSharingRemaining(order)} 份
</Typography.Text>
</div>
) : null}
<div className="worker-hall-card-amount-row">
<div className="worker-hall-card-amount-group">
<span className="worker-hall-card-amount-label">接单报酬</span>
<div className="worker-hall-card-amount">
{formatMoney(rewardAmount)}
</div>
</div>
<div className="worker-hall-card-deposit-group">
{depositAmount <= 0 ? (
<span className="worker-hall-deposit-free-badge">
<CheckCircleOutlined /> 免保证金
</span>
) : (
<span className="worker-hall-deposit-badge">
保证金 {formatMoney(depositAmount)}
</span>
)}
</div>
</div>
{disabledReason ? (
<Typography.Text type="danger" className="worker-hall-card-hint">
{disabledReason}
</Typography.Text>
) : null}
<div className="worker-hall-card-actions">
<div className="worker-hall-card-grab-row">
{order.sharing?.enabled &&
(sharingRemaining > 0 || order.myShare) &&
!disabledReason ? (
<Button
type="primary"
size="large"
className="worker-hall-sharing-button"
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
onClick={() => openSharingModal(order)}
>
{order.myShare
? `已拼 ${order.myShare.quantity} 份`
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="large"
className="worker-hall-card-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => requestGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
)
})}
</div> </div>
{loadMoreIndicator} {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 (
<article className="worker-hall-card">
<div className="worker-hall-card-header">
<div className="worker-hall-card-tags">
{order.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />} className="worker-hall-pinned-tag">
置顶
</Tag>
) : null}
<Tag className="worker-hall-category-tag">
{order.parentCategoryName
? `${order.parentCategoryName} · ${order.categoryName}`
: order.categoryName || '默认分类'}
</Tag>
{isInternalWorker && order.publishedAt ? (
<HallPublishedTag
publishedAt={order.publishedAt}
className="worker-hall-published-tag"
/>
) : null}
{order.sharing?.enabled ? <Tag color="purple">拼单</Tag> : null}
{Number(order.timeoutMinutes || 0) > 0 ? (
<Tag color="orange" icon={<ClockCircleOutlined />}>
限时 {order.timeoutMinutes}m
</Tag>
) : null}
</div>
<span className="worker-hall-card-orderno" title={`订单号:${getDisplayOrderNo(order)}`}>
{getDisplayOrderNo(order)}
</span>
</div>
<div className="worker-hall-card-body">
<strong className="worker-hall-card-title-text" title={productName}>
{productName}
</strong>
</div>
{order.sharing?.enabled ? (
<div className="worker-hall-card-sharing">
<Typography.Text type="secondary">
单价 {formatMoney(order.sharing.unitReward)} · 进度{' '}
{order.sharingProgress?.joinedQuantity || 0}/{order.sharing.totalQuantity} · 剩{' '}
{sharingRemaining} 份
</Typography.Text>
</div>
) : null}
<div className="worker-hall-card-amount-row">
<div className="worker-hall-card-amount-group">
<span className="worker-hall-card-amount-label">接单报酬</span>
<div className="worker-hall-card-amount">{formatMoney(rewardAmount)}</div>
</div>
<div className="worker-hall-card-deposit-group">
{depositAmount <= 0 ? (
<span className="worker-hall-deposit-free-badge">
<CheckCircleOutlined /> 免保证金
</span>
) : (
<span className="worker-hall-deposit-badge">保证金 {formatMoney(depositAmount)}</span>
)}
</div>
</div>
{disabledReason ? (
<Typography.Text type="danger" className="worker-hall-card-hint">
{disabledReason}
</Typography.Text>
) : null}
<div className="worker-hall-card-actions">
<div className="worker-hall-card-grab-row">
{order.sharing?.enabled && (sharingRemaining > 0 || order.myShare) && !disabledReason ? (
<Button
type="primary"
size="large"
className="worker-hall-sharing-button"
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
onClick={() => onOpenSharing(order)}
>
{order.myShare
? `已拼 ${order.myShare.quantity} 份`
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="large"
className="worker-hall-card-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbing}
onClick={() => onGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
)
})
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 (
<article className="worker-hall-mobile-card">
<div className="worker-hall-mobile-card-top">
<div className="worker-hall-mobile-card-tags">
{order.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />} className="worker-hall-mobile-tag">
置顶
</Tag>
) : null}
{order.categoryName ? (
<Tag color="blue" className="worker-hall-mobile-tag">
{order.parentCategoryName
? `${order.parentCategoryName} · ${order.categoryName}`
: order.categoryName}
</Tag>
) : null}
{isInternalWorker && order.publishedAt ? (
<HallPublishedTag
publishedAt={order.publishedAt}
className="worker-hall-published-tag"
/>
) : null}
{order.sharing?.enabled ? (
<Tag color="purple" className="worker-hall-mobile-tag">
拼单 {order.sharingProgress?.joinedQuantity || 0}/{order.sharing.totalQuantity} · 剩
{sharingRemaining}份
</Tag>
) : null}
{Number(order.timeoutMinutes || 0) > 0 ? (
<Tag color="orange" icon={<ClockCircleOutlined />} className="worker-hall-mobile-tag">
限时{order.timeoutMinutes}分
</Tag>
) : null}
</div>
<span className="worker-hall-mobile-card-orderno">{getDisplayOrderNo(order)}</span>
</div>
<div className="worker-hall-mobile-card-main">
<strong className="worker-hall-mobile-card-title" title={productName}>
{productName}
</strong>
<div className="worker-hall-mobile-card-meta">
<span>所需保证金: {formatMoney(resolveHallDepositAmount(order))}</span>
{order.sharing?.enabled ? (
<span> · 拼单单价 {formatMoney(order.sharing.unitReward)}/份</span>
) : null}
</div>
</div>
<div className="worker-hall-mobile-card-footer">
<div className="worker-hall-mobile-card-price-group">
<span className="worker-hall-mobile-card-price-label">接单报酬</span>
<strong className="worker-hall-mobile-card-price-val">
{formatMoney(resolveHallRewardAmount(order))}
</strong>
</div>
<div className="worker-hall-mobile-card-btn-group">
{order.sharing?.enabled && (sharingRemaining > 0 || order.myShare) && !disabledReason ? (
<Button
type="primary"
size="middle"
className="worker-hall-sharing-button"
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
onClick={() => onOpenSharing(order)}
>
{order.myShare
? `已拼 ${order.myShare.quantity} 份`
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="middle"
className="worker-hall-mobile-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbing}
onClick={() => onGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
)
})
const HALL_CLOCK_INTERVAL_MS = 30_000
let hallClockNow = Date.now()
let hallClockTimer: ReturnType<typeof setInterval> | 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 (
<Tag
icon={<ClockCircleOutlined />}
className={className}
title={`发布时间: ${formatDateTime(publishedAt)}`}
>
{formatPublishedElapsedTime(publishedAt, nowMs)}
</Tag>
)
})
function getCategoryGradient(name: string, id: number): string { function getCategoryGradient(name: string, id: number): string {
const gradients = [ const gradients = [
'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+5
View File
@@ -1131,6 +1131,9 @@
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 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 { .worker-hall-card:hover {
@@ -2599,6 +2602,8 @@ body {
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
content-visibility: auto;
contain-intrinsic-size: auto 190px;
} }
.worker-hall-mobile-card-top { .worker-hall-mobile-card-top {
@@ -127,7 +127,15 @@ export function patchWorkerWorkOrderListEnvelope(
(entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id, (entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id,
) )
if (index >= 0) { 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) { } else if (insert) {
nextItems = [event.data, ...items] nextItems = [event.data, ...items]
} else { } else {
@@ -65,6 +65,28 @@ test('按工单号搜到的行,实时事件来了也不能被删掉,并且
assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') 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('不可证真的关键词下,快照只能就地更新:不插入、不删除', () => { test('不可证真的关键词下,快照只能就地更新:不插入、不删除', () => {
const envelope = buildEnvelope([buildRow()]) const envelope = buildEnvelope([buildRow()])
// 关键词没命中前端已知字段(unknown):既不该把它当"不匹配"删掉 // 关键词没命中前端已知字段(unknown):既不该把它当"不匹配"删掉