perf(worker-hall): 优化百单大厅加载与渲染性能
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function listAdminWorkerLevels() {
|
||||
|
||||
export async function listAdminWorkCategories() {
|
||||
await ensureWorkerPlatformDefaults()
|
||||
return { items: (await listAllWorkCategories()).map(mapWorkCategory) }
|
||||
return { items: (await listAllWorkCategories()).map((category) => mapWorkCategory(category)) }
|
||||
}
|
||||
|
||||
async function generateUniqueCategoryKey(): Promise<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个',
|
||||
|
||||
Reference in New Issue
Block a user