586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
import { Button, Dropdown, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
|
||
import {
|
||
CopyOutlined,
|
||
ExportOutlined,
|
||
EyeOutlined,
|
||
FileTextOutlined,
|
||
StopOutlined,
|
||
SyncOutlined,
|
||
UndoOutlined,
|
||
} from '@ant-design/icons'
|
||
import type { ColumnsType } from 'antd/es/table'
|
||
import type { RockTokenItem } from '../../api'
|
||
import { formatDateTime } from '../../utils/time'
|
||
import { TokenRemarkCell } from './TokenRemarkCell'
|
||
import type { UidEditState } from './types'
|
||
|
||
const { Text } = Typography
|
||
|
||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||
|
||
// 满 72 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。
|
||
function usingElapsedDays(usingAt?: string): number | null {
|
||
if (!usingAt) return null
|
||
const startedAt = new Date(usingAt).getTime()
|
||
if (Number.isNaN(startedAt)) return null
|
||
const diff = Date.now() - startedAt
|
||
return diff > 0 ? diff / MS_PER_DAY : 0
|
||
}
|
||
|
||
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
|
||
// 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。
|
||
export function TokenInfoCell({ record }: { record: RockTokenItem }) {
|
||
const redeemCode = (record.redeem_code || '').trim()
|
||
const extractCode = (record.token || '').trim()
|
||
const tokenNo = record.upstream_token_id || '-'
|
||
const packageNo = record.package_id ? String(record.package_id) : '-'
|
||
return (
|
||
<Space direction="vertical" size={1} style={{ width: '100%', lineHeight: 1.45 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
|
||
<span style={{ flexShrink: 0, fontSize: 10, color: '#94a3b8' }}>#{record.id}</span>
|
||
<Text
|
||
copyable={redeemCode ? { text: redeemCode, tooltips: ['复制兑换码', '已复制兑换码'] } : false}
|
||
style={{
|
||
fontSize: 11,
|
||
fontFamily: 'monospace',
|
||
fontWeight: 600,
|
||
color: '#0f172a',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{tokenNo}/{redeemCode || '-'}
|
||
</Text>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, minWidth: 0 }}>
|
||
<span style={{ flexShrink: 0, fontSize: 10, color: '#94a3b8', lineHeight: '18px' }}>包ID</span>
|
||
<Text
|
||
copyable={extractCode ? { text: extractCode, tooltips: ['复制提取码', '已复制提取码'] } : false}
|
||
style={{
|
||
fontSize: 11,
|
||
fontFamily: 'monospace',
|
||
color: '#64748b',
|
||
wordBreak: 'break-all',
|
||
}}
|
||
>
|
||
{packageNo}/{extractCode || '-'}
|
||
</Text>
|
||
</div>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
export interface TokenColumnHandlers {
|
||
copyingVoucherId: number | null
|
||
usingWarnDays: number
|
||
usingDangerDays: number
|
||
refreshingTokenId: number | null
|
||
onRefreshBinding: (id: number) => void
|
||
openingUpstreamId: number | null
|
||
onOpenUpstream: (id: number) => void
|
||
onCopyVoucher: (record: RockTokenItem) => void
|
||
onPreviewVoucher: (record: RockTokenItem) => void
|
||
onEditUid: (state: UidEditState) => void
|
||
onViewRawData: (record: RockTokenItem) => void
|
||
onResetToken: (id: number) => void
|
||
onReleaseToken: (id: number) => void
|
||
onUpdateRemark: (id: number, remark: string, status?: string) => void
|
||
onMarkAbnormal: (id: number, remark?: string) => void
|
||
}
|
||
|
||
export function buildTokenColumns({
|
||
copyingVoucherId,
|
||
usingWarnDays,
|
||
usingDangerDays,
|
||
refreshingTokenId,
|
||
onRefreshBinding,
|
||
openingUpstreamId,
|
||
onOpenUpstream,
|
||
onCopyVoucher,
|
||
onPreviewVoucher,
|
||
onEditUid,
|
||
onViewRawData,
|
||
onResetToken,
|
||
onReleaseToken,
|
||
onUpdateRemark,
|
||
onMarkAbnormal,
|
||
}: TokenColumnHandlers): ColumnsType<RockTokenItem> {
|
||
return [
|
||
{
|
||
title: 'ID / Token(编号/兑换码 · 包ID/提取码)',
|
||
key: 'token_info',
|
||
width: 320,
|
||
render: (_, record) => <TokenInfoCell record={record} />,
|
||
},
|
||
{
|
||
title: '规格与适用范围',
|
||
dataIndex: 'color',
|
||
width: 120,
|
||
render: (_, record) => (
|
||
<Space direction="vertical" size={2}>
|
||
<Tag
|
||
color="geekblue"
|
||
style={{ borderRadius: 4, fontWeight: 500, fontSize: 11, padding: '0 4px', margin: 0 }}
|
||
>
|
||
通用黑色 Token
|
||
</Tag>
|
||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||
{record.region || 'Q/V区通用'}
|
||
</Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '库存状态',
|
||
dataIndex: 'status',
|
||
width: 165,
|
||
render: (status, record) => {
|
||
switch (status) {
|
||
case 'available':
|
||
return (
|
||
<Tag color="success" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||
● 可用库存
|
||
</Tag>
|
||
)
|
||
case 'using': {
|
||
const days = usingElapsedDays(record.using_at)
|
||
if (days !== null && days >= usingDangerDays) {
|
||
return (
|
||
<Tag
|
||
color="error"
|
||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
|
||
>
|
||
⚠️ 使用中超时 {days.toFixed(1)} 天
|
||
</Tag>
|
||
)
|
||
}
|
||
if (days !== null && days >= usingWarnDays) {
|
||
return (
|
||
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||
● 扫码使用中 · {days.toFixed(1)} 天
|
||
</Tag>
|
||
)
|
||
}
|
||
return (
|
||
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||
● 扫码使用中
|
||
</Tag>
|
||
)
|
||
}
|
||
case 'bound':
|
||
return (
|
||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||
● 已授权绑定
|
||
</Tag>
|
||
)
|
||
case 'redeemed':
|
||
return (
|
||
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||
● 已交付发货
|
||
</Tag>
|
||
)
|
||
case 'mismatch':
|
||
return (
|
||
<Tag color="error" style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}>
|
||
⚠️ 绑定错误 (阻断)
|
||
</Tag>
|
||
)
|
||
case 'abnormal':
|
||
return (
|
||
<Tag
|
||
color="default"
|
||
style={{
|
||
fontWeight: 600,
|
||
padding: '2px 8px',
|
||
color: '#b91c1c',
|
||
background: '#fef2f2',
|
||
border: '1px solid #fca5a5',
|
||
}}
|
||
>
|
||
🚫 已上报失效
|
||
</Tag>
|
||
)
|
||
default:
|
||
return <Tag>{status}</Tag>
|
||
}
|
||
},
|
||
},
|
||
{
|
||
title: '流转时间',
|
||
width: 180,
|
||
render: (_, record) => {
|
||
const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null
|
||
const usingColor =
|
||
usingDays === null
|
||
? '#64748b'
|
||
: usingDays >= usingDangerDays
|
||
? '#dc2626'
|
||
: usingDays >= usingWarnDays
|
||
? '#d97706'
|
||
: '#0891b2'
|
||
return (
|
||
<Space direction="vertical" size={2}>
|
||
<div style={{ fontSize: 12, color: '#334155' }}>
|
||
<span style={{ color: '#94a3b8' }}>入库: </span>
|
||
{formatDateTime(record.created_at)}
|
||
</div>
|
||
{record.status === 'using' && record.using_at && (
|
||
<div style={{ fontSize: 11, color: usingColor }}>
|
||
<span>⏳ 使用中: </span>
|
||
{formatDateTime(record.using_at)}
|
||
{usingDays !== null ? `(${usingDays.toFixed(1)} 天)` : ''}
|
||
</div>
|
||
)}
|
||
{record.mismatch_at && (
|
||
<div style={{ fontSize: 11, color: '#e11d48' }}>
|
||
<span>⚠️ 绑错: </span>
|
||
{formatDateTime(record.mismatch_at)}
|
||
</div>
|
||
)}
|
||
{record.redeemed_at && (
|
||
<div style={{ fontSize: 11, color: '#16a34a' }}>
|
||
<span>✅ 发货: </span>
|
||
{formatDateTime(record.redeemed_at)}
|
||
</div>
|
||
)}
|
||
{!record.redeemed_at && record.bound_at && (
|
||
<div style={{ fontSize: 11, color: '#0284c7' }}>
|
||
<span>🔗 绑定: </span>
|
||
{formatDateTime(record.bound_at)}
|
||
</div>
|
||
)}
|
||
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
|
||
<div style={{ fontSize: 11, color: '#6366f1' }}>
|
||
<span>📦 提货: </span>
|
||
{formatDateTime(record.claimed_at)}
|
||
</div>
|
||
)}
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '关联业务工单',
|
||
dataIndex: 'assigned_order_no',
|
||
width: 158,
|
||
render: (orderNo, record) => {
|
||
if (!orderNo) {
|
||
return <Text type="secondary">-</Text>
|
||
}
|
||
return (
|
||
<Space direction="vertical" size={3}>
|
||
{/* 系统履约工单号 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Text
|
||
code
|
||
copyable={{ text: orderNo }}
|
||
style={{ fontWeight: 600, color: '#1d4ed8', fontSize: 12 }}
|
||
title="系统履约工单号"
|
||
>
|
||
{orderNo}
|
||
</Text>
|
||
</div>
|
||
{/* 创建订单时的商户单号 */}
|
||
{record.client_order_no ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
|
||
<span style={{ color: '#64748b' }}>商户:</span>
|
||
<Text
|
||
copyable={{ text: record.client_order_no }}
|
||
type="secondary"
|
||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||
title="创建订单时的商户单号 (ClientOrderNo)"
|
||
>
|
||
{record.client_order_no}
|
||
</Text>
|
||
</div>
|
||
) : null}
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '预期 UID vs 扫码角色 (风控门禁)',
|
||
width: 255,
|
||
render: (_, record) => {
|
||
const isMismatch = record.status === 'mismatch'
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: isMismatch ? '6px 10px' : '0',
|
||
background: isMismatch ? '#fff1f0' : 'transparent',
|
||
borderRadius: isMismatch ? 6 : 0,
|
||
border: isMismatch ? '1px dashed #ffa39e' : 'none',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
||
<span style={{ color: '#64748b' }}>预期 UID:</span>
|
||
<Text strong style={{ color: '#0f172a' }}>{record.expected_uid || '-'}</Text>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginTop: 2 }}>
|
||
<span style={{ color: '#64748b' }}>扫码反查:</span>
|
||
<Text type={isMismatch ? 'danger' : undefined} strong={isMismatch}>
|
||
{record.actual_role_name ? `${record.actual_role_name} (${record.actual_role_id || '-'})` : '-'}
|
||
</Text>
|
||
</div>
|
||
{isMismatch && record.mismatch_reason && (
|
||
<div style={{ marginTop: 4 }}>
|
||
<Tooltip title={record.mismatch_reason}>
|
||
<Tag color="error" style={{ fontSize: 11, margin: 0, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{record.mismatch_reason}
|
||
</Tag>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '备注',
|
||
width: 44,
|
||
align: 'center',
|
||
render: (_, record) => (
|
||
<TokenRemarkCell
|
||
record={record}
|
||
onUpdateRemark={onUpdateRemark}
|
||
onMarkAbnormal={onMarkAbnormal}
|
||
compact
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '凭证',
|
||
key: 'voucher',
|
||
width: 88,
|
||
align: 'center',
|
||
render: (_, record) => {
|
||
if (record.status !== 'redeemed' && !record.upstream_fulfill_id) {
|
||
return <Text type="secondary">-</Text>
|
||
}
|
||
const isCopying = copyingVoucherId === record.id
|
||
const isRockBlue = record.target_skin?.includes('蓝')
|
||
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
|
||
const imgSrc = `/images/products/${sku}.webp`
|
||
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||
<Dropdown
|
||
trigger={['click']}
|
||
menu={{
|
||
items: [
|
||
{ key: 'copy', icon: <CopyOutlined />, label: '复制发货结果图' },
|
||
{ key: 'preview', icon: <EyeOutlined />, label: '预览高清大图' },
|
||
],
|
||
onClick: ({ key }) => {
|
||
if (key === 'copy') onCopyVoucher(record)
|
||
if (key === 'preview') onPreviewVoucher(record)
|
||
},
|
||
}}
|
||
>
|
||
<div
|
||
title={`${record.target_skin || '黑色ROCK长袖T恤'} · 点击展开复制/预览`}
|
||
style={{
|
||
width: 30,
|
||
height: 30,
|
||
flexShrink: 0,
|
||
borderRadius: 6,
|
||
border: '1px solid #e2e8f0',
|
||
background: '#f8fafc',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<img
|
||
src={imgSrc}
|
||
alt={record.target_skin || '道具'}
|
||
style={{ width: '100%', height: '100%', objectFit: 'contain', padding: 2 }}
|
||
/>
|
||
</div>
|
||
</Dropdown>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<Tooltip title={isCopying ? '正在生成发货结果图…' : '复制发货结果图'}>
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<CopyOutlined />}
|
||
loading={isCopying}
|
||
onClick={() => onCopyVoucher(record)}
|
||
style={{ width: 20, height: 20, padding: 0, fontSize: 11 }}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="预览高清大图">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<EyeOutlined />}
|
||
onClick={() => onPreviewVoucher(record)}
|
||
style={{ width: 20, height: 20, padding: 0, fontSize: 11 }}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '原始',
|
||
width: 44,
|
||
align: 'center',
|
||
render: (_, record) =>
|
||
record.raw_data ? (
|
||
<Tooltip title="查看原始数据">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<FileTextOutlined />}
|
||
onClick={() => onViewRawData(record)}
|
||
/>
|
||
</Tooltip>
|
||
) : (
|
||
<Text type="secondary">-</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 140,
|
||
fixed: 'right',
|
||
render: (_, record) => {
|
||
const refreshButton = (
|
||
<Button
|
||
size="small"
|
||
icon={<SyncOutlined />}
|
||
loading={refreshingTokenId === record.id}
|
||
onClick={() => onRefreshBinding(record.id)}
|
||
>
|
||
刷新
|
||
</Button>
|
||
)
|
||
const upstreamDisabled = !record.package_id
|
||
const upstreamTip = upstreamDisabled
|
||
? '该 Token 未关联上游打包记录,无法跳转上游'
|
||
: record.package_share_code
|
||
? ''
|
||
: '本地暂无包分享码,点击时会自动向上游反查'
|
||
const upstreamButton = (
|
||
<Tooltip title={upstreamTip || undefined}>
|
||
<span style={{ display: 'inline-block' }}>
|
||
<Button
|
||
size="small"
|
||
icon={<ExportOutlined />}
|
||
disabled={upstreamDisabled}
|
||
loading={openingUpstreamId === record.id}
|
||
onClick={() => onOpenUpstream(record.id)}
|
||
>
|
||
跳转上游
|
||
</Button>
|
||
</span>
|
||
</Tooltip>
|
||
)
|
||
if (record.status === 'mismatch') {
|
||
return (
|
||
<Space direction="vertical" size={4}>
|
||
{refreshButton}
|
||
{upstreamButton}
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
onClick={() =>
|
||
onEditUid({
|
||
id: record.id,
|
||
value: record.actual_role_id || record.expected_uid || '',
|
||
scanned: record.actual_role_id || '',
|
||
roleName: record.actual_role_name || '',
|
||
})
|
||
}
|
||
>
|
||
修改 UID
|
||
</Button>
|
||
<Popconfirm
|
||
title="确定将该链接标记为已上报失效?"
|
||
description="上报给上游后该链接将永久作废,系统不会再将其作为可用库存分配给任何新订单。"
|
||
okText="确认上报失效"
|
||
cancelText="取消"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={() => onMarkAbnormal(record.id, '绑定错误-已上报上游 (已失效)')}
|
||
>
|
||
<Button size="small" danger icon={<StopOutlined />}>
|
||
上报失效
|
||
</Button>
|
||
</Popconfirm>
|
||
<Popconfirm
|
||
title="确定解冻重置该 Token 回库存?"
|
||
description="注意:仅在确认卡密未作废、未上报上游时操作。重置后将清除原订单绑定与错误标记,重新释放为可用库存。若已上报上游请点击【上报失效】。"
|
||
okText="确认重置回库存"
|
||
cancelText="取消"
|
||
onConfirm={() => onResetToken(record.id)}
|
||
>
|
||
<Button size="small" ghost style={{ color: '#64748b', borderColor: '#cbd5e1' }} icon={<UndoOutlined />}>
|
||
解冻重置
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
)
|
||
}
|
||
if (record.status === 'abnormal') {
|
||
return (
|
||
<Space direction="vertical" size={4}>
|
||
<Text type="secondary" style={{ fontSize: 11 }}>已失效作废</Text>
|
||
{upstreamButton}
|
||
<Popconfirm
|
||
title="确定强制恢复为可用库存?"
|
||
description="警告:该链接曾标记为已上报失效。若上游已作废该卡密,重新放回库存将导致下一单提货失败!请确保卡密真实可用。"
|
||
okText="强制恢复回库存"
|
||
cancelText="取消"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={() => onResetToken(record.id)}
|
||
>
|
||
<Button size="small" icon={<UndoOutlined />} style={{ fontSize: 11 }}>
|
||
恢复回库存
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
)
|
||
}
|
||
if (record.status === 'using') {
|
||
const canRelease = !record.bound_at && !record.actual_role_id
|
||
return (
|
||
<Space direction="vertical" size={4}>
|
||
{refreshButton}
|
||
{upstreamButton}
|
||
{canRelease && (
|
||
<Popconfirm
|
||
title="释放这条未绑定的提货链接?"
|
||
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
|
||
okText="确认释放"
|
||
cancelText="取消"
|
||
onConfirm={() => onReleaseToken(record.id)}
|
||
>
|
||
<Button size="small" icon={<UndoOutlined />}>
|
||
释放回库存
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
)
|
||
}
|
||
if (record.status === 'bound') {
|
||
return (
|
||
<Space direction="vertical" size={4}>
|
||
{refreshButton}
|
||
{upstreamButton}
|
||
</Space>
|
||
)
|
||
}
|
||
return (
|
||
<Space direction="vertical" size={4}>
|
||
{upstreamButton}
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
}
|