diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 032e761f..3825c862 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -240,7 +240,18 @@ export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'wo AND wf.status = 'pending' ) )` + const isTimeoutForMe = `( + (wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId}) + AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted') + AND EXISTS ( + SELECT 1 FROM work_order_events timeout_ev + WHERE timeout_ev.work_order_id = wo.id + AND timeout_ev.event_type LIKE 'timeout_%' + AND timeout_ev.payload_json->>'workerId' = '${normalizedWorkerId}' + ) + )` return `CASE + WHEN ${isTimeoutForMe} THEN 'open' WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' ELSE ${baseExpression} END` @@ -385,7 +396,10 @@ export async function listWorkOrders({ END, CASE WHEN worker_share.status != 'cancelled' THEN worker_share.created_at - END + END, + worker_share.created_at, + wo.assigned_at, + wo.created_at ) DESC NULLS LAST, wo.id DESC` : 'wo.id DESC' const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy @@ -594,6 +608,11 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise FROM work_order_shares wos WHERE wos.worker_id = $1 AND wos.status != 'cancelled' + UNION + SELECT timeout_ev.work_order_id + FROM work_order_events timeout_ev + WHERE timeout_ev.event_type LIKE 'timeout_%' + AND timeout_ev.payload_json->>'workerId' = $1::text ), worker_base AS ( SELECT @@ -601,6 +620,13 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise wo.status AS wo_status, wos.status AS wos_status, wos.id IS NOT NULL AS is_worker_share, + wo.assigned_worker_id = $1 AS is_assigned_to_me, + EXISTS ( + SELECT 1 FROM work_order_events we + WHERE we.work_order_id = wo.id + AND we.event_type LIKE 'timeout_%' + AND we.payload_json->>'workerId' = $1::text + ) AS has_timeout_for_me, CASE WHEN wos.id IS NOT NULL THEN wos.share_reward WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount @@ -617,6 +643,8 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise worker_orders AS ( SELECT CASE + WHEN NOT is_assigned_to_me AND (NOT is_worker_share OR wos_status = 'cancelled' OR wos_status IS NULL) AND has_timeout_for_me + THEN 'open' WHEN ( CASE WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') @@ -887,6 +915,84 @@ export async function countWorkerTimeoutEvents(workerId: number | string): Promi return counts.get(Number(workerId)) || 0 } +export async function listWorkerTimeoutEventsByOrderIds( + workerId: number | string, + workOrderIds: number[], +): Promise< + Map< + number, + { + eventType: string + createdAt: string + policy: string + quantity?: number + depositAmount?: number + isSharing?: boolean + } + > +> { + if (workOrderIds.length === 0) return new Map() + const result = await query<{ + work_order_id: string + event_type: string + created_at: string + payload_json: Record | string + }>( + `SELECT work_order_id, event_type, created_at, payload_json + FROM work_order_events + WHERE event_type LIKE 'timeout_%' + AND payload_json->>'workerId' = $1::text + AND work_order_id = ANY($2::bigint[]) + ORDER BY id DESC`, + [String(workerId), workOrderIds], + ) + const map = new Map< + number, + { + eventType: string + createdAt: string + policy: string + quantity?: number + depositAmount?: number + isSharing?: boolean + } + >() + for (const row of result.rows) { + const orderId = Number(row.work_order_id) + if (!map.has(orderId)) { + const payload: Record = + typeof row.payload_json === 'string' + ? (() => { + try { + return JSON.parse(row.payload_json) as Record + } catch { + return {} + } + })() + : (row.payload_json as Record) || {} + const info: { + eventType: string + createdAt: string + policy: string + quantity?: number + depositAmount?: number + isSharing?: boolean + } = { + eventType: row.event_type, + createdAt: String(row.created_at), + policy: String(payload.policy || 'reopen'), + } + const qty = Number(payload.quantity || 0) + if (qty > 0) info.quantity = qty + const dep = Number(payload.depositAmount || 0) + if (dep > 0) info.depositAmount = dep + if (payload.isSharing === true) info.isSharing = true + map.set(orderId, info) + } + } + return map +} + export async function countWorkerCancellationsSince( workerId: number | string, sinceIso: string, @@ -1027,6 +1133,11 @@ export function buildWorkOrderWhere({ FROM work_order_shares share_source WHERE share_source.worker_id = ${workerSharePlaceholder} AND share_source.status != 'cancelled' + UNION + SELECT timeout_ev.work_order_id + FROM work_order_events timeout_ev + WHERE timeout_ev.event_type LIKE 'timeout_%' + AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder} ) worker_visible ON worker_visible.work_order_id = wo.id LEFT JOIN work_order_shares worker_share ON worker_share.work_order_id = wo.id diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index e85d7cdb..417be135 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -60,6 +60,22 @@ export async function listWorkerSharesByWorker( return result.rows } +export async function listCancelledWorkerSharesByWorker( + workerId: number | string, + workOrderIds: number[], +): Promise { + if (workOrderIds.length === 0) return [] + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.worker_id = $1 + AND wos.work_order_id = ANY($2::bigint[]) + AND wos.status = 'cancelled' + ORDER BY wos.id DESC`, + [Number(workerId), workOrderIds], + ) + return result.rows +} + export async function countWorkOrderPendingSharingSubmissions( workOrderId: number | string, ): Promise { diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 6737bfc1..a6a7975d 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -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 } : {}), } } diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 46508228..f10eed15 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -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) } diff --git a/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx b/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx index 81aba9dc..c5c0f68b 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx @@ -6,7 +6,7 @@ import { ThunderboltOutlined, WarningOutlined, } from '@ant-design/icons' -import { Button, Card, Empty, Input, Space, Table, Tabs, Typography } from 'antd' +import { Alert, Button, Card, Empty, Input, Space, Table, Tabs, Typography } from 'antd' import type { TableColumnsType } from 'antd' import type { Pagination, WorkerOrderOverviewItem, WorkOrder } from '@/types/worker-platform' @@ -246,6 +246,15 @@ export function WorkerOrderDesktopList({ onChange={(nextKey) => onStatusChange(nextKey === 'all' ? '' : nextKey)} /> + {status === 'open' ? ( + + ) : null} + rowKey="workOrderId" size="middle" diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index 6cd53ade..4c3b5a26 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -1,5 +1,5 @@ import { MessageOutlined, PictureOutlined, UserAddOutlined } from '@ant-design/icons' -import { Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typography } from 'antd' +import { Alert, Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typography } from 'antd' import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImagePreviewList from '@/components/files/ImagePreviewList' @@ -49,6 +49,8 @@ export function WorkerOrderDetailDrawer({ onOpenCancelRequest: (order: WorkOrder) => void onOpenFeedback: (order: WorkOrder) => void }) { + const isTimedOut = Boolean(order && (order.status === 'open' || order.timeoutNotice?.isTimedOut)) + return ( {order ? (
+ {isTimedOut ? ( + + + {order.timeoutNotice?.warningMessage || + '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'} + + + 该工单已不再属于您,所有履约与提交操作入口均已关闭。请切勿向该买家赠送商品或代练。 + + {order.timeoutNotice?.timedOutAt ? ( + + 超时发生时间:{formatDateTime(order.timeoutNotice.timedOutAt)} + + ) : null} + + } + /> + ) : null}
{getOrderStatusDisplay(order).label} @@ -105,11 +130,31 @@ export function WorkerOrderDetailDrawer({ {getOrderStatusHint(order)} - {formatMoney(order.rewardAmount)} + {isTimedOut ? ( + 已超时失效 (¥0.00) + ) : ( + formatMoney(order.rewardAmount) + )} - {formatMoney(order.freezeDepositAmount)} + {isTimedOut ? ( + 已解除冻结 + ) : ( + formatMoney(order.freezeDepositAmount) + )} + {isTimedOut ? ( + <> + + + {order.timeoutNotice?.isTakenByOther ? '已被其他打手接手' : '已退回抢单大厅'} + + + + {formatDateTime(order.timeoutNotice?.timedOutAt || order.updatedAt)} + + + ) : null} {formatDateTime(order.assignedAt)} @@ -126,18 +171,28 @@ export function WorkerOrderDetailDrawer({ - {renderDetailFieldValues(getMaterialDetailItems(order), '当前还没有资料内容')} - {getMaterialScreenshots(order).length > 0 ? ( -
- 买家主页截图 -
- -
-
- ) : null} + {isTimedOut ? ( + + 工单已超时退回大厅,游戏资料已锁定。严禁在游戏内向买家赠送或履约! + + ) : ( + <> + {renderDetailFieldValues(getMaterialDetailItems(order), '当前还没有资料内容')} + {getMaterialScreenshots(order).length > 0 ? ( +
+ 买家主页截图 +
+ +
+
+ ) : null} + + )}
- {isFriendGiftOrder(order) && order.gift ? : null} + {isFriendGiftOrder(order) && order.gift && !isTimedOut ? ( + + ) : null} diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 95c7f58f..fd38263f 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -7,7 +7,7 @@ import { ReloadOutlined, SearchOutlined, } from '@ant-design/icons' -import { Button, Empty, Input, Pagination, Spin, Tag } from 'antd' +import { Alert, Button, Empty, Input, Pagination, Spin, Tag } from 'antd' import { DeadlineCountdown } from '@/components/DeadlineCountdown' import ImagePreviewList from '@/components/files/ImagePreviewList' @@ -146,6 +146,17 @@ export function WorkerOrderMobileList({
+ {status === 'open' ? ( +
+ +
+ ) : null} +
{loading ? (
@@ -233,6 +244,22 @@ function WorkerOrderMobileCard({ return (
+ {order.status === 'open' || order.timeoutNotice?.isTimedOut ? ( +
+ ⚠️ 该订单已超时退回大厅 + {order.timeoutNotice?.isTakenByOther ? '(已被其他打手接手)' : ''} + ,请勿在游戏内继续赠送!若私自继续赠送,造成的财产损失平台概不负责。 +
+ ) : null}
{order.isUnread ? ( @@ -272,16 +299,32 @@ function WorkerOrderMobileCard({
{order.productName || '未命名商品'}
-
- 报酬 - - {formatMoney(order.rewardAmount)} - -
+ {order.status === 'open' || order.timeoutNotice?.isTimedOut ? ( +
+ + 状态 + + + 已超时失效 + +
+ ) : ( +
+ 报酬 + + {formatMoney(order.rewardAmount)} + +
+ )}
- 押金: {formatMoney(order.freezeDepositAmount)} + {order.status === 'open' || order.timeoutNotice?.isTimedOut + ? '押金: 已解除冻结' + : `押金: ${formatMoney(order.freezeDepositAmount)}`} {order.myShare ? ( @@ -297,7 +340,21 @@ function WorkerOrderMobileCard({ {order.problemNote}
) : null} - {items.length > 0 || screenshots.length > 0 ? ( + {order.status === 'open' || order.timeoutNotice?.isTimedOut ? ( +
+ 工单已超时退回大厅,资料已锁定,禁止继续向该买家履约。 +
+ ) : items.length > 0 || screenshots.length > 0 ? (
{items.length > 0 ? (
diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index 03c930cc..a5d91b2a 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -126,6 +126,11 @@ export function buildWorkerOrderTableColumns({ ))} ) : null} + {row.status === 'open' || row.timeoutNotice?.isTimedOut ? ( + + ⚠️ 请勿继续赠送 + + ) : null} {row.status === 'problem' && String(row.problemNote || '').trim() ? ( ( -
- - {formatMoney(row.rewardAmount)} - - - 押金:{formatMoney(row.freezeDepositAmount)} - - {row.myShare ? ( - - {row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)} + render: (_, row) => + row.status === 'open' || row.timeoutNotice?.isTimedOut ? ( +
+ + 已超时失效 - ) : null} -
- ), + + 押金:已解除冻结 + +
+ ) : ( +
+ + {formatMoney(row.rewardAmount)} + + + 押金:{formatMoney(row.freezeDepositAmount)} + + {row.myShare ? ( + + {row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)} + + ) : null} +
+ ), }, { title: '验收信息', @@ -167,6 +182,18 @@ export function buildWorkerOrderTableColumns({ title: '快捷复制', width: 250, render: (_, row) => { + if (row.status === 'open' || row.timeoutNotice?.isTimedOut) { + return ( +
+ + 工单已超时退回大厅 + + + 已锁定资料,禁止继续履约 + +
+ ) + } const items = getMaterialDetailItems(row).filter((item) => String(item.value || '').trim()) const screenshots = getMaterialScreenshots(row) return ( diff --git a/apps/frontend/src/pages/worker/worker-order-utils.tsx b/apps/frontend/src/pages/worker/worker-order-utils.tsx index ff0d21a5..e9234e02 100644 --- a/apps/frontend/src/pages/worker/worker-order-utils.tsx +++ b/apps/frontend/src/pages/worker/worker-order-utils.tsx @@ -50,6 +50,7 @@ export function getGiftPhaseMeta(phase: string): { label: string; color: string /** 好友赠送模式:待提交本人资料阶段。 */ export function canSubmitGiftMaterial(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false return ( isFriendGiftOrder(order) && ['in_progress', 'problem'].includes(order.status) && @@ -59,6 +60,7 @@ export function canSubmitGiftMaterial(order: WorkOrder) { /** 好友赠送模式:冷却未结束前不能提交赠送凭证。 */ export function isGiftSubmissionBlocked(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return true if (!isFriendGiftOrder(order)) return false if (!['in_progress', 'problem'].includes(order.status)) return false return order.gift?.phase !== 'gift_ready' @@ -66,6 +68,12 @@ export function isGiftSubmissionBlocked(order: WorkOrder) { /** 好友赠送单进行中按赠送阶段展示状态,其他模式维持原状态文案。 */ export function getOrderStatusDisplay(order: WorkOrder): { label: string; color: string } { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') { + if (order.timeoutNotice?.isTakenByOther) { + return { label: '已超时(已被其他打手接手)', color: 'red' } + } + return { label: '已超时退回大厅', color: 'red' } + } if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) { return getGiftPhaseMeta(order.gift.phase) } @@ -75,6 +83,12 @@ export function getOrderStatusDisplay(order: WorkOrder): { label: string; color: /** 问题单来源与资料更新的订单角标,桌面表格与移动卡片共用。 */ export function getOrderFlagTags(order: WorkOrder) { const flags: Array<{ key: string; label: string; color: string }> = [] + if (order.timeoutNotice?.isTimedOut || order.status === 'open') { + if (order.timeoutNotice?.isTakenByOther) { + flags.push({ key: 'taken_by_other', label: '已被其他打手接手', color: 'magenta' }) + } + return flags + } if (order.problemMarked) flags.push({ key: 'problem', label: '平台标记', color: 'red' }) if (order.pendingCancelRequest) flags.push({ key: 'cancel', label: '退单审核中', color: 'orange' }) @@ -84,6 +98,12 @@ export function getOrderFlagTags(order: WorkOrder) { } export function getOrderStatusHint(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') { + return ( + order.timeoutNotice?.warningMessage || + '该订单已超时退回抢单大厅,请勿在游戏内继续赠送!若私自继续赠送,造成的财产损失平台概不负责。' + ) + } if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) { const hints: Record = { material_required: '请提交本人游戏资料(昵称/ID/区服 + 主页截图),后台核对后添加好友。', @@ -97,6 +117,7 @@ export function getOrderStatusHint(order: WorkOrder) { } export function canSubmitAcceptance(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false if (order.myShare) { return order.myShare.status === 'joined' && order.status !== 'accepted' } @@ -107,10 +128,12 @@ export function canSubmitAcceptance(order: WorkOrder) { } export function canRemindAcceptance(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false return !order.myShare && order.status === 'pending_acceptance' } export function canRequestCancel(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false if (order.myShare) { return ( ['joined', 'submitted'].includes(order.myShare.status) && @@ -121,11 +144,13 @@ export function canRequestCancel(order: WorkOrder) { } export function canSubmitFeedback(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false if (order.myShare) return order.myShare.status !== 'cancelled' return Boolean(order.assignedAt || order.worker) } export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false // 好友赠送模式必须由后台人工验收赠送凭证,不适用 VIP 免审核直通。 if (isFriendGiftOrder(order)) return false if (!workerCanAutoAcceptWithoutEvidence) return false @@ -134,16 +159,19 @@ export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutE } export function canSupplementAcceptedEvidence(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false return order.status === 'accepted' } export function isVipEvidencePending(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false const acceptance = getWorkerAcceptance(order) return acceptance?.reviewMode === 'vip_auto' && acceptance?.evidenceStatus === 'pending' } /** 是否可编辑验收图片:未提交时可暂存,已提交时可补充或修改。 */ export function canEditAcceptanceImages(order: WorkOrder) { + if (order.timeoutNotice?.isTimedOut || order.status === 'open') return false if (order.myShare) { return ( ['joined', 'submitted'].includes(order.myShare.status) || canSupplementAcceptedEvidence(order) diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index fd411744..8d70feb1 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -646,6 +646,14 @@ export type WorkOrder = { pendingCancelRequest?: boolean pendingFeedback?: boolean materialUpdated?: boolean + /** 超时历史订单安全警示信息(针对已超时退回大厅的工单) */ + timeoutNotice?: { + isTimedOut: boolean + timedOutAt: string | null + policy?: string + warningMessage?: string + isTakenByOther?: boolean + } /** 取消信息,仅已取消工单返回。 */ cancel?: { reason: string