Files
affiliate_dash/frontend/src/pages/platformRock/tokenColumns.tsx
T

586 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
},
},
]
}