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
@@ -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