Files
affiliate_dash/frontend/src/pages/PlatformRock.tsx
T

629 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react'
import { Alert, Button, Form, Modal, Space, message } from 'antd'
import { HistoryOutlined, ImportOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
import { rockApi, type RockConfig, type RockStats, type RockTokenDeleteResult, type RockTokenItem } from '../api'
import { PageHeader } from '../components/PageHeader'
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 { PurchaseLogDrawer } from './platformRock/PurchaseLogDrawer'
import { ImportAllPackagesModal } from './platformRock/ImportAllPackagesModal'
import { TokenDeleteModal, type TokenDeleteMode } from './platformRock/TokenDeleteModal'
import { TokenRecycleBinDrawer } from './platformRock/TokenRecycleBinDrawer'
import { UidEditModal } from './platformRock/UidEditModal'
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
import type { UidEditState } from './platformRock/types'
function prettyRawData(raw?: string) {
if (!raw) return '暂无原始数据'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
export default function PlatformRock() {
const [config, setConfig] = useState<RockConfig | null>(null)
const [stats, setStats] = useState<RockStats | null>(null)
const [upstreamStock, setUpstreamStock] = useState<number | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [testingConn, setTestingConn] = useState(false)
const [testingLogin, setTestingLogin] = useState(false)
const [refreshingToken, setRefreshingToken] = useState(false)
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 [refreshingStale, setRefreshingStale] = useState(false)
const [purchasing, setPurchasing] = useState(false)
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
const [purchaseCount, setPurchaseCount] = useState(50)
const [purchaseLogOpen, setPurchaseLogOpen] = useState(false)
const [importPackageOpen, setImportPackageOpen] = useState(false)
const [rawDataRecord, setRawDataRecord] = useState<RockTokenItem | null>(null)
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([])
const [selectedTokens, setSelectedTokens] = useState<RockTokenItem[]>([])
const [tokenDeleteMode, setTokenDeleteMode] = useState<TokenDeleteMode | null>(null)
const [recycleOpen, setRecycleOpen] = useState(false)
// 凭证图片操作状态
const [copyingVoucherId, setCopyingVoucherId] = useState<number | null>(null)
const [previewVoucherUrl, setPreviewVoucherUrl] = useState('')
const [previewVoucherOpen, setPreviewVoucherOpen] = useState(false)
// Token 列表与筛选
const [tokens, setTokens] = useState<RockTokenItem[]>([])
const [totalTokens, setTotalTokens] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState<string>('all')
const [searchKeyword, setSearchKeyword] = useState('')
const [loadingTokens, setLoadingTokens] = useState(false)
const [form] = Form.useForm()
const [uidEdit, setUidEdit] = useState<UidEditState | null>(null)
const [savingUid, setSavingUid] = useState(false)
const loadData = useCallback(async () => {
setLoadingStats(true)
try {
try {
const dashboard = await rockApi.refreshDashboard()
setUpstreamStock(dashboard.available_black)
} catch (e) {
setUpstreamStock(null)
message.warning('获取上游库存/采购单价失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
const [cfg, st] = await Promise.all([rockApi.getConfig(), rockApi.getStats()])
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,
bearer_token: cfg.bearer_token,
login_username: cfg.login_username,
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,
bound_warn_days: cfg.bound_warn_days ?? 2,
auto_purchase_count: cfg.auto_purchase_count,
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
})
} catch (e) {
message.error('加载 Rock 平台配置失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setLoadingStats(false)
}
}, [form])
// 只刷新统计,不触碰配置表单,供定时刷新和列表刷新共用。
const loadStats = useCallback(async () => {
const st = await rockApi.getStats()
setStats(st)
}, [])
const loadTokens = useCallback(async () => {
setLoadingTokens(true)
try {
const res = await rockApi.listTokens({
page,
page_size: pageSize,
status: filterStatus === 'all' ? undefined : filterStatus,
search: searchKeyword || undefined,
})
setTokens(res.list || [])
setTotalTokens(res.total || 0)
} catch (e) {
message.error('加载库存列表失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setLoadingTokens(false)
}
}, [page, pageSize, filterStatus, searchKeyword])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
loadTokens()
}, [loadTokens])
// 定时刷新列表与统计,保证跨过 2 天/3 天阈值后标签和徽标自动更新。
const refreshTokensAndStats = useCallback(() => {
void loadStats().catch(() => {})
void loadTokens()
}, [loadStats, loadTokens])
useEffect(() => {
const timer = window.setInterval(() => {
if (document.visibilityState !== 'visible') return
refreshTokensAndStats()
}, 60000)
return () => window.clearInterval(timer)
}, [refreshTokensAndStats])
useEffect(() => {
setSelectedTokenIds([])
setSelectedTokens([])
}, [filterStatus, searchKeyword])
// 一键切换自动发货总开关 (Kill Switch)
const handleToggleAutoDelivery = async (checked: boolean) => {
Modal.confirm({
title: checked ? '确认开启 Rock 自动发货通道?' : '确认一键关闭 Rock 自动发货通道(软熔断)?',
content: checked
? '开启后,黑色 ROCK 与蓝色运动服两款商品将恢复 0666.pro 全自动反代发货。'
: '关闭后,两款商品将脱离自动发货通道,进单将转交人工打手大厅处理,防止错单穿透。',
okText: checked ? '确认开启' : '立即熔断关闭',
cancelText: '取消',
okButtonProps: {
danger: !checked,
},
onOk: async () => {
try {
const updated = await rockApi.updateConfig({ auto_delivery_enabled: checked })
setConfig(updated)
message.success(checked ? '自动发货通道已开启' : '自动发货通道已成功熔断关闭')
loadData()
} catch (e) {
message.error('更新总开关失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
},
})
}
// 自动补货开关:切换即保存,避免只改表单值后被刷新回填覆盖
const handleToggleAutoPurchase = async (checked: boolean) => {
const previous = autoPurchaseEnabled
setAutoPurchaseEnabled(checked)
setTogglingAutoPurchase(true)
try {
const updated = await rockApi.updateConfig({ auto_purchase_enabled: checked })
setConfig(updated)
setAutoPurchaseEnabled(updated.auto_purchase_enabled)
message.success(checked ? '自动补货已开启' : '自动补货已关闭')
} catch (e) {
setAutoPurchaseEnabled(previous)
message.error('更新自动补货开关失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setTogglingAutoPurchase(false)
}
}
// 一键刷新异常 Token:绑定错误 + 待绑定超时 + 绑定后未发货,同步执行,单次最多 20 条
const handleRefreshStale = async () => {
setRefreshingStale(true)
try {
const res = await rockApi.refreshStaleTokens()
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 {
setRefreshingStale(false)
}
}
// 自动刷新开关:与「一键刷新」同规则(绑定错误 + 待绑定超时 + 绑定后未发货),切换即保存
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')
if (!bearerToken) {
message.warning('请先输入 Bearer Token')
return
}
setTestingConn(true)
try {
const res = await rockApi.testConnection(bearerToken)
message.success(`连通成功!商户:${res.user?.username || '正常'},可用积分:${res.user?.points || 0}`)
loadData()
} catch (e) {
message.error('连通性测试失败:' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setTestingConn(false)
}
}
// 测试上游账号密码登录(只验证,不保存 token)
const handleTestLogin = async () => {
const username = form.getFieldValue('login_username') as string | undefined
const password = form.getFieldValue('login_password') as string | undefined
setTestingLogin(true)
try {
const res = await rockApi.testLogin({ username, password })
message.success(`登录成功:${res.username}(${res.role}),积分 ${res.points}`)
} catch (e) {
message.error('测试登录失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setTestingLogin(false)
}
}
// 强制登录上游并保存新的 Token
const handleRefreshToken = async () => {
setRefreshingToken(true)
try {
const res = await rockApi.refreshRockToken()
message.success(`Token 已刷新:${res.username}(${res.role}),积分 ${res.points}`)
await loadData()
} catch (e) {
message.error('刷新 Token 失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setRefreshingToken(false)
}
}
// 保存策略配置
const handleSaveConfig = async () => {
try {
const values = await form.validateFields()
// 密码留空表示不修改,避免每次保存都清空已保存的密文
if (!values.login_password) {
delete values.login_password
}
setSavingConfig(true)
const updated = await rockApi.updateConfig(values)
setConfig(updated)
message.success('配置已成功保存生效')
loadData()
} catch (e) {
if (e instanceof Error) {
message.error('保存失败: ' + e.message)
}
} finally {
setSavingConfig(false)
}
}
const openPurchaseModal = (count = 50) => {
setPurchaseCount(count)
setPurchaseModalOpen(true)
}
// 采购指定数量 Token
const handlePurchase = async (count: number) => {
if (!count || count <= 0) {
message.warning('请输入有效的采购数量')
return
}
if (upstreamStock !== null && count > upstreamStock) {
message.warning(`上游可用库存不足:当前 ${upstreamStock} 个,本次需要 ${count} 个`)
return
}
setPurchasing(true)
try {
const res = await rockApi.purchaseTokens(count)
if (res.limit_reason) {
message.warning(res.message || `上游货源不足,已按 ${res.planned_count} 个采购`)
} else {
message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`)
}
setPurchaseModalOpen(false)
loadData()
loadTokens()
} catch (e) {
message.error('采购失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setPurchasing(false)
}
}
const handleCorrectUid = async () => {
if (!uidEdit) {
return
}
const nextUid = uidEdit.value.trim()
if (!/^\d{1,15}$/.test(nextUid)) {
message.warning('请填写 15 位以内的纯数字 UID')
return
}
setSavingUid(true)
try {
const res = await rockApi.correctExpectedUid(uidEdit.id, nextUid)
message.success(res.message || '已更新买家 UID')
setUidEdit(null)
loadData()
loadTokens()
} catch (e) {
message.error('修改 UID 失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setSavingUid(false)
}
}
const handleFilterStatusChange = (value: string) => {
setFilterStatus(value)
setPage(1)
}
const handlePageChange = (nextPage: number, nextPageSize: number) => {
setSelectedTokenIds([])
setSelectedTokens([])
setPage(nextPage)
setPageSize(nextPageSize)
}
const handleTokenDeleted = (result: RockTokenDeleteResult) => {
setTokenDeleteMode(null)
setSelectedTokenIds([])
setSelectedTokens([])
setPage(1)
message.success(
`已软删除 ${result.deleted} 条 Token${result.forced ? '(含强制确认的进行中 Token)' : ''}`,
)
loadData()
loadTokens()
}
const handleUidValueChange = (value: string) => {
setUidEdit((current) => (current ? { ...current, value } : current))
}
const autoDelivery = config?.auto_delivery_enabled ?? true
const availableStock = stats?.available_stock ?? 0
const warningStock = config?.token_warning_stock ?? 10
const isStockLow = availableStock <= warningStock
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 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
return (
<div style={{ padding: '0 4px' }}>
<PageHeader
title="Rock 发货平台 (0666.pro) 自动发货与库存风控"
subtitle="和平精英「黑色ROCK长袖T恤」与「蓝色运动服上衣」通用库存池、上游采购、UID强一致防错门禁与软熔断机制"
extra={
<Space size="middle">
<Button
type="primary"
icon={<ShoppingCartOutlined />}
onClick={() => openPurchaseModal(50)}
loading={purchasing}
style={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
boxShadow: '0 2px 4px rgba(37,99,235,0.2)',
}}
>
采购通用 Token
</Button>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loadingStats}>
刷新状态
</Button>
<Button icon={<HistoryOutlined />} onClick={() => setPurchaseLogOpen(true)}>
采购日志
</Button>
<Button icon={<ImportOutlined />} onClick={() => setImportPackageOpen(true)}>
全量导入
</Button>
</Space>
}
/>
{stats?.last_purchase_error ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="最近一次 Rock 采购/打包未完全成功"
description={`${stats.last_purchase_error}${
stats.last_purchase_error_at ? `(${stats.last_purchase_error_at})` : ''
}`}
/>
) : null}
{/* 顶部高质感指标卡片 */}
<RockMetricCards
stats={stats}
upstreamStock={upstreamStock}
blackTokenCost={tokenCost}
availableStock={availableStock}
warningStock={warningStock}
isStockLow={isStockLow}
hasMismatch={hasMismatch}
upstreamPoints={upstreamPoints}
maxPurchasable={maxPurchasable}
onOpenPurchase={openPurchaseModal}
/>
{/* 核心控制与上游策略看板 */}
<RockStrategyCard
form={form}
autoDelivery={autoDelivery}
autoPurchaseEnabled={autoPurchaseEnabled}
autoPurchaseCoolingSeconds={stats?.auto_purchase_cooldown_seconds ?? 0}
nextAutoPurchaseAt={stats?.next_auto_purchase_at}
loginPasswordSet={config?.login_password_set}
lastTokenRefreshAt={config?.last_token_refresh_at}
lastTokenError={config?.last_token_error}
testingLogin={testingLogin}
onTestLogin={handleTestLogin}
refreshingToken={refreshingToken}
onRefreshToken={handleRefreshToken}
togglingAutoPurchase={togglingAutoPurchase}
testingConn={testingConn}
savingConfig={savingConfig}
onToggleAutoDelivery={handleToggleAutoDelivery}
onToggleAutoPurchase={handleToggleAutoPurchase}
onTestConnection={handleTestConnection}
onSaveConfig={handleSaveConfig}
/>
{/* 筛选与搜索工具条(对齐标杆页面) */}
<RockTokenFilterBar
stats={stats}
searchKeyword={searchKeyword}
filterStatus={filterStatus}
totalTokens={totalTokens}
loadingTokens={loadingTokens}
autoRefreshMismatchEnabled={autoRefreshMismatchEnabled}
refreshingStale={refreshingStale}
togglingAutoRefresh={togglingAutoRefresh}
onSearchKeywordChange={setSearchKeyword}
onFilterStatusChange={handleFilterStatusChange}
onRefresh={refreshTokensAndStats}
onRefreshStale={handleRefreshStale}
onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
/>
{/* 主库存与错单审计表格 */}
<RockTokenTable
tokens={tokens}
loadingTokens={loadingTokens}
usingWarnDays={usingWarnDays}
usingDangerDays={usingDangerDays}
boundWarnDays={boundWarnDays}
page={page}
pageSize={pageSize}
totalTokens={totalTokens}
selectedTokenIds={selectedTokenIds}
onSelectedTokenIdsChange={(ids, rows) => {
setSelectedTokenIds(ids)
setSelectedTokens(rows)
}}
onBatchDelete={() => setTokenDeleteMode('batch')}
onDeleteAll={() => setTokenDeleteMode('all')}
deletedCount={stats?.deleted_stock ?? 0}
onOpenRecycleBin={() => setRecycleOpen(true)}
copyingVoucherId={copyingVoucherId}
setCopyingVoucherId={setCopyingVoucherId}
setPreviewVoucherUrl={setPreviewVoucherUrl}
setPreviewVoucherOpen={setPreviewVoucherOpen}
onEditUid={setUidEdit}
onViewRawData={setRawDataRecord}
onPageChange={handlePageChange}
onReloadData={loadData}
onReloadTokens={loadTokens}
/>
{/* 手动采购指定数量 Token 弹窗 */}
<PurchaseTokenModal
open={purchaseModalOpen}
stats={stats}
purchaseCount={purchaseCount}
purchasing={purchasing}
maxPurchasable={maxPurchasable}
upstreamPoints={upstreamPoints}
estimatedCost={estimatedCost}
isPointsInsufficient={isPointsInsufficient}
tokenCost={tokenCost}
upstreamStock={upstreamStock}
onPurchaseCountChange={setPurchaseCount}
onCancel={() => setPurchaseModalOpen(false)}
onConfirm={handlePurchase}
/>
<Modal
open={Boolean(rawDataRecord)}
title={rawDataRecord ? `原始数据 · 0666 #${rawDataRecord.upstream_token_id}` : '原始数据'}
footer={null}
width={760}
onCancel={() => setRawDataRecord(null)}
>
<pre
style={{
maxHeight: 540,
overflow: 'auto',
margin: 0,
padding: 16,
borderRadius: 8,
background: '#0f172a',
color: '#e2e8f0',
fontSize: 12,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{prettyRawData(rawDataRecord?.raw_data)}
</pre>
</Modal>
<UidEditModal
uidEdit={uidEdit}
savingUid={savingUid}
onValueChange={handleUidValueChange}
onConfirm={handleCorrectUid}
onCancel={() => setUidEdit(null)}
/>
{/* 发货结果图片高清预览弹窗 */}
<VoucherPreviewModal
open={previewVoucherOpen}
previewVoucherUrl={previewVoucherUrl}
onClose={() => setPreviewVoucherOpen(false)}
/>
<PurchaseLogDrawer open={purchaseLogOpen} onClose={() => setPurchaseLogOpen(false)} />
<ImportAllPackagesModal
open={importPackageOpen}
onClose={() => setImportPackageOpen(false)}
onImported={() => {
loadData()
loadTokens()
}}
/>
<TokenDeleteModal
open={tokenDeleteMode !== null}
mode={tokenDeleteMode ?? 'batch'}
selectedTokens={selectedTokens}
selectedTokenIds={selectedTokenIds}
filterStatus={filterStatus}
searchKeyword={searchKeyword}
onCancel={() => setTokenDeleteMode(null)}
onDeleted={handleTokenDeleted}
/>
<TokenRecycleBinDrawer
open={recycleOpen}
onClose={() => setRecycleOpen(false)}
onRestored={() => {
loadData()
loadTokens()
}}
/>
</div>
)
}