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 带随机段,不可枚举),存下来过多久都能看。
*/
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) {
@@ -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,
})
@@ -69,7 +69,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<string> {
@@ -86,6 +86,7 @@ export {
mapCollectLookupOrder,
mapWorkCategory,
mapWorkOrderAdmin,
mapWorkOrderForHall,
mapWorkOrderForWorker,
mapWorkOrderMaterial,
mapWorkOrderPublic,
@@ -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<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 {
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 {
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,
}
}
@@ -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个',
+390 -286
View File
@@ -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<Record<string | number, boolean>>({})
const [confirmOpen, setConfirmOpen] = useState(false)
const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled)
const [nowMs, setNowMs] = useState(() => Date.now())
const loadMoreRef = useRef<HTMLDivElement | null>(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: <QuestionCircleOutlined />,
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: <QuestionCircleOutlined />,
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 (
<article key={order.workOrderId} 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 ? (
<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>
)
})
orders.map((order) => (
<HallMobileCard
key={order.workOrderId}
order={order}
worker={worker}
remainingSlots={remainingSlots}
isInternalWorker={isInternalWorker}
grabbing={grabbingId === order.workOrderId}
onGrab={requestGrab}
onOpenSharing={openSharingModal}
/>
))
)}
{loadMoreIndicator}
@@ -1058,132 +995,18 @@ export default function WorkerHallPage() {
) : (
<>
<div className="worker-hall-card-grid">
{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 (
<article key={order.workOrderId} 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 ? (
<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>
)
})}
{orders.map((order) => (
<HallDesktopCard
key={order.workOrderId}
order={order}
worker={worker}
remainingSlots={remainingSlots}
isInternalWorker={isInternalWorker}
grabbing={grabbingId === order.workOrderId}
onGrab={requestGrab}
onOpenSharing={openSharingModal}
/>
))}
</div>
{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 {
const gradients = [
'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+5
View File
@@ -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 {
@@ -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 {
@@ -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):既不该把它当"不匹配"删掉