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:
@@ -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, unknown> | 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<string, unknown> =
|
||||
typeof row.payload_json === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(row.payload_json) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
: (row.payload_json as Record<string, unknown>) || {}
|
||||
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
|
||||
|
||||
@@ -60,6 +60,22 @@ export async function listWorkerSharesByWorker(
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listCancelledWorkerSharesByWorker(
|
||||
workerId: number | string,
|
||||
workOrderIds: number[],
|
||||
): Promise<WorkOrderShareRow[]> {
|
||||
if (workOrderIds.length === 0) return []
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${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<number> {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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' ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="已超时订单免责声明与警示"
|
||||
description="该列表中的工单因超时已退回抢单大厅(或已被其他打手接手),不再属于您。请打手切勿在游戏内继续赠送!若私自继续赠送,造成的财产损失平台概不负责。超时工单仅供查阅历史记录,已锁定一切履约与提交操作。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Table<WorkOrder>
|
||||
rowKey="workOrderId"
|
||||
size="middle"
|
||||
|
||||
@@ -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 (
|
||||
<Drawer
|
||||
className="worker-order-detail-drawer"
|
||||
@@ -60,6 +62,29 @@ export function WorkerOrderDetailDrawer({
|
||||
>
|
||||
{order ? (
|
||||
<div className="worker-order-detail-stack">
|
||||
{isTimedOut ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="⚠️ 超时订单安全警示与免责声明"
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%', marginTop: 4 }}>
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
{order.timeoutNotice?.warningMessage ||
|
||||
'该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
该工单已不再属于您,所有履约与提交操作入口均已关闭。请切勿向该买家赠送商品或代练。
|
||||
</Typography.Text>
|
||||
{order.timeoutNotice?.timedOutAt ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
超时发生时间:{formatDateTime(order.timeoutNotice.timedOutAt)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="worker-order-detail-actions">
|
||||
<Tag color={getOrderStatusDisplay(order).color}>
|
||||
{getOrderStatusDisplay(order).label}
|
||||
@@ -105,11 +130,31 @@ export function WorkerOrderDetailDrawer({
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="阶段说明">{getOrderStatusHint(order)}</Descriptions.Item>
|
||||
<Descriptions.Item label="接单奖励">
|
||||
{formatMoney(order.rewardAmount)}
|
||||
{isTimedOut ? (
|
||||
<Typography.Text type="danger">已超时失效 (¥0.00)</Typography.Text>
|
||||
) : (
|
||||
formatMoney(order.rewardAmount)
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="冻结押金">
|
||||
{formatMoney(order.freezeDepositAmount)}
|
||||
{isTimedOut ? (
|
||||
<Typography.Text type="secondary">已解除冻结</Typography.Text>
|
||||
) : (
|
||||
formatMoney(order.freezeDepositAmount)
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{isTimedOut ? (
|
||||
<>
|
||||
<Descriptions.Item label="超时去向">
|
||||
<Tag color={order.timeoutNotice?.isTakenByOther ? 'magenta' : 'red'}>
|
||||
{order.timeoutNotice?.isTakenByOther ? '已被其他打手接手' : '已退回抢单大厅'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="超时时间">
|
||||
{formatDateTime(order.timeoutNotice?.timedOutAt || order.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
) : null}
|
||||
<Descriptions.Item label="接单时间">
|
||||
{formatDateTime(order.assignedAt)}
|
||||
</Descriptions.Item>
|
||||
@@ -126,18 +171,28 @@ export function WorkerOrderDetailDrawer({
|
||||
</Card>
|
||||
|
||||
<Card title="资料信息" size="small">
|
||||
{renderDetailFieldValues(getMaterialDetailItems(order), '当前还没有资料内容')}
|
||||
{getMaterialScreenshots(order).length > 0 ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary">买家主页截图</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ImagePreviewList files={getMaterialScreenshots(order)} size={72} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isTimedOut ? (
|
||||
<Typography.Text type="danger">
|
||||
工单已超时退回大厅,游戏资料已锁定。严禁在游戏内向买家赠送或履约!
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
{renderDetailFieldValues(getMaterialDetailItems(order), '当前还没有资料内容')}
|
||||
{getMaterialScreenshots(order).length > 0 ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary">买家主页截图</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ImagePreviewList files={getMaterialScreenshots(order)} size={72} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{isFriendGiftOrder(order) && order.gift ? <GiftProgressCard order={order} /> : null}
|
||||
{isFriendGiftOrder(order) && order.gift && !isTimedOut ? (
|
||||
<GiftProgressCard order={order} />
|
||||
) : null}
|
||||
|
||||
<Card title="验收信息" size="small">
|
||||
<AcceptanceSummary order={order} />
|
||||
|
||||
@@ -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({
|
||||
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={onRefresh} title="刷新" />
|
||||
</div>
|
||||
|
||||
{status === 'open' ? (
|
||||
<div style={{ margin: '8px 12px 4px 12px' }}>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="已超时订单警示说明"
|
||||
description="本列表中的工单因超时已退回抢单大厅(或已被其他打手接手),不再属于您。请打手切勿在游戏内继续赠送!造成损失平台概不负责。"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="worker-orders-mobile-list">
|
||||
{loading ? (
|
||||
<div className="worker-hall-loading">
|
||||
@@ -233,6 +244,22 @@ function WorkerOrderMobileCard({
|
||||
|
||||
return (
|
||||
<article className="worker-order-mobile-card">
|
||||
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#fff2f0',
|
||||
borderBottom: '1px solid #ffccc7',
|
||||
color: '#cf1322',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
⚠️ 该订单已超时退回大厅
|
||||
{order.timeoutNotice?.isTakenByOther ? '(已被其他打手接手)' : ''}
|
||||
,请勿在游戏内继续赠送!若私自继续赠送,造成的财产损失平台概不负责。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="worker-order-mobile-header">
|
||||
<div className="worker-order-mobile-header-left">
|
||||
{order.isUnread ? (
|
||||
@@ -272,16 +299,32 @@ function WorkerOrderMobileCard({
|
||||
|
||||
<div className="worker-order-mobile-main-row">
|
||||
<div className="worker-order-mobile-product-title">{order.productName || '未命名商品'}</div>
|
||||
<div className="worker-order-mobile-price-badge">
|
||||
<span className="worker-order-mobile-price-label">报酬</span>
|
||||
<strong className="worker-order-mobile-price-num">
|
||||
{formatMoney(order.rewardAmount)}
|
||||
</strong>
|
||||
</div>
|
||||
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
|
||||
<div className="worker-order-mobile-price-badge">
|
||||
<span className="worker-order-mobile-price-label" style={{ color: '#cf1322' }}>
|
||||
状态
|
||||
</span>
|
||||
<strong
|
||||
className="worker-order-mobile-price-num"
|
||||
style={{ color: '#cf1322', fontSize: 13 }}
|
||||
>
|
||||
已超时失效
|
||||
</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="worker-order-mobile-price-badge">
|
||||
<span className="worker-order-mobile-price-label">报酬</span>
|
||||
<strong className="worker-order-mobile-price-num">
|
||||
{formatMoney(order.rewardAmount)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="worker-order-mobile-sub-row">
|
||||
<span className="worker-order-mobile-sub-item">
|
||||
押金: {formatMoney(order.freezeDepositAmount)}
|
||||
{order.status === 'open' || order.timeoutNotice?.isTimedOut
|
||||
? '押金: 已解除冻结'
|
||||
: `押金: ${formatMoney(order.freezeDepositAmount)}`}
|
||||
</span>
|
||||
{order.myShare ? (
|
||||
<span className="worker-order-mobile-sub-item">
|
||||
@@ -297,7 +340,21 @@ function WorkerOrderMobileCard({
|
||||
<span>{order.problemNote}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{items.length > 0 || screenshots.length > 0 ? (
|
||||
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: '#fafafa',
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: 6,
|
||||
margin: '8px 12px',
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
工单已超时退回大厅,资料已锁定,禁止继续向该买家履约。
|
||||
</div>
|
||||
) : items.length > 0 || screenshots.length > 0 ? (
|
||||
<div className="worker-order-mobile-credentials-box">
|
||||
{items.length > 0 ? (
|
||||
<div className="worker-order-mobile-credentials-grid">
|
||||
|
||||
@@ -126,6 +126,11 @@ export function buildWorkerOrderTableColumns({
|
||||
))}
|
||||
</Space>
|
||||
) : null}
|
||||
{row.status === 'open' || row.timeoutNotice?.isTimedOut ? (
|
||||
<Typography.Text type="danger" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
⚠️ 请勿继续赠送
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{row.status === 'problem' && String(row.problemNote || '').trim() ? (
|
||||
<Typography.Text
|
||||
type="danger"
|
||||
@@ -142,21 +147,31 @@ export function buildWorkerOrderTableColumns({
|
||||
{
|
||||
title: '奖励 / 押金',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
{formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
押金:{formatMoney(row.freezeDepositAmount)}
|
||||
</Typography.Text>
|
||||
{row.myShare ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)}
|
||||
render: (_, row) =>
|
||||
row.status === 'open' || row.timeoutNotice?.isTimedOut ? (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type="danger" style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
已超时失效
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
押金:已解除冻结
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
{formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
押金:{formatMoney(row.freezeDepositAmount)}
|
||||
</Typography.Text>
|
||||
{row.myShare ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '验收信息',
|
||||
@@ -167,6 +182,18 @@ export function buildWorkerOrderTableColumns({
|
||||
title: '快捷复制',
|
||||
width: 250,
|
||||
render: (_, row) => {
|
||||
if (row.status === 'open' || row.timeoutNotice?.isTimedOut) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>
|
||||
工单已超时退回大厅
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
已锁定资料,禁止继续履约
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const items = getMaterialDetailItems(row).filter((item) => String(item.value || '').trim())
|
||||
const screenshots = getMaterialScreenshots(row)
|
||||
return (
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user