feat:Rock刷新去UID拦截,新增绑定错误自动刷新与使用/绑定超时预警

This commit is contained in:
2026-11-08 18:03:48 +08:00
parent 1dba8e4c2e
commit 51dcb9de5a
22 changed files with 768 additions and 182 deletions
+18
View File
@@ -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 })
+53
View File
@@ -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 && (