feat(task): 任务详情与列表页增加H5订单号领取链接及兑换有效性控制

- 后端 AdminTaskDetail 视图补充关联订单的 claimStatus、claimStatusReason 等状态字段
- 任务详情页升级“领取链接”卡片:支持固定 H5 链接展示、状态标签(有效/暂停/关闭),并支持一键复制及【暂停/关闭/恢复启用】弹窗操作
- 任务详情页保留历史 Token 链接作为辅助对比展示
- 任务列表页在平台订单号旁增加一键复制 H5 领取链接的快捷按钮
- 验证方式:前后端类型检查、全量单元测试(后端 674、前端 132)及前后端全量构建均通过
This commit is contained in:
yml2213
2026-10-10 18:38:49 +08:00
parent 412d219cca
commit 122144a2b7
4 changed files with 266 additions and 26 deletions
@@ -368,6 +368,9 @@ export async function getAdminTaskDetail(
platformOrderId: order.platform_order_id,
payStatus: order.pay_status,
orderStatus: order.order_status,
claimStatus: (order.claim_status as 'active' | 'paused' | 'closed') || 'active',
claimStatusReason: order.claim_status_reason || null,
claimStatusUpdatedAt: order.claim_status_updated_at || null,
}
: null,
orderItem: orderItem
@@ -3,7 +3,11 @@ import {
CheckCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
LinkOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
@@ -12,9 +16,11 @@ import {
Button,
Card,
Descriptions,
Divider,
Empty,
Image,
Input,
Modal,
Skeleton,
Space,
Table,
@@ -23,7 +29,7 @@ import {
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
@@ -39,6 +45,7 @@ import {
markAdminTaskManualReview,
resendAdminTaskKuaishouIndustryVoucherCode,
retryAdminTask,
updateAdminOrderClaimStatus,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
@@ -65,6 +72,9 @@ export default function AdminTaskDetailPage() {
const [actionLoadingKey, setActionLoadingKey] = useState('')
const [lastClaimUrl, setLastClaimUrl] = useState('')
const [screenshotPreviewUrl, setScreenshotPreviewUrl] = useState('')
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
const [manualDispatchForm, setManualDispatchForm] = useState<ManualDispatchForm>({
deliveryReference: '',
deliveredCredential: '',
@@ -231,6 +241,61 @@ export default function AdminTaskDetailPage() {
)
}
const orderId = resolvedDetail.order?.orderId ? String(resolvedDetail.order.orderId) : ''
const platformOrderId =
resolvedDetail.order?.platformOrderId || resolvedDetail.task.platformOrderId || ''
const h5ClaimUrl = platformOrderId
? `${window.location.origin}/h5/${encodeURIComponent(platformOrderId)}`
: ''
const claimStatus = resolvedDetail.order?.claimStatus || 'active'
const claimStatusReason = resolvedDetail.order?.claimStatusReason || ''
const statusMutation = useMutation({
mutationFn: (data: { status: 'active' | 'paused' | 'closed'; reason?: string }) => {
if (!orderId) throw new Error('当前任务无关联订单信息')
return updateAdminOrderClaimStatus(orderId, data)
},
onSuccess: () => {
message.success('订单兑换状态已更新')
setStatusModalVisible(false)
setStatusReason('')
void query.refetch()
},
onError: (err: unknown) => {
message.error(err instanceof Error ? err.message : '更新状态失败')
},
})
const handleCopyH5Link = () => {
if (!h5ClaimUrl) return
navigator.clipboard
.writeText(h5ClaimUrl)
.then(() => message.success('已复制 H5 领取链接: ' + h5ClaimUrl))
.catch(() => message.error('复制失败,请手动复制'))
}
const openStatusModal = (status: 'active' | 'paused' | 'closed') => {
if (!orderId) {
message.warning('当前任务无关联订单 ID,无法更改兑换状态')
return
}
if (status === 'active') {
modal.confirm({
title: '恢复启用订单兑换',
content: '恢复后,买家可正常进入 H5 页面提交 UID 领取,确认恢复吗?',
onOk: () =>
statusMutation.mutateAsync({
status: 'active',
reason: '客服在任务详情中恢复启用',
}),
})
return
}
setTargetStatus(status)
setStatusReason('')
setStatusModalVisible(true)
}
return (
<section className="page-stack">
<PageHeader
@@ -332,31 +397,138 @@ export default function AdminTaskDetailPage() {
</Descriptions>
</Card>
{resolvedDetail.claimToken ? (
<Card title="领取链接">
<Space direction="vertical" size={8} className="full-width">
{claimUrl ? (
<Typography.Text copyable={{ text: claimUrl }}>{claimUrl}</Typography.Text>
) : claimLinkInvalid ? (
<Typography.Text type="secondary">领取链接已失效</Typography.Text>
) : (
<Typography.Text type="secondary">暂无可用领取链接</Typography.Text>
)}
<Space wrap>
<StatusTag value={resolvedDetail.claimToken.status} />
{isExternalClaimLink ? (
<Typography.Text type="secondary">外部交付链接</Typography.Text>
) : (
<Typography.Text type="secondary">
{resolvedDetail.claimToken.expiredAt
? `到期:${formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}`
: '长期有效'}
</Typography.Text>
)}
<Button icon={<CopyOutlined />} disabled={!claimUrl} onClick={copyClaimUrl}>
复制链接
</Button>
{h5ClaimUrl || resolvedDetail.claimToken ? (
<Card
title={
<Space size={12}>
<span>领取链接与兑换控制</span>
{resolvedDetail.order ? (
claimStatus === 'active' ? (
<Tag color="success">H5兑换有效</Tag>
) : claimStatus === 'paused' ? (
<Tag color="warning">H5兑换已暂停</Tag>
) : (
<Tag color="error">H5兑换已关闭</Tag>
)
) : null}
</Space>
}
>
<Space direction="vertical" size={16} className="full-width">
{h5ClaimUrl ? (
<div
style={{
background: '#fafafa',
padding: 12,
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<Space direction="vertical" size={8} className="full-width">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space>
<Tag color="blue">固定 H5 链接</Tag>
<Typography.Text strong copyable={{ text: h5ClaimUrl }}>
{h5ClaimUrl}
</Typography.Text>
</Space>
<Space wrap>
<Button
type="primary"
ghost
icon={<CopyOutlined />}
onClick={handleCopyH5Link}
>
复制 H5 链接
</Button>
{orderId ? (
<>
{claimStatus !== 'paused' && (
<Button
danger
style={{ color: '#fa8c16', borderColor: '#ffa39e' }}
icon={<PauseCircleOutlined />}
onClick={() => openStatusModal('paused')}
>
暂停兑换
</Button>
)}
{claimStatus !== 'closed' && (
<Button
danger
icon={<StopOutlined />}
onClick={() => openStatusModal('closed')}
>
关闭兑换
</Button>
)}
{claimStatus !== 'active' && (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => openStatusModal('active')}
>
恢复启用
</Button>
)}
</>
) : null}
</Space>
</div>
{claimStatusReason ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
状态备注:{claimStatusReason}
</Typography.Text>
) : null}
</Space>
</div>
) : null}
{resolvedDetail.claimToken ? (
<div>
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginBottom: 6 }}
>
历史/备用 Token 链接:
</Typography.Text>
<Space wrap align="center">
{claimUrl ? (
<Typography.Text copyable={{ text: claimUrl }}>{claimUrl}</Typography.Text>
) : claimLinkInvalid ? (
<Typography.Text type="secondary">链接已失效</Typography.Text>
) : (
<Typography.Text type="secondary">暂无可用链接</Typography.Text>
)}
<StatusTag value={resolvedDetail.claimToken.status} />
{isExternalClaimLink ? (
<Typography.Text type="secondary">外部交付链接</Typography.Text>
) : (
<Typography.Text type="secondary">
{resolvedDetail.claimToken.expiredAt
? `到期:${formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}`
: '长期有效'}
</Typography.Text>
)}
<Button
icon={<CopyOutlined />}
size="small"
disabled={!claimUrl}
onClick={copyClaimUrl}
>
复制 Token 链接
</Button>
</Space>
</div>
) : null}
</Space>
</Card>
) : null}
@@ -432,6 +604,48 @@ export default function AdminTaskDetailPage() {
},
]}
/>
<Modal
title={targetStatus === 'paused' ? '暂停订单兑换' : '关闭订单兑换'}
open={statusModalVisible}
confirmLoading={statusMutation.isPending}
onOk={() =>
statusMutation.mutateAsync({
status: targetStatus,
reason: statusReason.trim() || undefined,
})
}
onCancel={() => setStatusModalVisible(false)}
okText="确认变更"
cancelText="取消"
centered
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
<Alert
type={targetStatus === 'paused' ? 'warning' : 'error'}
showIcon
message={
targetStatus === 'paused'
? '暂停后,买家访问 H5 链接将提示“订单兑换已暂停”,无法提交 UID。可在售后处理完成后恢复启用。'
: '关闭后,买家访问 H5 链接将提示“订单兑换已关闭”,完全阻断兑换。通常用于退款完成场景。'
}
/>
<div>
<Typography.Text type="secondary">原因 / 备注(可选):</Typography.Text>
<Input.TextArea
rows={3}
placeholder={
targetStatus === 'paused'
? '例如:买家申请退款中 / 售后核实'
: '例如:买家已全额退款'
}
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
style={{ marginTop: 6 }}
/>
</div>
</div>
</Modal>
</section>
)
}
@@ -1,5 +1,6 @@
import {
CloseCircleOutlined,
CopyOutlined,
ReloadOutlined,
SearchOutlined,
UserSwitchOutlined,
@@ -124,7 +125,26 @@ export default function AdminTasksPage() {
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
{row.taskNo}
</Typography.Link>
<span className="muted">{row.platformOrderId}</span>
<Space size={4}>
<span className="muted">{row.platformOrderId}</span>
{row.platformOrderId ? (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
title="复制 H5 领取链接"
style={{ padding: 0, height: 'auto', color: '#64748b' }}
onClick={(e) => {
e.stopPropagation()
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(row.platformOrderId)}`
navigator.clipboard
.writeText(h5Url)
.then(() => message.success('已复制 H5 领取链接: ' + h5Url))
.catch(() => message.error('复制失败,请手动复制'))
}}
/>
) : null}
</Space>
</div>
),
},
+3
View File
@@ -87,6 +87,9 @@ export interface AdminTaskDetail {
platformOrderId: string
payStatus: string
orderStatus: string
claimStatus?: 'active' | 'paused' | 'closed'
claimStatusReason?: string | null
claimStatusUpdatedAt?: string | null
}
orderItem: null | {
orderItemId: number