refactor(web): 拆分 PlatformRock 大屏为容器 + 9 个子组件(1306 → 最大 335 行)

- PlatformRock.tsx 335 行:保留全部 state、loadData/loadTokens、useEffect、
  handler 与顶层布局;默认导出名不变,App.tsx 的 lazy 导入未改
- platformRock/ 子目录:tokenColumns 329、RockStrategyCard 267、
  PurchaseTokenModal 183、RockTokenTable 156、RockMetricCards 124、
  VoucherPreviewModal 95、RockTokenFilterBar 76、UidEditModal 47、types.ts 6

state 归属与更新时机、请求次数与参数、useEffect 依赖语义均未改变;子组件 props
显式声明类型,无 any;行级异步 handler 随表格下沉,state setter 经显式类型 props 传入。

验证:
- SSR 快照 renderToString 拆分前后字节一致(40920 bytes)
- jsdom 交互差分:拆分前后 23 个检查点的 DOM 快照与 API 调用日志完全一致
  (覆盖采购、UID 修正、解冻、释放、熔断、筛选/搜索/翻页、保存配置、连通性测试)
- 原文件应保留的 230 行代码逐字命中
This commit is contained in:
yml2213
2026-10-06 19:43:29 +08:00
parent c9e35d5d90
commit b1089f37b2
10 changed files with 1367 additions and 1055 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
import { Button, Col, Divider, InputNumber, Modal, Row, Space } from 'antd'
import { ShoppingCartOutlined } from '@ant-design/icons'
import type { RockStats } from '../../api'
export interface PurchaseTokenModalProps {
open: boolean
stats: RockStats | null
purchaseCount: number
purchasing: boolean
maxPurchasable: number
upstreamPoints: number
estimatedCost: number
isPointsInsufficient: boolean
onPurchaseCountChange: (value: number) => void
onCancel: () => void
onConfirm: (count: number) => void
}
export function PurchaseTokenModal({
open,
stats,
purchaseCount,
purchasing,
maxPurchasable,
upstreamPoints,
estimatedCost,
isPointsInsufficient,
onPurchaseCountChange,
onCancel,
onConfirm,
}: PurchaseTokenModalProps) {
return (
<Modal
title={
<Space>
<ShoppingCartOutlined style={{ color: '#2563eb' }} />
<span>手动采购通用黑色 Token (0666.pro)</span>
</Space>
}
open={open}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
取消
</Button>,
<Button
key="submit"
type="primary"
icon={<ShoppingCartOutlined />}
loading={purchasing}
disabled={isPointsInsufficient || purchaseCount <= 0}
onClick={() => onConfirm(purchaseCount)}
style={{
background: isPointsInsufficient
? undefined
: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
}}
>
确认采购 {purchaseCount} 个 ({estimatedCost} 点)
</Button>,
]}
destroyOnClose
width={500}
>
<div style={{ marginTop: 16 }}>
{/* 账户与资产概览 */}
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '12px 16px',
marginBottom: 18,
}}
>
<Row gutter={16}>
<Col span={12}>
<div style={{ fontSize: 12, color: '#64748b' }}>0666.pro 采购商户</div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
{stats?.username || '正常商户'}
</div>
</Col>
<Col span={12}>
<div style={{ fontSize: 12, color: '#64748b' }}>当前实时积分余额</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#7c3aed', marginTop: 2 }}>
{upstreamPoints.toFixed(1)} 点
</div>
</Col>
</Row>
<Divider style={{ margin: '10px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#64748b' }}>
<span>采购单价:<strong style={{ color: '#0f172a' }}>21 点 / 个</strong></span>
<span>最大可采购:<strong style={{ color: '#2563eb' }}>{maxPurchasable} 个</strong></span>
</div>
</div>
{/* 采购数量输入 */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontWeight: 600, marginBottom: 8, color: '#0f172a' }}>
采购数量:
</div>
<InputNumber
min={1}
max={maxPurchasable > 0 ? maxPurchasable : 10000}
value={purchaseCount}
onChange={(val) => onPurchaseCountChange(val || 1)}
style={{ width: '100%' }}
addonAfter="个 Token"
size="large"
/>
</div>
{/* 快捷数量选择胶囊 */}
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 8 }}>快捷选择:</div>
<Space wrap size={[8, 8]}>
{[10, 20, 50, 100].map((num) => (
<Button
key={num}
size="small"
type={purchaseCount === num ? 'primary' : 'default'}
onClick={() => onPurchaseCountChange(num)}
disabled={maxPurchasable > 0 && num > maxPurchasable}
>
{num} 个
</Button>
))}
{maxPurchasable > 0 && (
<Button
size="small"
type={purchaseCount === maxPurchasable ? 'primary' : 'dashed'}
onClick={() => onPurchaseCountChange(maxPurchasable)}
style={{ color: '#2563eb', borderColor: '#bfdbfe' }}
>
全部可购 ({maxPurchasable} 个)
</Button>
)}
</Space>
</div>
{/* 实时消耗与结余试算 */}
<div
style={{
background: isPointsInsufficient ? '#fef2f2' : '#f0fdf4',
border: `1px solid ${isPointsInsufficient ? '#fecaca' : '#bbf7d0'}`,
borderRadius: 8,
padding: '12px 16px',
fontSize: 13,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: isPointsInsufficient ? '#991b1b' : '#166534' }}>
本次采购消耗积分:
</span>
<strong style={{ color: isPointsInsufficient ? '#dc2626' : '#16a34a', fontSize: 16 }}>
{estimatedCost} 点
</strong>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 6,
fontSize: 12,
}}
>
<span style={{ color: isPointsInsufficient ? '#b91c1c' : '#15803d' }}>
{isPointsInsufficient
? '⚠️ 账户积分不足,请先充值或减少采购数量'
: '采购后预计剩余积分:'}
</span>
{!isPointsInsufficient && (
<span style={{ color: '#64748b' }}>
{(upstreamPoints - estimatedCost).toFixed(1)} 点
</span>
)}
</div>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,124 @@
import { Col, Row, Tag } from 'antd'
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
FireOutlined,
ShoppingCartOutlined,
} from '@ant-design/icons'
import type { RockStats } from '../../api'
export interface RockMetricCardsProps {
stats: RockStats | null
availableStock: number
warningStock: number
isStockLow: boolean
hasMismatch: boolean
upstreamPoints: number
maxPurchasable: number
onOpenPurchase: (count: number) => void
}
export function RockMetricCards({
stats,
availableStock,
warningStock,
isStockLow,
hasMismatch,
upstreamPoints,
maxPurchasable,
onOpenPurchase,
}: RockMetricCardsProps) {
return (
<Row gutter={[16, 16]} style={{ marginBottom: 20 }}>
<Col xs={24} sm={12} lg={6}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">可用通用库存 (Available)</span>
<div className={`metric-card-icon ${isStockLow ? 'metric-card-icon--rose' : ''}`}>
<ShoppingCartOutlined />
</div>
</div>
<div className="metric-card-value" style={{ color: isStockLow ? '#e11d48' : '#0f172a' }}>
{availableStock}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>个</span>
</div>
<div className="metric-card-footer">
<span>警戒水位: {warningStock} 个</span>
{isStockLow ? (
<Tag color="error" style={{ margin: 0, borderRadius: 4 }}>库存紧缺</Tag>
) : (
<span style={{ color: '#10b981', fontWeight: 600 }}>储备充足</span>
)}
</div>
</div>
</Col>
<Col xs={24} sm={12} lg={6}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">绑定错误阻断 (Mismatch)</span>
<div className={`metric-card-icon ${hasMismatch ? 'metric-card-icon--rose' : ''}`}>
<ExclamationCircleOutlined />
</div>
</div>
<div className="metric-card-value" style={{ color: hasMismatch ? '#e11d48' : '#0f172a' }}>
{stats?.mismatch_stock ?? 0}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>个</span>
</div>
<div className="metric-card-footer">
<span>{hasMismatch ? 'UID强一致校验拦截保护' : '暂无异常阻断'}</span>
{hasMismatch ? (
<Tag color="error" style={{ margin: 0, borderRadius: 4 }}>需核对处理</Tag>
) : (
<span style={{ color: '#10b981', fontWeight: 600 }}>状态正常</span>
)}
</div>
</div>
</Col>
<Col xs={24} sm={12} lg={6}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">已履约交付 (Redeemed)</span>
<div className="metric-card-icon metric-card-icon--emerald">
<CheckCircleOutlined />
</div>
</div>
<div className="metric-card-value">
{stats?.redeemed_stock ?? 0}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>个</span>
</div>
<div className="metric-card-footer">
<span>当前扫码中</span>
<span style={{ fontWeight: 600, color: '#2563eb' }}>{stats?.using_stock ?? 0} 个</span>
</div>
</div>
</Col>
<Col xs={24} sm={12} lg={6}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">0666.pro 账号积分</span>
<div className="metric-card-icon metric-card-icon--purple">
<FireOutlined />
</div>
</div>
<div className="metric-card-value" style={{ color: '#7c3aed' }}>
{upstreamPoints.toFixed(1)}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>点</span>
</div>
<div className="metric-card-footer">
<span>账号: {stats?.username || '未同步'}</span>
<a
onClick={() => onOpenPurchase(maxPurchasable > 0 ? Math.min(50, maxPurchasable) : 10)}
style={{ fontWeight: 600, color: '#2563eb', cursor: 'pointer' }}
title="点击快速打开手动采购弹窗"
>
约可采购 {maxPurchasable} 个 ➔
</a>
</div>
</div>
</Col>
</Row>
)
}
@@ -0,0 +1,267 @@
import {
Button,
Card,
Col,
Divider,
Form,
Input,
InputNumber,
Row,
Space,
Switch,
Tag,
Tooltip,
Typography,
} from 'antd'
import type { FormInstance } from 'antd'
import {
ControlOutlined,
ExclamationCircleOutlined,
KeyOutlined,
SafetyCertificateOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
const { Text } = Typography
export interface RockStrategyCardProps {
form: FormInstance
autoDelivery: boolean
testingConn: boolean
savingConfig: boolean
onToggleAutoDelivery: (checked: boolean) => void
onTestConnection: () => void
onSaveConfig: () => void
}
export function RockStrategyCard({
form,
autoDelivery,
testingConn,
savingConfig,
onToggleAutoDelivery,
onTestConnection,
onSaveConfig,
}: RockStrategyCardProps) {
return (
<Card
style={{
marginBottom: 20,
borderRadius: 12,
border: '1px solid #e2e8f0',
boxShadow: 'var(--shadow-sm)',
}}
styles={{ body: { padding: '20px 24px' } }}
>
{/* 通道状态与软熔断横幅 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 18px',
borderRadius: 8,
marginBottom: 20,
background: autoDelivery ? '#f0fdf4' : '#fef2f2',
border: autoDelivery ? '1px solid #bbf7d0' : '1px solid #fecaca',
transition: 'all 0.3s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 38,
height: 38,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 18,
background: autoDelivery ? '#dcfce7' : '#fee2e2',
color: autoDelivery ? '#16a34a' : '#dc2626',
}}
>
{autoDelivery ? <SafetyCertificateOutlined /> : <ControlOutlined />}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong style={{ fontSize: 14, color: autoDelivery ? '#166534' : '#991b1b' }}>
{autoDelivery
? '自动发货通道正常运转 (Active)'
: '自动发货已全局软熔断 (Kill Switch Engaged)'}
</strong>
<Tag color={autoDelivery ? 'success' : 'error'} style={{ borderRadius: 10 }}>
{autoDelivery ? '反代履约中' : '已转交人工打手'}
</Tag>
</div>
<div style={{ fontSize: 12, color: autoDelivery ? '#15803d' : '#b91c1c', marginTop: 2 }}>
{autoDelivery
? '和平精英「黑色 ROCK 长袖」与「蓝色运动服」开启 0666.pro 全自动反代发货,UID 强一致安全门禁全面守护。'
: '两款商品已彻底切断上游 0666.pro 自动发货通道,所有新订单均保留在待发货状态,由人工接单平台处理。'}
</div>
</div>
</div>
<Space size="middle">
<Switch
checked={autoDelivery}
onChange={onToggleAutoDelivery}
checkedChildren="开启"
unCheckedChildren="熔断"
style={{
backgroundColor: autoDelivery ? '#16a34a' : '#dc2626',
}}
/>
<Button
size="small"
danger={autoDelivery}
type={autoDelivery ? 'default' : 'primary'}
onClick={() => onToggleAutoDelivery(!autoDelivery)}
>
{autoDelivery ? '一键紧急熔断' : '恢复自动发货'}
</Button>
</Space>
</div>
{/* 策略表单 */}
<Form form={form} layout="vertical" autoComplete="off">
{/* 阻断浏览器对凭据配置项的强制账密自动填充 (Honeypot) */}
<div style={{ position: 'absolute', top: -9999, left: -9999, width: 0, height: 0, overflow: 'hidden' }} aria-hidden="true">
<input type="text" name="chrome_prevent_autofill_username" tabIndex={-1} autoComplete="username" />
<input type="password" name="chrome_prevent_autofill_password" tabIndex={-1} autoComplete="current-password" />
</div>
<Row gutter={24}>
<Col xs={24} lg={12}>
<div
style={{
background: '#f8fafc',
padding: '16px 18px',
borderRadius: 10,
border: '1px solid #f1f5f9',
height: '100%',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<KeyOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<strong style={{ fontSize: 14, color: '#0f172a' }}>0666.pro 采购认证凭据</strong>
</div>
<Form.Item name="base_url" label="上游平台接口地址 (Base URL)" style={{ marginBottom: 14 }}>
<Input
placeholder="https://0666.pro"
autoComplete="off"
name="upstream_base_url"
id="upstream_base_url"
/>
</Form.Item>
<Form.Item
name="bearer_token"
label={
<Space>
<span>上游商户 Bearer Token (yd_token)</span>
<Tooltip title="0666.pro 采用无密码架构,直接从网页 localStorage 提取 64 位十六进制 Token 即可">
<ExclamationCircleOutlined style={{ color: '#94a3b8' }} />
</Tooltip>
</Space>
}
style={{ marginBottom: 12 }}
>
<Input.Password
placeholder="粘贴 64 位十六进制 Bearer Token"
autoComplete="new-password"
name="upstream_bearer_token"
id="upstream_bearer_token"
addonAfter={
<Button
type="link"
size="small"
onClick={onTestConnection}
loading={testingConn}
style={{ padding: '0 4px', height: 'auto' }}
>
测试连通性
</Button>
}
/>
</Form.Item>
<div
style={{
background: '#eff6ff',
border: '1px dashed #bfdbfe',
borderRadius: 6,
padding: '8px 12px',
fontSize: 12,
color: '#1e40af',
}}
>
💡 <strong>极简提取指引:</strong>登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '}
<Text code copyable={{ text: "localStorage.getItem('yd_token')" }}>
localStorage.getItem('yd_token')
</Text>{' '}
即可一键复制。
</div>
</div>
</Col>
<Col xs={24} lg={12}>
<div
style={{
background: '#f8fafc',
padding: '16px 18px',
borderRadius: 10,
border: '1px solid #f1f5f9',
height: '100%',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<ThunderboltOutlined style={{ color: '#7c3aed', fontSize: 16 }} />
<strong style={{ fontSize: 14, color: '#0f172a' }}>库存风控与自动补货策略</strong>
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="token_warning_stock" label="库存告警安全水位">
<InputNumber min={1} max={500} style={{ width: '100%' }} addonAfter="个" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="auto_purchase_enabled" label="自动补货触发开关" valuePropName="checked">
<Switch checkedChildren="开启自动补货" unCheckedChildren="关闭" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="auto_purchase_count" label="单次自动补货数量">
<InputNumber min={5} max={200} style={{ width: '100%' }} addonAfter="个" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="purchase_cooldown_minutes" label="补货防抖冷却时间">
<InputNumber min={1} max={60} style={{ width: '100%' }} addonAfter="分钟" />
</Form.Item>
</Col>
</Row>
</div>
</Col>
</Row>
<Divider style={{ margin: '18px 0 16px 0' }} />
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
onClick={onSaveConfig}
loading={savingConfig}
style={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
minWidth: 130,
}}
>
保存策略配置
</Button>
</div>
</Form>
</Card>
)
}
@@ -0,0 +1,76 @@
import { Badge, Button, Card, Col, Input, Radio, Row, Space, Typography } from 'antd'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import type { RockStats } from '../../api'
const { Text } = Typography
export interface RockTokenFilterBarProps {
stats: RockStats | null
searchKeyword: string
filterStatus: string
totalTokens: number
loadingTokens: boolean
onSearchKeywordChange: (value: string) => void
onFilterStatusChange: (value: string) => void
onRefresh: () => void
}
export function RockTokenFilterBar({
stats,
searchKeyword,
filterStatus,
totalTokens,
loadingTokens,
onSearchKeywordChange,
onFilterStatusChange,
onRefresh,
}: RockTokenFilterBarProps) {
return (
<Card
styles={{ body: { padding: '16px 20px' } }}
style={{ marginBottom: 16, borderRadius: 10, border: '1px solid #e2e8f0' }}
>
<Row gutter={[16, 12]} align="middle" justify="space-between">
<Col xs={24} md={16}>
<Space size="middle" wrap>
<Input
placeholder="搜索 0666 编号 / 提取码 / 订单号 / UID"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchKeyword}
onChange={(e) => onSearchKeywordChange(e.target.value)}
onPressEnter={onRefresh}
style={{ width: 340 }}
allowClear
/>
<Radio.Group
value={filterStatus}
onChange={(e) => {
onFilterStatusChange(e.target.value)
}}
buttonStyle="solid"
>
<Radio.Button value="all">全部</Radio.Button>
<Radio.Button value="available">可用</Radio.Button>
<Radio.Button value="using">使用中</Radio.Button>
<Radio.Button value="bound">已绑定</Radio.Button>
<Radio.Button value="redeemed">已发货</Radio.Button>
<Radio.Button value="mismatch">
<Badge count={stats?.mismatch_stock ?? 0} offset={[6, -2]} size="small">
<span>绑定错误</span>
</Badge>
</Radio.Button>
</Radio.Group>
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
刷新
</Button>
</Space>
</Col>
<Col xs={24} md={8} style={{ textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
共检索到 <strong style={{ color: '#0f172a' }}>{totalTokens}</strong> 条库存记录
</Text>
</Col>
</Row>
</Card>
)
}
@@ -0,0 +1,156 @@
import type { Dispatch, SetStateAction } from 'react'
import { Table, message } from 'antd'
import type { RockTokenItem } from '../../api'
import { rockApi } from '../../api'
import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../../utils/voucher'
import { formatDateTime } from '../../utils/time'
import { buildTokenColumns } from './tokenColumns'
import type { UidEditState } from './types'
export interface RockTokenTableProps {
tokens: RockTokenItem[]
loadingTokens: boolean
page: number
pageSize: number
totalTokens: number
copyingVoucherId: number | null
setCopyingVoucherId: Dispatch<SetStateAction<number | null>>
setPreviewVoucherUrl: Dispatch<SetStateAction<string>>
setPreviewVoucherOpen: Dispatch<SetStateAction<boolean>>
onEditUid: (state: UidEditState) => void
onPageChange: (page: number, pageSize: number) => void
onReloadData: () => void
onReloadTokens: () => void
}
export function RockTokenTable({
tokens,
loadingTokens,
page,
pageSize,
totalTokens,
copyingVoucherId,
setCopyingVoucherId,
setPreviewVoucherUrl,
setPreviewVoucherOpen,
onEditUid,
onPageChange,
onReloadData,
onReloadTokens,
}: RockTokenTableProps) {
// 人工重置解冻 Mismatch Token
const handleResetToken = async (id: number) => {
try {
await rockApi.resetToken(id)
message.success('已解除绑定异常,重置为可用库存')
onReloadData()
onReloadTokens()
} catch (e) {
message.error('重置失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
}
// 买家未扫码绑定时,把占用中的提货链接放回库存
const handleReleaseToken = async (id: number) => {
try {
await rockApi.releaseToken(id)
message.success('已释放回可用库存,该提货链接可以再次分配')
onReloadData()
onReloadTokens()
} catch (e) {
message.error('释放失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
}
// 复制/生成发货凭证图片
const handleCopyVoucher = async (record: RockTokenItem) => {
setCopyingVoucherId(record.id)
try {
const isRockBlue = record.target_skin?.includes('蓝')
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤')
const channel = record.actual_channel || record.region || '安卓-微信'
const timeStr = record.redeemed_at || record.bound_at || record.created_at
const cleanRole = (record.actual_role_name || '').trim()
const dataURL = await generateVoucherDataURL({
productName,
productSKU: sku,
roleName: cleanRole && cleanRole !== '-' ? cleanRole : '',
roleUID: record.expected_uid || record.actual_role_id || '-',
channel,
currencyText: '幸运币 x 10',
redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()),
})
const copied = await copyVoucherDataUrlToClipboard(dataURL)
if (copied) {
message.success('发货结果图片已复制到剪贴板!可直接在微信/QQ聊天框粘贴 (Ctrl+V)')
} else {
setPreviewVoucherUrl(dataURL)
setPreviewVoucherOpen(true)
message.info('已生成凭据图片,已为您打开预览窗口,支持直接保存或右键复制')
}
} catch (e) {
message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setCopyingVoucherId(null)
}
}
const handlePreviewVoucher = async (record: RockTokenItem) => {
try {
const isRockBlue = record.target_skin?.includes('蓝')
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤')
const channel = record.actual_channel || record.region || '安卓-微信'
const timeStr = record.redeemed_at || record.bound_at || record.created_at
const cleanRole = (record.actual_role_name || '').trim()
const dataURL = await generateVoucherDataURL({
productName,
productSKU: sku,
roleName: cleanRole && cleanRole !== '-' ? cleanRole : '',
roleUID: record.expected_uid || record.actual_role_id || '-',
channel,
currencyText: '幸运币 x 10',
redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()),
})
setPreviewVoucherUrl(dataURL)
setPreviewVoucherOpen(true)
} catch (e) {
message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
}
const columns = buildTokenColumns({
copyingVoucherId,
onCopyVoucher: handleCopyVoucher,
onPreviewVoucher: handlePreviewVoucher,
onEditUid,
onResetToken: handleResetToken,
onReleaseToken: handleReleaseToken,
})
return (
<Table
rowKey="id"
columns={columns}
dataSource={tokens}
loading={loadingTokens}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: totalTokens,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total) => `共 ${total} 条库存记录`,
onChange: (p, ps) => {
onPageChange(p, ps)
},
}}
/>
)
}
@@ -0,0 +1,47 @@
import { Input, Modal } from 'antd'
import type { UidEditState } from './types'
export interface UidEditModalProps {
uidEdit: UidEditState | null
savingUid: boolean
onValueChange: (value: string) => void
onConfirm: () => void
onCancel: () => void
}
export function UidEditModal({
uidEdit,
savingUid,
onValueChange,
onConfirm,
onCancel,
}: UidEditModalProps) {
return (
<Modal
title="修改买家填写的 UID"
open={Boolean(uidEdit)}
okText="保存"
cancelText="取消"
confirmLoading={savingUid}
onOk={() => void onConfirm()}
onCancel={onCancel}
destroyOnClose
>
<p style={{ marginTop: 0, color: '#64748b' }}>
提货号继续留在原订单上,不会放回库存。改成扫码 UID 后,绑定阻断会解除。
</p>
{uidEdit?.scanned ? (
<p>
扫码角色:{uidEdit.roleName ? `「${uidEdit.roleName}」` : ''} {uidEdit.scanned}
</p>
) : null}
<Input
value={uidEdit?.value || ''}
maxLength={15}
placeholder="买家游戏 UID"
onChange={(event) => onValueChange(event.target.value.replace(/\D/g, '').slice(0, 15))}
onPressEnter={() => void onConfirm()}
/>
</Modal>
)
}
@@ -0,0 +1,95 @@
import { Button, Modal, Space, Typography, message } from 'antd'
import { CopyOutlined, DownloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import { copyVoucherDataUrlToClipboard } from '../../utils/voucher'
const { Text } = Typography
export interface VoucherPreviewModalProps {
open: boolean
previewVoucherUrl: string
onClose: () => void
}
export function VoucherPreviewModal({
open,
previewVoucherUrl,
onClose,
}: VoucherPreviewModalProps) {
return (
<Modal
title={
<Space>
<SafetyCertificateOutlined style={{ color: '#10b981' }} />
<span>发货到账存证凭据 (高清预览)</span>
</Space>
}
open={open}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
关闭
</Button>,
<Button
key="download"
icon={<DownloadOutlined />}
onClick={() => {
if (!previewVoucherUrl) return
const link = document.createElement('a')
link.href = previewVoucherUrl
link.download = `发货凭证_${Date.now()}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('凭据图片已下载')
}}
>
下载凭证图
</Button>,
<Button
key="copy"
type="primary"
icon={<CopyOutlined />}
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
borderColor: '#059669',
}}
onClick={async () => {
if (!previewVoucherUrl) return
const copied = await copyVoucherDataUrlToClipboard(previewVoucherUrl)
if (copied) {
message.success('发货结果图片已复制到剪贴板!可直接在聊天窗粘贴 (Ctrl+V)')
} else {
message.info('请右键下方图片选择「复制图片」')
}
}}
>
复制图片到剪贴板
</Button>,
]}
width={420}
destroyOnClose
centered
>
<div style={{ textAlign: 'center', padding: '12px 0' }}>
{previewVoucherUrl ? (
<img
src={previewVoucherUrl}
alt="发货凭证大图"
style={{
maxWidth: '100%',
maxHeight: 520,
borderRadius: 16,
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.25)',
objectFit: 'contain',
}}
/>
) : (
<div style={{ padding: 40, color: '#94a3b8' }}>正在生成高清凭据图...</div>
)}
<div style={{ marginTop: 12, fontSize: 12, color: '#64748b' }}>
💡 提示:点击「复制图片到剪贴板」后,可直接在微信/QQ等客服聊天窗口按 <Text code>Ctrl + V</Text> 发送。
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,329 @@
import { Button, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
import { CopyOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import type { RockTokenItem } from '../../api'
import type { UidEditState } from './types'
const { Text } = Typography
export interface TokenColumnHandlers {
copyingVoucherId: number | null
onCopyVoucher: (record: RockTokenItem) => void
onPreviewVoucher: (record: RockTokenItem) => void
onEditUid: (state: UidEditState) => void
onResetToken: (id: number) => void
onReleaseToken: (id: number) => void
}
export function buildTokenColumns({
copyingVoucherId,
onCopyVoucher,
onPreviewVoucher,
onEditUid,
onResetToken,
onReleaseToken,
}: TokenColumnHandlers): ColumnsType<RockTokenItem> {
return [
{
title: '0666 编号 / 提取码',
dataIndex: 'upstream_token_id',
width: 250,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text
copyable={record.upstream_token_id ? { text: record.upstream_token_id } : false}
style={{ fontWeight: 600 }}
>
0666 #{record.upstream_token_id || '-'}
</Text>
<Text
copyable={record.redeem_code ? { text: record.redeem_code } : false}
type="secondary"
style={{ fontSize: 12, fontFamily: 'monospace' }}
>
{record.redeem_code || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>本地 #{record.id}</Text>
</Space>
),
},
{
title: '规格与适用范围',
dataIndex: 'color',
width: 150,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Tag color="geekblue" style={{ borderRadius: 4, fontWeight: 500 }}>
通用黑色 Token
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.region || 'Q/V区通用 · 自动分发'}
</Text>
</Space>
),
},
{
title: '库存状态',
dataIndex: 'status',
width: 140,
render: (status) => {
switch (status) {
case 'available':
return (
<Tag color="success" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 可用库存
</Tag>
)
case 'using':
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>
)
default:
return <Tag>{status}</Tag>
}
},
},
{
title: '关联业务工单',
dataIndex: 'assigned_order_no',
width: 220,
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: 250,
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: '发货结果图片',
key: 'voucher',
width: 240,
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', gap: 10 }}>
{/* 商品缩略图:鼠标悬浮微放、点击快速预览高清大图凭据 */}
<Tooltip title="点击预览深海蓝发货凭据大图">
<div
onClick={() => onPreviewVoucher(record)}
style={{
width: 44,
height: 44,
flexShrink: 0,
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.08)'
e.currentTarget.style.borderColor = '#93c5fd'
e.currentTarget.style.boxShadow = '0 3px 8px rgba(37,99,235,0.2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.borderColor = '#e2e8f0'
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.06)'
}}
>
<img
src={imgSrc}
alt={record.target_skin || '道具'}
style={{ width: '100%', height: '100%', objectFit: 'contain', padding: 2 }}
/>
</div>
</Tooltip>
{/* 操作按钮与商品徽章 */}
<Space direction="vertical" size={3}>
<Space size={4}>
<Button
size="small"
type="primary"
icon={<CopyOutlined />}
loading={isCopying}
onClick={() => onCopyVoucher(record)}
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
borderColor: '#059669',
boxShadow: '0 1px 2px rgba(16, 185, 129, 0.2)',
fontSize: 12,
fontWeight: 500,
padding: '0 8px',
}}
>
复制结果图
</Button>
<Tooltip title="查看高清发货凭据大图">
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => onPreviewVoucher(record)}
/>
</Tooltip>
</Space>
<div>
<Tag color="cyan" style={{ fontSize: 11, margin: 0 }}>
{record.target_skin || '黑色ROCK长袖T恤'}
</Tag>
</div>
</Space>
</div>
)
},
},
{
title: '操作',
key: 'action',
width: 168,
fixed: 'right',
render: (_, record) => {
if (record.status === 'mismatch') {
return (
<Space direction="vertical" size={4}>
<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="确定解冻重置该 Token?"
description="重置后将清除原订单绑定与错误标记,重新释放为可用通用库存。已扫码的链接放回库存后,其他订单领走就会废掉。"
okText="确认解冻"
cancelText="取消"
onConfirm={() => onResetToken(record.id)}
>
<Button size="small" danger ghost icon={<UndoOutlined />}>
解冻重置
</Button>
</Popconfirm>
</Space>
)
}
if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
return (
<Popconfirm
title="释放这条未绑定的提货链接?"
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
okText="确认释放"
cancelText="取消"
onConfirm={() => onReleaseToken(record.id)}
>
<Button size="small" icon={<UndoOutlined />}>
释放回库存
</Button>
</Popconfirm>
)
}
return <Text type="secondary">-</Text>
},
},
]
}
+6
View File
@@ -0,0 +1,6 @@
export interface UidEditState {
id: number
value: string
scanned: string
roleName: string
}