feat:2、刷新绑定信息,操作加一个刷新绑定状态的按钮,3、绑定错误有提示,手动上报异常,拿到包ID做链接跳转(定位)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user