feat(worker): 打手接单界面支持查看超时历史订单并增加安全免责警示

- 数据库与查询层:通过索引 idx_work_order_events_timeout_worker_id 关联打手历史 timeout 事件与取消的拼单份额,打手在退回大厅或被他人抢走后仍能在「已超时」tab 查看历史记录;
- 状态与权限管控:针对超时工单将个人端状态稳定映射为 open,报酬与冻结押金置为 0,彻底禁用上传图片、提交验收、好友资料、申请取消等履约操作;
- 风险防护与免责说明:列表与抽屉详情顶部增加醒目的免责声明与警示横幅(告知订单已退回大厅或被他人接手,严禁在游戏内继续赠送,造成的损失平台概不负责);锁定买家游戏资料与快捷复制;
- 验证方式:通过后端全部 676 个测试、typecheck、check:sql 及前端 check(eslint、vitest/node:test、tsc、vite build)。
This commit is contained in:
yml2213
2026-10-11 15:20:46 +08:00
parent b82f9c4063
commit 6012add3c9
10 changed files with 460 additions and 59 deletions
@@ -421,12 +421,27 @@ export function mapWorkOrderAdmin(
}
}
export type WorkerOrderMapOptions = {
isTimedOutForMe?: boolean
timeoutNotice?:
| {
isTimedOut: boolean
timedOutAt: string | null
policy?: string
warningMessage?: string
isTakenByOther?: boolean
}
| null
| undefined
}
export function mapWorkOrderForWorker(
workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number },
myShare?: WorkOrderShareRow | null,
workerNote = '',
viewedAt?: string | null,
options?: WorkerOrderMapOptions,
) {
const mapped = mapWorkOrderAdmin(workOrder)
const {
@@ -441,17 +456,27 @@ export function mapWorkOrderForWorker(
adminNote: _adminNote,
...visibleOrder
} = mapped
const personalStatus = resolveWorkerShareStatus(workOrder.status, myShare?.status)
const isTimedOutForMe =
options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut)
const personalStatus = isTimedOutForMe
? WORK_ORDER_STATUS.OPEN
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
return {
...visibleOrder,
productName: myShare
? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0))
: visibleOrder.productName,
status: personalStatus,
rewardAmount: myShare ? Number(myShare.share_reward || 0) : visibleOrder.rewardAmount,
freezeDepositAmount: myShare
? Number(myShare.share_deposit || 0)
: resolveFreezeDepositAmount(workOrder, permissions),
rewardAmount: isTimedOutForMe
? 0
: myShare
? Number(myShare.share_reward || 0)
: visibleOrder.rewardAmount,
freezeDepositAmount: isTimedOutForMe
? 0
: myShare
? Number(myShare.share_deposit || 0)
: resolveFreezeDepositAmount(workOrder, permissions),
/** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */
depositFreeAmount: Number(permissions.depositFreeAmount || 0),
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
@@ -466,6 +491,7 @@ export function mapWorkOrderForWorker(
workerNote,
myShare: myShare ? mapWorkOrderShare(myShare) : null,
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
...(options?.timeoutNotice ? { timeoutNotice: options.timeoutNotice } : {}),
}
}
@@ -8,9 +8,11 @@ import {
getWorkOrderShare,
getWorkerCancelRequestRisk,
getWorkerOrderOverview,
listCancelledWorkerSharesByWorker,
listWorkerCancelRequests,
listWorkerOrderFeedbacks,
listWorkerSharesByWorker,
listWorkerTimeoutEventsByOrderIds,
listWorkerWorkOrderNotes,
listWorkerWorkOrderViews,
listWorkOrderEventsByOrderId,
@@ -88,14 +90,26 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const workOrderIds = items.map((item) => Number(item.id))
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds),
getPendingCancelRequestOrderIds(worker.id, workOrderIds),
getPendingFeedbackOrderIds(worker.id, workOrderIds),
getLatestMaterialEventAtByOrderIds(workOrderIds),
])
const [
notes,
views,
pendingCancelOrderIds,
pendingFeedbackOrderIds,
materialEventAtByOrderId,
timeoutEventsByOrderId,
cancelledShares,
] = await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds),
getPendingCancelRequestOrderIds(worker.id, workOrderIds),
getPendingFeedbackOrderIds(worker.id, workOrderIds),
getLatestMaterialEventAtByOrderIds(workOrderIds),
listWorkerTimeoutEventsByOrderIds(worker.id, workOrderIds),
listCancelledWorkerSharesByWorker(worker.id, workOrderIds),
])
const cancelledShareByOrderId = new Map(
cancelledShares.map((share) => [Number(share.work_order_id), share]),
)
const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
)
@@ -108,24 +122,71 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
return {
items: items.map((item) => {
const workOrderId = Number(item.id)
const activeShare = myShareByOrderId.get(workOrderId)
const cancelledShare = cancelledShareByOrderId.get(workOrderId)
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
// 判断当前打手是否仍持有该单
const isOwnerCurrently =
(item.assigned_worker_id != null &&
Number(item.assigned_worker_id) === Number(worker.id)) ||
Boolean(activeShare)
// 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单
const isTimedOutForMe =
!isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && item.sharing_enabled))
let timeoutNotice:
| {
isTimedOut: boolean
timedOutAt: string | null
warningMessage: string
isTakenByOther?: boolean
}
| undefined = undefined
if (isTimedOutForMe) {
const isTakenByOther =
(item.assigned_worker_id != null &&
Number(item.assigned_worker_id) !== Number(worker.id)) ||
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(item.status)
const warningMessage = isTakenByOther
? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'
: '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'
timeoutNotice = {
isTimedOut: true,
timedOutAt:
timeoutEvent?.createdAt ||
(item.deadline_at ? String(item.deadline_at) : null) ||
item.updated_at,
warningMessage,
isTakenByOther,
}
}
const materialEventAt = materialEventAtByOrderId.get(workOrderId) || ''
const materialSeenAt = materialSeenAtByOrderId.get(workOrderId) || ''
const materialUpdated =
!isTimedOutForMe &&
Boolean(materialEventAt) &&
(!materialSeenAt ||
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime())
return {
...mapWorkOrderForWorker(
item,
permissions,
myShareByOrderId.get(workOrderId),
activeShare || cancelledShare || null,
noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(workOrderId) || null,
{ isTimedOutForMe, timeoutNotice },
),
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
problemMarked: item.status === 'problem',
pendingCancelRequest: pendingCancelOrderIds.has(workOrderId),
pendingFeedback: pendingFeedbackOrderIds.has(workOrderId),
problemMarked: !isTimedOutForMe && item.status === 'problem',
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
materialUpdated,
}
}),
@@ -183,10 +244,13 @@ export async function getWorkerMyOrderEvents(workOrderId: number | string, sessi
const workerId = Number(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (!sharingShare && !workerOwnsWorkOrder(workOrder, workerId)) {
throw createHttpError('只能查看自己订单的流转记录', {
statusCode: 403,
errorCode: 'work_order_events_owner_required',
})
const timeoutEvents = await listWorkerTimeoutEventsByOrderIds(workerId, [Number(workOrder.id)])
if (!timeoutEvents.has(Number(workOrder.id))) {
throw createHttpError('只能查看自己订单的流转记录', {
statusCode: 403,
errorCode: 'work_order_events_owner_required',
})
}
}
const events = await listWorkOrderEventsByOrderId(workOrderId)
return { items: await mapWorkOrderEvents(events) }