Merge remote-tracking branch 'gitea-aliyun/main' into dev
This commit is contained in:
@@ -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个',
|
||||||
|
|||||||
@@ -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%)',
|
||||||
|
|||||||
@@ -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):既不该把它当"不匹配"删掉
|
||||||
|
|||||||
Reference in New Issue
Block a user