feat:2、刷新绑定信息,操作加一个刷新绑定状态的按钮,3、绑定错误有提示,手动上报异常,拿到包ID做链接跳转(定位)

This commit is contained in:
2026-10-29 18:15:40 +08:00
parent 8494fabc82
commit b27e128c5b
25 changed files with 1417 additions and 68 deletions
+30
View File
@@ -575,6 +575,8 @@ export interface RockConfig {
black_token_cost: number;
min_points_warning: number;
token_warning_stock: number;
using_warn_days: number;
using_danger_days: number;
auto_purchase_enabled: boolean;
auto_purchase_count: number;
purchase_cooldown_minutes: number;
@@ -607,10 +609,29 @@ export interface RockDashboard {
cost_updated: boolean;
}
export interface TokenBindingRefreshResult {
status: string;
bound: boolean;
role_name?: string;
role_id?: string;
channel?: string;
changed: boolean;
warning: boolean;
message: string;
redeemed_at?: string;
}
export interface TokenPackageLink {
package_id: number;
share_code: string;
url: string;
}
export interface RockTokenItem {
id: number;
upstream_token_id: string;
package_id?: number;
package_share_code?: string;
token?: string;
redeem_code: string;
color: string;
@@ -632,6 +653,7 @@ export interface RockTokenItem {
remark?: string;
raw_data?: string;
claimed_at?: string;
using_at?: string;
bound_at?: string;
redeemed_at?: string;
created_at: string;
@@ -684,6 +706,14 @@ export const rockApi = {
request
.post(`/platform/rock/tokens/${id}/release`)
.then((r) => r.data.data),
refreshTokenBinding: (id: number) =>
request
.post(`/platform/rock/tokens/${id}/refresh-binding`)
.then((r) => r.data.data as TokenBindingRefreshResult),
getTokenPackageLink: (id: number) =>
request
.get(`/platform/rock/tokens/${id}/package-link`)
.then((r) => r.data.data as TokenPackageLink),
updateRemark: (id: number, data: { remark: string; status?: string }) =>
request
.post(`/platform/rock/tokens/${id}/remark`, data)
+7
View File
@@ -73,6 +73,8 @@ export default function PlatformRock() {
bearer_token: cfg.bearer_token,
min_points_warning: cfg.min_points_warning,
token_warning_stock: cfg.token_warning_stock,
using_warn_days: cfg.using_warn_days ?? 3,
using_danger_days: cfg.using_danger_days ?? 5,
auto_purchase_count: cfg.auto_purchase_count,
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
})
@@ -262,6 +264,9 @@ export default function PlatformRock() {
const hasMismatch = (stats?.mismatch_stock ?? 0) > 0
const upstreamPoints = stats?.upstream_points ?? 0
const tokenCost = config?.black_token_cost && config.black_token_cost > 0 ? config.black_token_cost : 20
const usingWarnDays = config?.using_warn_days && config.using_warn_days > 0 ? config.using_warn_days : 3
const usingDangerDays =
config?.using_danger_days && config.using_danger_days > usingWarnDays ? config.using_danger_days : 5
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
const estimatedCost = (purchaseCount || 0) * tokenCost
const isPointsInsufficient = estimatedCost > upstreamPoints
@@ -348,6 +353,8 @@ export default function PlatformRock() {
<RockTokenTable
tokens={tokens}
loadingTokens={loadingTokens}
usingWarnDays={usingWarnDays}
usingDangerDays={usingDangerDays}
page={page}
pageSize={pageSize}
totalTokens={totalTokens}
@@ -258,6 +258,24 @@ export function RockStrategyCard({
<InputNumber min={1} max={60} style={{ width: '100%' }} addonAfter="分钟" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="using_warn_days"
label="使用中橙色预警"
tooltip="扫码使用中的 Token 超过该天数后,状态标签变为橙色"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="using_danger_days"
label="使用中红色预警"
tooltip="必须大于橙色预警天数;超过后状态标签变为红色"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
</Form.Item>
</Col>
</Row>
</div>
</Col>
@@ -1,4 +1,5 @@
import type { Dispatch, SetStateAction } from 'react'
import { useState } from 'react'
import { Table, message } from 'antd'
import type { RockTokenItem } from '../../api'
import { rockApi } from '../../api'
@@ -13,6 +14,8 @@ export interface RockTokenTableProps {
page: number
pageSize: number
totalTokens: number
usingWarnDays: number
usingDangerDays: number
copyingVoucherId: number | null
setCopyingVoucherId: Dispatch<SetStateAction<number | null>>
setPreviewVoucherUrl: Dispatch<SetStateAction<string>>
@@ -30,6 +33,8 @@ export function RockTokenTable({
page,
pageSize,
totalTokens,
usingWarnDays,
usingDangerDays,
copyingVoucherId,
setCopyingVoucherId,
setPreviewVoucherUrl,
@@ -40,6 +45,52 @@ export function RockTokenTable({
onReloadData,
onReloadTokens,
}: RockTokenTableProps) {
const [refreshingTokenId, setRefreshingTokenId] = useState<number | null>(null)
const [openingUpstreamId, setOpeningUpstreamId] = useState<number | null>(null)
const handleOpenUpstream = async (id: number) => {
// 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。
const win = window.open('about:blank', '_blank')
setOpeningUpstreamId(id)
try {
const res = await rockApi.getTokenPackageLink(id)
if (!win) {
message.warning('浏览器拦截了新窗口,请允许弹窗后重试')
return
}
win.opener = null
win.location.href = res.url
} catch (e) {
win?.close()
message.error('获取上游包链接失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setOpeningUpstreamId(null)
}
}
const handleRefreshBinding = async (id: number) => {
setRefreshingTokenId(id)
try {
const res = await rockApi.refreshTokenBinding(id)
if (res.status === 'mismatch' || res.warning) {
message.warning(res.message || '上游绑定状态需要人工核对')
} else if (res.status === 'redeemed') {
message.success(res.message || '上游已发货,本地状态已同步为已交付')
} else if (res.bound) {
const role = res.role_name || res.role_id || '-'
message.success(res.message || `刷新成功:上游已绑定「${role}」`)
} else {
message.info(res.message || '上游仍未绑定,本地状态保持不变')
}
onReloadData()
onReloadTokens()
} catch (e) {
message.error('刷新绑定状态失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setRefreshingTokenId(null)
}
}
// 人工重置解冻 Mismatch 或 Abnormal Token
const handleResetToken = async (id: number) => {
try {
@@ -152,6 +203,12 @@ export function RockTokenTable({
const columns = buildTokenColumns({
copyingVoucherId,
usingWarnDays,
usingDangerDays,
refreshingTokenId,
onRefreshBinding: handleRefreshBinding,
openingUpstreamId,
onOpenUpstream: handleOpenUpstream,
onCopyVoucher: handleCopyVoucher,
onPreviewVoucher: handlePreviewVoucher,
onEditUid,
+137 -49
View File
@@ -1,5 +1,5 @@
import { Button, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
import { CopyOutlined, EyeOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons'
import { CopyOutlined, ExportOutlined, EyeOutlined, StopOutlined, SyncOutlined, UndoOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import type { RockTokenItem } from '../../api'
import { formatDateTime } from '../../utils/time'
@@ -8,8 +8,25 @@ 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
}
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
@@ -22,6 +39,12 @@ export interface TokenColumnHandlers {
export function buildTokenColumns({
copyingVoucherId,
usingWarnDays,
usingDangerDays,
refreshingTokenId,
onRefreshBinding,
openingUpstreamId,
onOpenUpstream,
onCopyVoucher,
onPreviewVoucher,
onEditUid,
@@ -99,8 +122,8 @@ export function buildTokenColumns({
{
title: '库存状态',
dataIndex: 'status',
width: 140,
render: (status) => {
width: 170,
render: (status, record) => {
switch (status) {
case 'available':
return (
@@ -108,12 +131,31 @@ export function buildTokenColumns({
● 可用库存
</Tag>
)
case 'using':
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' }}>
@@ -154,39 +196,57 @@ export function buildTokenColumns({
},
{
title: '流转时间',
width: 175,
render: (_, record) => (
<Space direction="vertical" size={2}>
<div style={{ fontSize: 12, color: '#334155' }}>
<span style={{ color: '#94a3b8' }}>入库: </span>
{formatDateTime(record.created_at)}
</div>
{record.mismatch_at && (
<div style={{ fontSize: 11, color: '#e11d48' }}>
<span>⚠️ 绑错: </span>
{formatDateTime(record.mismatch_at)}
width: 210,
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.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>
),
{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: '关联业务工单',
@@ -381,12 +441,31 @@ export function buildTokenColumns({
{
title: '操作',
key: 'action',
width: 180,
width: 200,
fixed: 'right',
render: (_, record) => {
const refreshButton = (
<Button
size="small"
icon={<SyncOutlined />}
loading={refreshingTokenId === record.id}
onClick={() => onRefreshBinding(record.id)}
>
刷新
</Button>
)
if (record.status === 'mismatch') {
return (
<Space direction="vertical" size={4}>
{refreshButton}
<Button
size="small"
icon={<ExportOutlined />}
loading={openingUpstreamId === record.id}
onClick={() => onOpenUpstream(record.id)}
>
跳转上游
</Button>
<Button
size="small"
type="primary"
@@ -446,21 +525,30 @@ export function buildTokenColumns({
</Space>
)
}
if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
if (record.status === 'using') {
const canRelease = !record.bound_at && !record.actual_role_id
return (
<Popconfirm
title="释放这条未绑定的提货链接?"
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
okText="确认释放"
cancelText="取消"
onConfirm={() => onReleaseToken(record.id)}
>
<Button size="small" icon={<UndoOutlined />}>
释放回库存
</Button>
</Popconfirm>
<Space direction="vertical" size={4}>
{refreshButton}
{canRelease && (
<Popconfirm
title="释放这条未绑定的提货链接?"
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
okText="确认释放"
cancelText="取消"
onConfirm={() => onReleaseToken(record.id)}
>
<Button size="small" icon={<UndoOutlined />}>
释放回库存
</Button>
</Popconfirm>
)}
</Space>
)
}
if (record.status === 'bound') {
return refreshButton
}
return <Text type="secondary">-</Text>
},
},