diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index 2db98ef..37c4717 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -1,48 +1,16 @@ import { useCallback, useEffect, useState } from 'react' -import { - Badge, - Button, - Card, - Col, - Divider, - Form, - Input, - InputNumber, - Modal, - Popconfirm, - Row, - Radio, - Space, - Switch, - Table, - Tag, - Tooltip, - Typography, - message, -} from 'antd' -import { - CheckCircleOutlined, - ControlOutlined, - CopyOutlined, - DownloadOutlined, - ExclamationCircleOutlined, - EyeOutlined, - FireOutlined, - KeyOutlined, - ReloadOutlined, - SafetyCertificateOutlined, - SearchOutlined, - ShoppingCartOutlined, - ThunderboltOutlined, - UndoOutlined, -} from '@ant-design/icons' -import type { ColumnsType } from 'antd/es/table' +import { Button, Form, Modal, Space, message } from 'antd' +import { ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons' import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api' import { PageHeader } from '../components/PageHeader' -import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../utils/voucher' -import { formatDateTime } from '../utils/time' - -const { Text } = Typography +import { RockMetricCards } from './platformRock/RockMetricCards' +import { RockStrategyCard } from './platformRock/RockStrategyCard' +import { RockTokenFilterBar } from './platformRock/RockTokenFilterBar' +import { RockTokenTable } from './platformRock/RockTokenTable' +import { PurchaseTokenModal } from './platformRock/PurchaseTokenModal' +import { UidEditModal } from './platformRock/UidEditModal' +import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal' +import type { UidEditState } from './platformRock/types' export default function PlatformRock() { const [config, setConfig] = useState(null) @@ -69,7 +37,7 @@ export default function PlatformRock() { const [loadingTokens, setLoadingTokens] = useState(false) const [form] = Form.useForm() - const [uidEdit, setUidEdit] = useState<{ id: number; value: string; scanned: string; roleName: string } | null>(null) + const [uidEdit, setUidEdit] = useState(null) const [savingUid, setSavingUid] = useState(false) const loadData = useCallback(async () => { @@ -231,396 +199,20 @@ export default function PlatformRock() { } } - // 人工重置解冻 Mismatch Token - const handleResetToken = async (id: number) => { - try { - await rockApi.resetToken(id) - message.success('已解除绑定异常,重置为可用库存') - loadData() - loadTokens() - } catch (e) { - message.error('重置失败: ' + (e instanceof Error ? e.message : '未知错误')) - } + const handleFilterStatusChange = (value: string) => { + setFilterStatus(value) + setPage(1) } - // 买家未扫码绑定时,把占用中的提货链接放回库存 - const handleReleaseToken = async (id: number) => { - try { - await rockApi.releaseToken(id) - message.success('已释放回可用库存,该提货链接可以再次分配') - loadData() - loadTokens() - } catch (e) { - message.error('释放失败: ' + (e instanceof Error ? e.message : '未知错误')) - } + const handlePageChange = (nextPage: number, nextPageSize: number) => { + setPage(nextPage) + setPageSize(nextPageSize) } - // 复制/生成发货凭证图片 - 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 handleUidValueChange = (value: string) => { + setUidEdit((current) => (current ? { ...current, value } : current)) } - 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: ColumnsType = [ - { - title: '0666 编号 / 提取码', - dataIndex: 'upstream_token_id', - width: 250, - render: (_, record) => ( - - - 0666 #{record.upstream_token_id || '-'} - - - {record.redeem_code || '-'} - - 本地 #{record.id} - - ), - }, - { - title: '规格与适用范围', - dataIndex: 'color', - width: 150, - render: (_, record) => ( - - - 通用黑色 Token - - - {record.region || 'Q/V区通用 · 自动分发'} - - - ), - }, - { - title: '库存状态', - dataIndex: 'status', - width: 140, - render: (status) => { - switch (status) { - case 'available': - return ( - - ● 可用库存 - - ) - case 'using': - return ( - - ● 扫码使用中 - - ) - case 'bound': - return ( - - ● 已授权绑定 - - ) - case 'redeemed': - return ( - - ● 已交付发货 - - ) - case 'mismatch': - return ( - - ⚠️ 绑定错误 (阻断) - - ) - default: - return {status} - } - }, - }, - { - title: '关联业务工单', - dataIndex: 'assigned_order_no', - width: 220, - render: (orderNo, record) => { - if (!orderNo) { - return - - } - return ( - - {/* 系统履约工单号 */} -
- - {orderNo} - -
- {/* 创建订单时的商户单号 */} - {record.client_order_no ? ( -
- 商户: - - {record.client_order_no} - -
- ) : null} -
- ) - }, - }, - { - title: '预期 UID vs 扫码角色 (风控门禁)', - width: 250, - render: (_, record) => { - const isMismatch = record.status === 'mismatch' - return ( -
-
- 预期 UID: - {record.expected_uid || '-'} -
-
- 扫码反查: - - {record.actual_role_name ? `${record.actual_role_name} (${record.actual_role_id || '-'})` : '-'} - -
- {isMismatch && record.mismatch_reason && ( -
- - - {record.mismatch_reason} - - -
- )} -
- ) - }, - }, - { - title: '发货结果图片', - key: 'voucher', - width: 240, - render: (_, record) => { - if (record.status !== 'redeemed' && !record.upstream_fulfill_id) { - return - - } - 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 ( -
- {/* 商品缩略图:鼠标悬浮微放、点击快速预览高清大图凭据 */} - -
handlePreviewVoucher(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)' - }} - > - {record.target_skin -
-
- - {/* 操作按钮与商品徽章 */} - - - - -
- ) - }, - }, - { - title: '操作', - key: 'action', - width: 168, - fixed: 'right', - render: (_, record) => { - if (record.status === 'mismatch') { - return ( - - - handleResetToken(record.id)} - > - - - - ) - } - if (record.status === 'using' && !record.bound_at && !record.actual_role_id) { - return ( - handleReleaseToken(record.id)} - > - - - ) - } - return - - }, - }, - ] - const autoDelivery = config?.auto_delivery_enabled ?? true const availableStock = stats?.available_stock ?? 0 const warningStock = config?.token_warning_stock ?? 10 @@ -658,649 +250,86 @@ export default function PlatformRock() { /> {/* 顶部高质感指标卡片 */} - - -
-
- 可用通用库存 (Available) -
- -
-
-
- {availableStock} - 个 -
-
- 警戒水位: {warningStock} 个 - {isStockLow ? ( - 库存紧缺 - ) : ( - 储备充足 - )} -
-
- - - -
-
- 绑定错误阻断 (Mismatch) -
- -
-
-
- {stats?.mismatch_stock ?? 0} - 个 -
-
- {hasMismatch ? 'UID强一致校验拦截保护' : '暂无异常阻断'} - {hasMismatch ? ( - 需核对处理 - ) : ( - 状态正常 - )} -
-
- - - -
-
- 已履约交付 (Redeemed) -
- -
-
-
- {stats?.redeemed_stock ?? 0} - 个 -
-
- 当前扫码中 - {stats?.using_stock ?? 0} 个 -
-
- - - -
-
- 0666.pro 账号积分 -
- -
-
-
- {upstreamPoints.toFixed(1)} - 点 -
- -
- -
+ {/* 核心控制与上游策略看板 */} - - {/* 通道状态与软熔断横幅 */} -
-
-
- {autoDelivery ? : } -
-
-
- - {autoDelivery - ? '自动发货通道正常运转 (Active)' - : '自动发货已全局软熔断 (Kill Switch Engaged)'} - - - {autoDelivery ? '反代履约中' : '已转交人工打手'} - -
-
- {autoDelivery - ? '和平精英「黑色 ROCK 长袖」与「蓝色运动服」开启 0666.pro 全自动反代发货,UID 强一致安全门禁全面守护。' - : '两款商品已彻底切断上游 0666.pro 自动发货通道,所有新订单均保留在待发货状态,由人工接单平台处理。'} -
-
-
- - - - -
- - {/* 策略表单 */} -
- {/* 阻断浏览器对凭据配置项的强制账密自动填充 (Honeypot) */} - - - - -
-
- - 0666.pro 采购认证凭据 -
- - - - - - - 上游商户 Bearer Token (yd_token) - - - - - } - style={{ marginBottom: 12 }} - > - - 测试连通性 - - } - /> - - -
- 💡 极简提取指引:登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '} - - localStorage.getItem('yd_token') - {' '} - 即可一键复制。 -
-
- - - -
-
- - 库存风控与自动补货策略 -
- - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - -
- -
- -
+ {/* 筛选与搜索工具条(对齐标杆页面) */} - - - - - } - value={searchKeyword} - onChange={(e) => setSearchKeyword(e.target.value)} - onPressEnter={loadTokens} - style={{ width: 340 }} - allowClear - /> - { - setFilterStatus(e.target.value) - setPage(1) - }} - buttonStyle="solid" - > - 全部 - 可用 - 使用中 - 已绑定 - 已发货 - - - 绑定错误 - - - - - - - - - 共检索到 {totalTokens} 条库存记录 - - - - + {/* 主库存与错单审计表格 */} - `共 ${total} 条库存记录`, - onChange: (p, ps) => { - setPage(p) - setPageSize(ps) - }, - }} + {/* 手动采购指定数量 Token 弹窗 */} - - - 手动采购通用黑色 Token (0666.pro) - - } + setPurchaseModalOpen(false)} - footer={[ - , - , - ]} - destroyOnClose - width={500} - > -
- {/* 账户与资产概览 */} -
- -
-
0666.pro 采购商户
-
- {stats?.username || '正常商户'} -
- - -
当前实时积分余额
-
- {upstreamPoints.toFixed(1)} 点 -
- - - -
- 采购单价:21 点 / 个 - 最大可采购:{maxPurchasable} 个 -
- + onConfirm={handlePurchase} + /> - {/* 采购数量输入 */} -
-
- 采购数量: -
- 0 ? maxPurchasable : 10000} - value={purchaseCount} - onChange={(val) => setPurchaseCount(val || 1)} - style={{ width: '100%' }} - addonAfter="个 Token" - size="large" - /> -
- - {/* 快捷数量选择胶囊 */} -
-
快捷选择:
- - {[10, 20, 50, 100].map((num) => ( - - ))} - {maxPurchasable > 0 && ( - - )} - -
- - {/* 实时消耗与结余试算 */} -
-
- - 本次采购消耗积分: - - - {estimatedCost} 点 - -
-
- - {isPointsInsufficient - ? '⚠️ 账户积分不足,请先充值或减少采购数量' - : '采购后预计剩余积分:'} - - {!isPointsInsufficient && ( - - {(upstreamPoints - estimatedCost).toFixed(1)} 点 - - )} -
-
- - - void handleCorrectUid()} + setUidEdit(null)} - destroyOnClose - > -

- 提货号继续留在原订单上,不会放回库存。改成扫码 UID 后,绑定阻断会解除。 -

- {uidEdit?.scanned ? ( -

- 扫码角色:{uidEdit.roleName ? `「${uidEdit.roleName}」` : ''} {uidEdit.scanned} -

- ) : null} - - setUidEdit((current) => - current - ? { ...current, value: event.target.value.replace(/\D/g, '').slice(0, 15) } - : current, - ) - } - onPressEnter={() => void handleCorrectUid()} - /> -
+ /> + {/* 发货结果图片高清预览弹窗 */} - - - 发货到账存证凭据 (高清预览) - - } + setPreviewVoucherOpen(false)} - footer={[ - , - , - , - ]} - width={420} - destroyOnClose - centered - > -
- {previewVoucherUrl ? ( - 发货凭证大图 - ) : ( -
正在生成高清凭据图...
- )} -
- 💡 提示:点击「复制图片到剪贴板」后,可直接在微信/QQ等客服聊天窗口按 Ctrl + V 发送。 -
-
-
+ previewVoucherUrl={previewVoucherUrl} + onClose={() => setPreviewVoucherOpen(false)} + /> ) } - - diff --git a/frontend/src/pages/platformRock/PurchaseTokenModal.tsx b/frontend/src/pages/platformRock/PurchaseTokenModal.tsx new file mode 100644 index 0000000..9a1f21f --- /dev/null +++ b/frontend/src/pages/platformRock/PurchaseTokenModal.tsx @@ -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 ( + + + 手动采购通用黑色 Token (0666.pro) + + } + open={open} + onCancel={onCancel} + footer={[ + , + , + ]} + destroyOnClose + width={500} + > +
+ {/* 账户与资产概览 */} +
+ +
+
0666.pro 采购商户
+
+ {stats?.username || '正常商户'} +
+ + +
当前实时积分余额
+
+ {upstreamPoints.toFixed(1)} 点 +
+ + + +
+ 采购单价:21 点 / 个 + 最大可采购:{maxPurchasable} 个 +
+ + + {/* 采购数量输入 */} +
+
+ 采购数量: +
+ 0 ? maxPurchasable : 10000} + value={purchaseCount} + onChange={(val) => onPurchaseCountChange(val || 1)} + style={{ width: '100%' }} + addonAfter="个 Token" + size="large" + /> +
+ + {/* 快捷数量选择胶囊 */} +
+
快捷选择:
+ + {[10, 20, 50, 100].map((num) => ( + + ))} + {maxPurchasable > 0 && ( + + )} + +
+ + {/* 实时消耗与结余试算 */} +
+
+ + 本次采购消耗积分: + + + {estimatedCost} 点 + +
+
+ + {isPointsInsufficient + ? '⚠️ 账户积分不足,请先充值或减少采购数量' + : '采购后预计剩余积分:'} + + {!isPointsInsufficient && ( + + {(upstreamPoints - estimatedCost).toFixed(1)} 点 + + )} +
+
+ + + ) +} diff --git a/frontend/src/pages/platformRock/RockMetricCards.tsx b/frontend/src/pages/platformRock/RockMetricCards.tsx new file mode 100644 index 0000000..ade7d12 --- /dev/null +++ b/frontend/src/pages/platformRock/RockMetricCards.tsx @@ -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 ( + +
+
+
+ 可用通用库存 (Available) +
+ +
+
+
+ {availableStock} + 个 +
+
+ 警戒水位: {warningStock} 个 + {isStockLow ? ( + 库存紧缺 + ) : ( + 储备充足 + )} +
+
+ + + +
+
+ 绑定错误阻断 (Mismatch) +
+ +
+
+
+ {stats?.mismatch_stock ?? 0} + 个 +
+
+ {hasMismatch ? 'UID强一致校验拦截保护' : '暂无异常阻断'} + {hasMismatch ? ( + 需核对处理 + ) : ( + 状态正常 + )} +
+
+ + + +
+
+ 已履约交付 (Redeemed) +
+ +
+
+
+ {stats?.redeemed_stock ?? 0} + 个 +
+
+ 当前扫码中 + {stats?.using_stock ?? 0} 个 +
+
+ + + + + + + ) +} diff --git a/frontend/src/pages/platformRock/RockStrategyCard.tsx b/frontend/src/pages/platformRock/RockStrategyCard.tsx new file mode 100644 index 0000000..fd6fae4 --- /dev/null +++ b/frontend/src/pages/platformRock/RockStrategyCard.tsx @@ -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 ( + + {/* 通道状态与软熔断横幅 */} +
+
+
+ {autoDelivery ? : } +
+
+
+ + {autoDelivery + ? '自动发货通道正常运转 (Active)' + : '自动发货已全局软熔断 (Kill Switch Engaged)'} + + + {autoDelivery ? '反代履约中' : '已转交人工打手'} + +
+
+ {autoDelivery + ? '和平精英「黑色 ROCK 长袖」与「蓝色运动服」开启 0666.pro 全自动反代发货,UID 强一致安全门禁全面守护。' + : '两款商品已彻底切断上游 0666.pro 自动发货通道,所有新订单均保留在待发货状态,由人工接单平台处理。'} +
+
+
+ + + + +
+ + {/* 策略表单 */} +
+ {/* 阻断浏览器对凭据配置项的强制账密自动填充 (Honeypot) */} + + + +
+
+
+ + 0666.pro 采购认证凭据 +
+ + + + + + + 上游商户 Bearer Token (yd_token) + + + + + } + style={{ marginBottom: 12 }} + > + + 测试连通性 + + } + /> + + +
+ 💡 极简提取指引:登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '} + + localStorage.getItem('yd_token') + {' '} + 即可一键复制。 +
+
+ + + +
+
+ + 库存风控与自动补货策略 +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + ) +} diff --git a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx new file mode 100644 index 0000000..e7fce49 --- /dev/null +++ b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx @@ -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 ( + + +
+ + } + value={searchKeyword} + onChange={(e) => onSearchKeywordChange(e.target.value)} + onPressEnter={onRefresh} + style={{ width: 340 }} + allowClear + /> + { + onFilterStatusChange(e.target.value) + }} + buttonStyle="solid" + > + 全部 + 可用 + 使用中 + 已绑定 + 已发货 + + + 绑定错误 + + + + + + + + + 共检索到 {totalTokens} 条库存记录 + + + + + ) +} diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx new file mode 100644 index 0000000..986182b --- /dev/null +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -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> + setPreviewVoucherUrl: Dispatch> + setPreviewVoucherOpen: Dispatch> + 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 ( +
`共 ${total} 条库存记录`, + onChange: (p, ps) => { + onPageChange(p, ps) + }, + }} + /> + ) +} diff --git a/frontend/src/pages/platformRock/UidEditModal.tsx b/frontend/src/pages/platformRock/UidEditModal.tsx new file mode 100644 index 0000000..a8ac70c --- /dev/null +++ b/frontend/src/pages/platformRock/UidEditModal.tsx @@ -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 ( + void onConfirm()} + onCancel={onCancel} + destroyOnClose + > +

+ 提货号继续留在原订单上,不会放回库存。改成扫码 UID 后,绑定阻断会解除。 +

+ {uidEdit?.scanned ? ( +

+ 扫码角色:{uidEdit.roleName ? `「${uidEdit.roleName}」` : ''} {uidEdit.scanned} +

+ ) : null} + onValueChange(event.target.value.replace(/\D/g, '').slice(0, 15))} + onPressEnter={() => void onConfirm()} + /> +
+ ) +} diff --git a/frontend/src/pages/platformRock/VoucherPreviewModal.tsx b/frontend/src/pages/platformRock/VoucherPreviewModal.tsx new file mode 100644 index 0000000..0f5bfda --- /dev/null +++ b/frontend/src/pages/platformRock/VoucherPreviewModal.tsx @@ -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 ( + + + 发货到账存证凭据 (高清预览) + + } + open={open} + onCancel={onClose} + footer={[ + , + , + , + ]} + width={420} + destroyOnClose + centered + > +
+ {previewVoucherUrl ? ( + 发货凭证大图 + ) : ( +
正在生成高清凭据图...
+ )} +
+ 💡 提示:点击「复制图片到剪贴板」后,可直接在微信/QQ等客服聊天窗口按 Ctrl + V 发送。 +
+
+
+ ) +} diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx new file mode 100644 index 0000000..0679646 --- /dev/null +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -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 { + return [ + { + title: '0666 编号 / 提取码', + dataIndex: 'upstream_token_id', + width: 250, + render: (_, record) => ( + + + 0666 #{record.upstream_token_id || '-'} + + + {record.redeem_code || '-'} + + 本地 #{record.id} + + ), + }, + { + title: '规格与适用范围', + dataIndex: 'color', + width: 150, + render: (_, record) => ( + + + 通用黑色 Token + + + {record.region || 'Q/V区通用 · 自动分发'} + + + ), + }, + { + title: '库存状态', + dataIndex: 'status', + width: 140, + render: (status) => { + switch (status) { + case 'available': + return ( + + ● 可用库存 + + ) + case 'using': + return ( + + ● 扫码使用中 + + ) + case 'bound': + return ( + + ● 已授权绑定 + + ) + case 'redeemed': + return ( + + ● 已交付发货 + + ) + case 'mismatch': + return ( + + ⚠️ 绑定错误 (阻断) + + ) + default: + return {status} + } + }, + }, + { + title: '关联业务工单', + dataIndex: 'assigned_order_no', + width: 220, + render: (orderNo, record) => { + if (!orderNo) { + return - + } + return ( + + {/* 系统履约工单号 */} +
+ + {orderNo} + +
+ {/* 创建订单时的商户单号 */} + {record.client_order_no ? ( +
+ 商户: + + {record.client_order_no} + +
+ ) : null} +
+ ) + }, + }, + { + title: '预期 UID vs 扫码角色 (风控门禁)', + width: 250, + render: (_, record) => { + const isMismatch = record.status === 'mismatch' + return ( +
+
+ 预期 UID: + {record.expected_uid || '-'} +
+
+ 扫码反查: + + {record.actual_role_name ? `${record.actual_role_name} (${record.actual_role_id || '-'})` : '-'} + +
+ {isMismatch && record.mismatch_reason && ( +
+ + + {record.mismatch_reason} + + +
+ )} +
+ ) + }, + }, + { + title: '发货结果图片', + key: 'voucher', + width: 240, + render: (_, record) => { + if (record.status !== 'redeemed' && !record.upstream_fulfill_id) { + return - + } + 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 ( +
+ {/* 商品缩略图:鼠标悬浮微放、点击快速预览高清大图凭据 */} + +
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)' + }} + > + {record.target_skin +
+
+ + {/* 操作按钮与商品徽章 */} + + + + +
+ ) + }, + }, + { + title: '操作', + key: 'action', + width: 168, + fixed: 'right', + render: (_, record) => { + if (record.status === 'mismatch') { + return ( + + + onResetToken(record.id)} + > + + + + ) + } + if (record.status === 'using' && !record.bound_at && !record.actual_role_id) { + return ( + onReleaseToken(record.id)} + > + + + ) + } + return - + }, + }, + ] +} diff --git a/frontend/src/pages/platformRock/types.ts b/frontend/src/pages/platformRock/types.ts new file mode 100644 index 0000000..f049775 --- /dev/null +++ b/frontend/src/pages/platformRock/types.ts @@ -0,0 +1,6 @@ +export interface UidEditState { + id: number + value: string + scanned: string + roleName: string +}