feat:Rock刷新去UID拦截,新增绑定错误自动刷新与使用/绑定超时预警
This commit is contained in:
@@ -577,6 +577,8 @@ export interface RockConfig {
|
||||
token_warning_stock: number;
|
||||
using_warn_days: number;
|
||||
using_danger_days: number;
|
||||
bound_warn_days: number;
|
||||
auto_refresh_mismatch_enabled: boolean;
|
||||
auto_purchase_enabled: boolean;
|
||||
auto_purchase_count: number;
|
||||
purchase_cooldown_minutes: number;
|
||||
@@ -596,6 +598,10 @@ export interface RockStats {
|
||||
mismatch_stock: number;
|
||||
abnormal_stock: number;
|
||||
deleted_stock?: number;
|
||||
using_overdue_stock?: number;
|
||||
using_danger_stock?: number;
|
||||
bound_overdue_stock?: number;
|
||||
auto_refresh_mismatch_enabled?: boolean;
|
||||
upstream_points: number;
|
||||
auto_delivery_enabled: boolean;
|
||||
token_warning_stock: number;
|
||||
@@ -779,6 +785,14 @@ export interface RockTokenRestoreResult {
|
||||
skipped_items: RockTokenRestoreSkipItem[];
|
||||
}
|
||||
|
||||
export interface RockMismatchRefreshResult {
|
||||
scanned: number;
|
||||
fixed: number;
|
||||
failed: number;
|
||||
remaining: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const rockApi = {
|
||||
getConfig: () =>
|
||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||
@@ -853,6 +867,10 @@ export const rockApi = {
|
||||
page_size: number;
|
||||
},
|
||||
),
|
||||
refreshMismatchTokens: () =>
|
||||
request
|
||||
.post("/platform/rock/token-batches/refresh-mismatch")
|
||||
.then((r) => r.data.data as RockMismatchRefreshResult),
|
||||
previewTokenDelete: (params?: { status?: string; search?: string }) =>
|
||||
request
|
||||
.get("/platform/rock/token-batches/preview", { params })
|
||||
|
||||
@@ -36,6 +36,9 @@ export default function PlatformRock() {
|
||||
const [savingConfig, setSavingConfig] = useState(false)
|
||||
const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false)
|
||||
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
||||
const [autoRefreshMismatchEnabled, setAutoRefreshMismatchEnabled] = useState(false)
|
||||
const [togglingAutoRefresh, setTogglingAutoRefresh] = useState(false)
|
||||
const [refreshingMismatch, setRefreshingMismatch] = useState(false)
|
||||
const [purchasing, setPurchasing] = useState(false)
|
||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
||||
const [purchaseCount, setPurchaseCount] = useState(50)
|
||||
@@ -79,6 +82,7 @@ export default function PlatformRock() {
|
||||
setConfig(cfg)
|
||||
setStats(st)
|
||||
setAutoPurchaseEnabled(cfg.auto_purchase_enabled)
|
||||
setAutoRefreshMismatchEnabled(cfg.auto_refresh_mismatch_enabled ?? false)
|
||||
form.setFieldsValue({
|
||||
auto_delivery_enabled: cfg.auto_delivery_enabled,
|
||||
base_url: cfg.base_url,
|
||||
@@ -88,6 +92,7 @@ export default function PlatformRock() {
|
||||
token_warning_stock: cfg.token_warning_stock,
|
||||
using_warn_days: cfg.using_warn_days ?? 3,
|
||||
using_danger_days: cfg.using_danger_days ?? 5,
|
||||
bound_warn_days: cfg.bound_warn_days ?? 2,
|
||||
auto_purchase_count: cfg.auto_purchase_count,
|
||||
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
|
||||
})
|
||||
@@ -172,6 +177,47 @@ export default function PlatformRock() {
|
||||
}
|
||||
}
|
||||
|
||||
// 一键刷新全部绑定错误的 Token:同步执行,单次最多 20 条
|
||||
const handleRefreshMismatch = async () => {
|
||||
setRefreshingMismatch(true)
|
||||
try {
|
||||
const res = await rockApi.refreshMismatchTokens()
|
||||
if (res.scanned === 0) {
|
||||
message.info('暂无可刷新的绑定错误 Token(10 分钟内已刷新过的会自动跳过)')
|
||||
} else if (res.failed > 0 || res.remaining > 0) {
|
||||
message.warning(res.message)
|
||||
} else {
|
||||
message.success(res.message)
|
||||
}
|
||||
loadData()
|
||||
loadTokens()
|
||||
} catch (e) {
|
||||
message.error('刷新绑定错误失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setRefreshingMismatch(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动刷新绑定错误开关:切换即保存,后端每 5 分钟巡检一次
|
||||
const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
|
||||
const previous = autoRefreshMismatchEnabled
|
||||
setAutoRefreshMismatchEnabled(checked)
|
||||
setTogglingAutoRefresh(true)
|
||||
try {
|
||||
const updated = await rockApi.updateConfig({ auto_refresh_mismatch_enabled: checked })
|
||||
setConfig(updated)
|
||||
setAutoRefreshMismatchEnabled(updated.auto_refresh_mismatch_enabled)
|
||||
message.success(
|
||||
checked ? '自动刷新已开启:每 5 分钟最多刷新 20 条绑定错误 Token' : '自动刷新已关闭',
|
||||
)
|
||||
} catch (e) {
|
||||
setAutoRefreshMismatchEnabled(previous)
|
||||
message.error('更新自动刷新开关失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setTogglingAutoRefresh(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连通性
|
||||
const handleTestConnection = async () => {
|
||||
const bearerToken = form.getFieldValue('bearer_token')
|
||||
@@ -336,6 +382,7 @@ export default function PlatformRock() {
|
||||
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 boundWarnDays = config?.bound_warn_days && config.bound_warn_days > 0 ? config.bound_warn_days : 2
|
||||
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
|
||||
const estimatedCost = (purchaseCount || 0) * tokenCost
|
||||
const isPointsInsufficient = estimatedCost > upstreamPoints
|
||||
@@ -428,9 +475,14 @@ export default function PlatformRock() {
|
||||
filterStatus={filterStatus}
|
||||
totalTokens={totalTokens}
|
||||
loadingTokens={loadingTokens}
|
||||
autoRefreshMismatchEnabled={autoRefreshMismatchEnabled}
|
||||
refreshingMismatch={refreshingMismatch}
|
||||
togglingAutoRefresh={togglingAutoRefresh}
|
||||
onSearchKeywordChange={setSearchKeyword}
|
||||
onFilterStatusChange={handleFilterStatusChange}
|
||||
onRefresh={loadTokens}
|
||||
onRefreshMismatch={handleRefreshMismatch}
|
||||
onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
|
||||
/>
|
||||
|
||||
{/* 主库存与错单审计表格 */}
|
||||
@@ -439,6 +491,7 @@ export default function PlatformRock() {
|
||||
loadingTokens={loadingTokens}
|
||||
usingWarnDays={usingWarnDays}
|
||||
usingDangerDays={usingDangerDays}
|
||||
boundWarnDays={boundWarnDays}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalTokens={totalTokens}
|
||||
|
||||
@@ -365,6 +365,15 @@ export function RockStrategyCard({
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="bound_warn_days"
|
||||
label="绑定过期预警"
|
||||
tooltip="已绑定 Token 超过该天数后标记为绑定超时,并计入「绑定超时」筛选"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Card, Col, Input, Radio, Row, Space, Typography } from 'antd'
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Badge, Button, Card, Input, Radio, Space, Switch, Tooltip, Typography } from 'antd'
|
||||
import { ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import type { RockStats } from '../../api'
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -10,9 +10,14 @@ export interface RockTokenFilterBarProps {
|
||||
filterStatus: string
|
||||
totalTokens: number
|
||||
loadingTokens: boolean
|
||||
autoRefreshMismatchEnabled: boolean
|
||||
refreshingMismatch: boolean
|
||||
togglingAutoRefresh: boolean
|
||||
onSearchKeywordChange: (value: string) => void
|
||||
onFilterStatusChange: (value: string) => void
|
||||
onRefresh: () => void
|
||||
onRefreshMismatch: () => void
|
||||
onToggleAutoRefreshMismatch: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function RockTokenFilterBar({
|
||||
@@ -21,71 +26,109 @@ export function RockTokenFilterBar({
|
||||
filterStatus,
|
||||
totalTokens,
|
||||
loadingTokens,
|
||||
autoRefreshMismatchEnabled,
|
||||
refreshingMismatch,
|
||||
togglingAutoRefresh,
|
||||
onSearchKeywordChange,
|
||||
onFilterStatusChange,
|
||||
onRefresh,
|
||||
onRefreshMismatch,
|
||||
onToggleAutoRefreshMismatch,
|
||||
}: 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="搜索 包ID / 0666 编号 / 提取码 / 兑换码 / 订单号 / UID"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchKeywordChange(e.target.value)}
|
||||
onPressEnter={onRefresh}
|
||||
style={{ width: 400 }}
|
||||
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.Button value="abnormal">
|
||||
<Badge count={stats?.abnormal_stock ?? 0} offset={[6, -2]} size="small" style={{ backgroundColor: '#64748b' }}>
|
||||
<span>已上报失效</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col
|
||||
xs={24}
|
||||
md={8}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
共检索到 <strong style={{ color: '#0f172a' }}>{totalTokens}</strong> 条 Token 记录
|
||||
{/* 桌面端保持单行:筛选按钮组可横向滚动,移动端自动换行堆叠 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="搜索 包ID / 0666 编号 / 提取码 / 兑换码 / 订单号 / UID"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchKeywordChange(e.target.value)}
|
||||
onPressEnter={onRefresh}
|
||||
style={{ width: 320, maxWidth: '100%', flex: '0 0 auto' }}
|
||||
allowClear
|
||||
/>
|
||||
<div style={{ flex: '1 1 auto', minWidth: 0, overflowX: 'auto' }}>
|
||||
<Radio.Group
|
||||
value={filterStatus}
|
||||
onChange={(e) => {
|
||||
onFilterStatusChange(e.target.value)
|
||||
}}
|
||||
buttonStyle="solid"
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<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.Button value="abnormal">
|
||||
<Badge
|
||||
count={stats?.abnormal_stock ?? 0}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: '#64748b' }}
|
||||
>
|
||||
<span>已上报失效</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="using_overdue">
|
||||
<Badge
|
||||
count={stats?.using_overdue_stock ?? 0}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: '#f59e0b' }}
|
||||
>
|
||||
<span>使用超时</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="bound_overdue">
|
||||
<Badge
|
||||
count={stats?.bound_overdue_stock ?? 0}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: '#ef4444' }}
|
||||
>
|
||||
<span>绑定超时</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Space size={10} style={{ flex: '0 0 auto', flexWrap: 'wrap' }}>
|
||||
<Text type="secondary" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>
|
||||
共 <strong style={{ color: '#0f172a' }}>{totalTokens}</strong> 条
|
||||
</Text>
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
|
||||
刷新
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Tooltip title="立即刷新全部绑定错误 Token(单次最多 20 条,约 30 秒)">
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={onRefreshMismatch}
|
||||
loading={refreshingMismatch}
|
||||
>
|
||||
一键刷新全部绑定错误
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Space size={6} style={{ whiteSpace: 'nowrap' }}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={autoRefreshMismatchEnabled}
|
||||
loading={togglingAutoRefresh}
|
||||
onChange={onToggleAutoRefreshMismatch}
|
||||
/>
|
||||
<Text style={{ fontSize: 13 }}>启用自动刷新</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface RockTokenTableProps {
|
||||
totalTokens: number
|
||||
usingWarnDays: number
|
||||
usingDangerDays: number
|
||||
boundWarnDays: number
|
||||
selectedTokenIds: number[]
|
||||
onSelectedTokenIdsChange: (ids: number[], rows: RockTokenItem[]) => void
|
||||
onBatchDelete: () => void
|
||||
@@ -45,6 +46,7 @@ export function RockTokenTable({
|
||||
totalTokens,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
boundWarnDays,
|
||||
selectedTokenIds,
|
||||
onSelectedTokenIdsChange,
|
||||
onBatchDelete,
|
||||
@@ -222,6 +224,7 @@ export function RockTokenTable({
|
||||
copyingVoucherId,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
boundWarnDays,
|
||||
refreshingTokenId,
|
||||
onRefreshBinding: handleRefreshBinding,
|
||||
openingUpstreamId,
|
||||
|
||||
@@ -31,6 +31,8 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
using_overdue: '使用超时',
|
||||
bound_overdue: '绑定超时',
|
||||
}
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
@@ -34,6 +34,8 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
using_overdue: '使用超时',
|
||||
bound_overdue: '绑定超时',
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
@@ -44,6 +46,8 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'redeemed', label: '已发货' },
|
||||
{ value: 'mismatch', label: '绑定错误' },
|
||||
{ value: 'abnormal', label: '已上报失效' },
|
||||
{ value: 'using_overdue', label: '使用超时' },
|
||||
{ value: 'bound_overdue', label: '绑定超时' },
|
||||
]
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
@@ -18,15 +18,20 @@ 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()
|
||||
// 满 24 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。
|
||||
function elapsedDaysSince(value?: string): number | null {
|
||||
if (!value) return null
|
||||
const startedAt = new Date(value).getTime()
|
||||
if (Number.isNaN(startedAt)) return null
|
||||
const diff = Date.now() - startedAt
|
||||
return diff > 0 ? diff / MS_PER_DAY : 0
|
||||
}
|
||||
|
||||
// 使用中超时起点:using_at。
|
||||
function usingElapsedDays(usingAt?: string): number | null {
|
||||
return elapsedDaysSince(usingAt)
|
||||
}
|
||||
|
||||
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
|
||||
// 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。
|
||||
export function TokenInfoCell({ record }: { record: RockTokenItem }) {
|
||||
@@ -73,6 +78,7 @@ export interface TokenColumnHandlers {
|
||||
copyingVoucherId: number | null
|
||||
usingWarnDays: number
|
||||
usingDangerDays: number
|
||||
boundWarnDays: number
|
||||
refreshingTokenId: number | null
|
||||
onRefreshBinding: (id: number) => void
|
||||
openingUpstreamId: number | null
|
||||
@@ -91,6 +97,7 @@ export function buildTokenColumns({
|
||||
copyingVoucherId,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
boundWarnDays,
|
||||
refreshingTokenId,
|
||||
onRefreshBinding,
|
||||
openingUpstreamId,
|
||||
@@ -166,12 +173,24 @@ export function buildTokenColumns({
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'bound':
|
||||
case 'bound': {
|
||||
const boundDays = elapsedDaysSince(record.bound_at)
|
||||
if (boundDays !== null && boundDays >= boundWarnDays) {
|
||||
return (
|
||||
<Tag
|
||||
color="volcano"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
|
||||
>
|
||||
⏱ 绑定超时 {boundDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 已授权绑定
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'redeemed':
|
||||
return (
|
||||
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
@@ -209,6 +228,7 @@ export function buildTokenColumns({
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null
|
||||
const boundDays = record.status === 'bound' ? elapsedDaysSince(record.bound_at) : null
|
||||
const usingColor =
|
||||
usingDays === null
|
||||
? '#64748b'
|
||||
@@ -243,9 +263,10 @@ export function buildTokenColumns({
|
||||
</div>
|
||||
)}
|
||||
{!record.redeemed_at && record.bound_at && (
|
||||
<div style={{ fontSize: 11, color: '#0284c7' }}>
|
||||
<div style={{ fontSize: 11, color: boundDays !== null && boundDays >= boundWarnDays ? '#ea580c' : '#0284c7' }}>
|
||||
<span>🔗 绑定: </span>
|
||||
{formatDateTime(record.bound_at)}
|
||||
{boundDays !== null ? `(${boundDays.toFixed(1)} 天)` : ''}
|
||||
</div>
|
||||
)}
|
||||
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
|
||||
|
||||
Reference in New Issue
Block a user