feat:rock增加软删除和恢复删除

This commit is contained in:
2026-10-31 16:48:47 +08:00
parent 2d1ff1b520
commit 909867b9fb
23 changed files with 2116 additions and 82 deletions
+85
View File
@@ -591,6 +591,7 @@ export interface RockStats {
redeemed_stock: number;
mismatch_stock: number;
abnormal_stock: number;
deleted_stock?: number;
upstream_points: number;
auto_delivery_enabled: boolean;
token_warning_stock: number;
@@ -658,6 +659,7 @@ export interface RockTokenItem {
redeemed_at?: string;
created_at: string;
updated_at?: string;
deleted_at?: string | null;
}
export interface RockPurchaseResult {
@@ -730,6 +732,47 @@ export interface ImportAllResult {
results: PackageImportItem[];
}
export interface RockTokenDeleteStatusCount {
status: string;
count: number;
}
export interface RockTokenDeleteItem {
id: number;
status: string;
assigned_order_no?: string;
}
export interface RockTokenDeletePreview {
total: number;
dangerous: number;
available: number;
requires_force: boolean;
status_counts: RockTokenDeleteStatusCount[];
dangerous_items: RockTokenDeleteItem[];
}
export interface RockTokenDeleteResult {
requested: number;
deleted: number;
forced: boolean;
status_counts: RockTokenDeleteStatusCount[];
}
export interface RockTokenRestoreSkipItem {
id: number;
status?: string;
reason: string;
}
export interface RockTokenRestoreResult {
requested: number;
restored: number;
skipped: number;
status_counts: RockTokenDeleteStatusCount[];
skipped_items: RockTokenRestoreSkipItem[];
}
export const rockApi = {
getConfig: () =>
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
@@ -788,6 +831,48 @@ export const rockApi = {
page_size: number;
},
),
previewTokenDelete: (params?: { status?: string; search?: string }) =>
request
.get("/platform/rock/token-batches/preview", { params })
.then((r) => r.data.data as RockTokenDeletePreview),
batchDeleteTokens: (data: {
ids: number[];
force?: boolean;
force_confirm_text?: string;
}) =>
request
.post("/platform/rock/token-batches/batch-delete", data)
.then((r) => r.data.data as RockTokenDeleteResult),
deleteAllTokens: (data: {
status?: string;
search?: string;
expected_count: number;
force?: boolean;
confirm_text: string;
force_confirm_text?: string;
}) =>
request
.post("/platform/rock/token-batches/delete-all", data)
.then((r) => r.data.data as RockTokenDeleteResult),
listDeletedTokens: (params?: {
page?: number;
page_size?: number;
status?: string;
search?: string;
}) =>
request.get("/platform/rock/token-batches/deleted", { params }).then(
(r) =>
r.data.data as {
list: RockTokenItem[];
total: number;
page: number;
page_size: number;
},
),
restoreTokens: (data: { ids: number[] }) =>
request
.post("/platform/rock/token-batches/restore", data)
.then((r) => r.data.data as RockTokenRestoreResult),
correctExpectedUid: (id: number, expectedUid: string) =>
request
.post(`/platform/rock/tokens/${id}/expected-uid`, {
+55 -1
View File
@@ -1,7 +1,7 @@
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 RockTokenItem } from '../api'
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'
@@ -10,6 +10,8 @@ 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'
@@ -38,6 +40,10 @@ export default function PlatformRock() {
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)
@@ -115,6 +121,11 @@ export default function PlatformRock() {
loadTokens()
}, [loadTokens])
useEffect(() => {
setSelectedTokenIds([])
setSelectedTokens([])
}, [filterStatus, searchKeyword])
// 一键切换自动发货总开关 (Kill Switch)
const handleToggleAutoDelivery = async (checked: boolean) => {
Modal.confirm({
@@ -257,10 +268,24 @@ export default function PlatformRock() {
}
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))
}
@@ -372,6 +397,15 @@ export default function PlatformRock() {
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}
@@ -451,6 +485,26 @@ export default function PlatformRock() {
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>
)
}
@@ -1,6 +1,7 @@
import type { Dispatch, SetStateAction } from 'react'
import { useState } from 'react'
import { Table, message } from 'antd'
import { Button, Space, Table, Typography, message } from 'antd'
import { DeleteOutlined, InboxOutlined } from '@ant-design/icons'
import type { RockTokenItem } from '../../api'
import { rockApi } from '../../api'
import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../../utils/voucher'
@@ -8,6 +9,8 @@ import { formatDateTime } from '../../utils/time'
import { buildTokenColumns } from './tokenColumns'
import type { UidEditState } from './types'
const { Text } = Typography
export interface RockTokenTableProps {
tokens: RockTokenItem[]
loadingTokens: boolean
@@ -16,6 +19,13 @@ export interface RockTokenTableProps {
totalTokens: number
usingWarnDays: number
usingDangerDays: number
selectedTokenIds: number[]
onSelectedTokenIdsChange: (ids: number[], rows: RockTokenItem[]) => void
onBatchDelete: () => void
onDeleteAll: () => void
deletedCount: number
onOpenRecycleBin: () => void
bulkDeleting?: boolean
copyingVoucherId: number | null
setCopyingVoucherId: Dispatch<SetStateAction<number | null>>
setPreviewVoucherUrl: Dispatch<SetStateAction<string>>
@@ -35,6 +45,13 @@ export function RockTokenTable({
totalTokens,
usingWarnDays,
usingDangerDays,
selectedTokenIds,
onSelectedTokenIdsChange,
onBatchDelete,
onDeleteAll,
deletedCount,
onOpenRecycleBin,
bulkDeleting,
copyingVoucherId,
setCopyingVoucherId,
setPreviewVoucherUrl,
@@ -220,23 +237,65 @@ export function RockTokenTable({
})
return (
<Table
rowKey="id"
columns={columns}
dataSource={tokens}
loading={loadingTokens}
scroll={{ x: 1700 }}
pagination={{
current: page,
pageSize,
total: totalTokens,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total) => `共 ${total} 条 Token 记录`,
onChange: (p, ps) => {
onPageChange(p, ps)
},
}}
/>
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
marginBottom: 12,
}}
>
<Space wrap>
<Text type="secondary">已选 {selectedTokenIds.length} 条</Text>
<Button
danger
icon={<DeleteOutlined />}
disabled={!selectedTokenIds.length || Boolean(bulkDeleting)}
onClick={onBatchDelete}
>
批量删除
</Button>
<Button
danger
disabled={Boolean(bulkDeleting) || totalTokens === 0}
onClick={onDeleteAll}
>
按当前筛选全部删除
</Button>
<Button icon={<InboxOutlined />} onClick={onOpenRecycleBin}>
已删除{deletedCount > 0 ? `(${deletedCount})` : ''}
</Button>
</Space>
</div>
<Table
rowKey="id"
columns={columns}
dataSource={tokens}
loading={loadingTokens}
scroll={{ x: 1700 }}
rowSelection={{
selectedRowKeys: selectedTokenIds,
preserveSelectedRowKeys: true,
onChange: (keys, rows) => {
onSelectedTokenIdsChange(keys.map((key) => Number(key)), rows)
},
getCheckboxProps: () => ({ disabled: Boolean(bulkDeleting) }),
}}
pagination={{
current: page,
pageSize,
total: totalTokens,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total) => `共 ${total} 条 Token 记录`,
onChange: (p, ps) => {
onPageChange(p, ps)
},
}}
/>
</div>
)
}
@@ -0,0 +1,294 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Checkbox, Input, Modal, Space, Tag, Typography } from 'antd'
import { ExclamationCircleOutlined } from '@ant-design/icons'
import {
rockApi,
type RockTokenDeletePreview,
type RockTokenDeleteResult,
type RockTokenDeleteStatusCount,
type RockTokenItem,
} from '../../api'
const { Text } = Typography
export type TokenDeleteMode = 'batch' | 'all'
export interface TokenDeleteModalProps {
open: boolean
mode: TokenDeleteMode
selectedTokens: RockTokenItem[]
selectedTokenIds: number[]
filterStatus: string
searchKeyword: string
onCancel: () => void
onDeleted: (result: RockTokenDeleteResult) => void
}
const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
}
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
function statusLabel(status: string) {
return STATUS_LABELS[status] || status || '-'
}
function statusTagColor(status: string) {
switch (status) {
case 'available':
return 'success'
case 'using':
return 'processing'
case 'bound':
return 'cyan'
case 'redeemed':
return 'purple'
case 'mismatch':
return 'error'
default:
return 'default'
}
}
function countSelectedByStatus(tokens: RockTokenItem[]) {
const counts = new Map<string, number>()
for (const token of tokens) {
counts.set(token.status, (counts.get(token.status) || 0) + 1)
}
return Array.from(counts.entries()).map(([status, count]) => ({ status, count }))
}
function renderStatusCounts(counts: RockTokenDeleteStatusCount[]) {
if (!counts.length) return <Text type="secondary">无</Text>
return (
<Space size={[6, 6]} wrap>
{counts.map((item) => (
<Tag key={item.status} color={statusTagColor(item.status)}>
{statusLabel(item.status)} {item.count}
</Tag>
))}
</Space>
)
}
export function TokenDeleteModal({
open,
mode,
selectedTokens,
selectedTokenIds,
filterStatus,
searchKeyword,
onCancel,
onDeleted,
}: TokenDeleteModalProps) {
const [preview, setPreview] = useState<RockTokenDeletePreview | null>(null)
const [loadingPreview, setLoadingPreview] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [acknowledged, setAcknowledged] = useState(false)
const [allConfirmText, setAllConfirmText] = useState('')
const [forceConfirmText, setForceConfirmText] = useState('')
const selectedStatusCounts = useMemo(
() => countSelectedByStatus(selectedTokens),
[selectedTokens],
)
const selectedDangerous = useMemo(
() => selectedTokens.filter((token) => DANGEROUS_STATUSES.has(token.status)).length,
[selectedTokens],
)
const selectedAvailable = useMemo(
() => selectedTokens.filter((token) => token.status === 'available').length,
[selectedTokens],
)
const total = mode === 'batch' ? selectedTokenIds.length : preview?.total ?? 0
const dangerous = mode === 'batch' ? selectedDangerous : preview?.dangerous ?? 0
const available = mode === 'batch' ? selectedAvailable : preview?.available ?? 0
const statusCounts = mode === 'batch' ? selectedStatusCounts : preview?.status_counts ?? []
const requiresForce = dangerous > 0
const filterLabel =
filterStatus && filterStatus !== 'all' ? statusLabel(filterStatus) : '全部状态'
const canSubmit =
total > 0 &&
acknowledged &&
!submitting &&
(!requiresForce || forceConfirmText.trim() === '强制删除') &&
(mode === 'batch' || allConfirmText.trim() === '全部删除')
useEffect(() => {
if (!open) return
setAcknowledged(false)
setAllConfirmText('')
setForceConfirmText('')
setError('')
if (mode === 'all') {
setLoadingPreview(true)
setPreview(null)
rockApi
.previewTokenDelete({
status: filterStatus && filterStatus !== 'all' ? filterStatus : undefined,
search: searchKeyword || undefined,
})
.then((data) => setPreview(data))
.catch((e) => setError(e instanceof Error ? e.message : '预览失败'))
.finally(() => setLoadingPreview(false))
}
}, [open, mode, filterStatus, searchKeyword])
const handleSubmit = async () => {
if (!canSubmit) return
setSubmitting(true)
setError('')
try {
const result =
mode === 'batch'
? await rockApi.batchDeleteTokens({
ids: selectedTokenIds,
force: requiresForce,
force_confirm_text: requiresForce ? forceConfirmText.trim() : undefined,
})
: await rockApi.deleteAllTokens({
status: filterStatus && filterStatus !== 'all' ? filterStatus : undefined,
search: searchKeyword || undefined,
expected_count: preview?.total ?? 0,
force: requiresForce,
confirm_text: allConfirmText.trim(),
force_confirm_text: requiresForce ? forceConfirmText.trim() : undefined,
})
onDeleted(result)
} catch (e) {
setError(e instanceof Error ? e.message : '删除失败')
if (mode === 'all') {
rockApi
.previewTokenDelete({
status: filterStatus && filterStatus !== 'all' ? filterStatus : undefined,
search: searchKeyword || undefined,
})
.then((data) => setPreview(data))
.catch(() => {})
}
} finally {
setSubmitting(false)
}
}
const title = mode === 'batch' ? '批量软删除 Token' : '按当前筛选全部软删除'
return (
<Modal
open={open}
title={title}
onCancel={submitting ? undefined : onCancel}
okText={submitting ? '删除中…' : '确认软删除'}
cancelText="取消"
okButtonProps={{ danger: true, disabled: !canSubmit, loading: submitting }}
onOk={handleSubmit}
width={640}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="软删除只影响本地列表、统计与库存分配"
description="不会删除或退回 0666.pro 上游 Token,数据仍保留在数据库;删除后全量导入不会把已删除的 Token 重新拉回。"
/>
{mode === 'all' && (
<div>
<Text type="secondary">
删除范围:{filterLabel}
{searchKeyword ? ` · 搜索「${searchKeyword}」` : ''}
</Text>
</div>
)}
{loadingPreview ? (
<Text type="secondary">正在统计当前筛选结果…</Text>
) : (
<>
<div>
<Text strong>共 {total} 条</Text>
{available > 0 && (
<Text type="secondary"> · 其中可用库存 {available} 条</Text>
)}
{dangerous > 0 && (
<Text type="danger"> · 使用中/已绑定/绑定错误 {dangerous} 条</Text>
)}
</div>
<div>
<Text type="secondary">状态分布:</Text>
{renderStatusCounts(statusCounts)}
</div>
</>
)}
{available > 0 && (
<Alert
type="warning"
showIcon
message={`将删除 ${available} 条可用库存`}
description="本地可用库存会立即减少;当前开启了自动补货,后续触发库存检查时可能自动补货,把库存补回来。"
/>
)}
{dangerous > 0 && (
<Alert
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
message={`包含 ${dangerous} 条进行中的 Token`}
description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
/>
)}
{error && <Alert type="error" showIcon message={error} />}
<Checkbox
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
>
我已确认上述影响,同意软删除
</Checkbox>
{mode === 'all' && (
<div>
<Text type="secondary">
请输入 <Text code>全部删除</Text> 确认按当前筛选删除:
</Text>
<Input
value={allConfirmText}
onChange={(e) => setAllConfirmText(e.target.value)}
placeholder="全部删除"
style={{ marginTop: 6 }}
autoComplete="off"
/>
</div>
)}
{requiresForce && (
<div>
<Text type="danger">
本次包含进行中的 Token,请再输入 <Text code>强制删除</Text> 确认:
</Text>
<Input
value={forceConfirmText}
onChange={(e) => setForceConfirmText(e.target.value)}
placeholder="强制删除"
style={{ marginTop: 6 }}
autoComplete="off"
status="error"
/>
</div>
)}
</Space>
</Modal>
)
}
@@ -0,0 +1,396 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Button,
Checkbox,
Drawer,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { UndoOutlined } from '@ant-design/icons'
import { rockApi, type RockTokenItem, type RockTokenRestoreResult } from '../../api'
import { formatDateTime } from '../../utils/time'
const { Text } = Typography
export interface TokenRecycleBinDrawerProps {
open: boolean
onClose: () => void
onRestored: (result: RockTokenRestoreResult) => void
}
const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
}
const STATUS_OPTIONS = [
{ value: 'all', label: '全部状态' },
{ value: 'available', label: '可用库存' },
{ value: 'using', label: '使用中' },
{ value: 'bound', label: '已绑定' },
{ value: 'redeemed', label: '已发货' },
{ value: 'mismatch', label: '绑定错误' },
{ value: 'abnormal', label: '已上报失效' },
]
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
const SKIP_REASONS: Record<string, string> = {
not_deleted: '已不在回收站',
duplicate_upstream: '上游编号已有在用 Token',
order_conflict: '同一订单已有在用 Token',
}
function statusLabel(status: string) {
return STATUS_LABELS[status] || status || '-'
}
function statusTagColor(status: string) {
switch (status) {
case 'available':
return 'success'
case 'using':
return 'processing'
case 'bound':
return 'cyan'
case 'redeemed':
return 'purple'
case 'mismatch':
return 'error'
case 'abnormal':
return 'default'
default:
return 'default'
}
}
function countByStatus(rows: RockTokenItem[]) {
const counts = new Map<string, number>()
for (const row of rows) {
counts.set(row.status, (counts.get(row.status) || 0) + 1)
}
return Array.from(counts.entries()).map(([status, count]) => ({ status, count }))
}
function errorText(e: unknown, fallback: string) {
return e instanceof Error ? e.message : fallback
}
export function TokenRecycleBinDrawer({
open,
onClose,
onRestored,
}: TokenRecycleBinDrawerProps) {
const [rows, setRows] = useState<RockTokenItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [status, setStatus] = useState('all')
const [searchInput, setSearchInput] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [selectedRows, setSelectedRows] = useState<RockTokenItem[]>([])
const [confirmRows, setConfirmRows] = useState<RockTokenItem[] | null>(null)
const [acknowledged, setAcknowledged] = useState(false)
const [restoring, setRestoring] = useState(false)
const [lastResult, setLastResult] = useState<RockTokenRestoreResult | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await rockApi.listDeletedTokens({
page,
page_size: pageSize,
status: status === 'all' ? undefined : status,
search: searchKeyword || undefined,
})
setRows(res.list || [])
setTotal(res.total || 0)
} catch (e) {
message.error('加载回收站失败: ' + errorText(e, '未知错误'))
} finally {
setLoading(false)
}
}, [page, pageSize, status, searchKeyword])
useEffect(() => {
if (!open) return
load()
}, [open, load])
const handleClose = () => {
setSelectedIds([])
setSelectedRows([])
setConfirmRows(null)
setAcknowledged(false)
setLastResult(null)
onClose()
}
const openConfirm = (target: RockTokenItem[]) => {
if (!target.length) return
setAcknowledged(false)
setConfirmRows(target)
}
const handleRestore = async () => {
if (!confirmRows || !acknowledged || restoring) return
setRestoring(true)
try {
const result = await rockApi.restoreTokens({ ids: confirmRows.map((row) => row.id) })
if (result.skipped > 0) {
message.warning(`已恢复 ${result.restored} 条,跳过 ${result.skipped} 条`)
} else {
message.success(`已恢复 ${result.restored} 条 Token`)
}
setLastResult(result.skipped > 0 ? result : null)
setConfirmRows(null)
setAcknowledged(false)
setSelectedIds([])
setSelectedRows([])
onRestored(result)
// 当前页被恢复空时回退一页,否则原地刷新。
if (page > 1 && result.restored >= rows.length) {
setPage(page - 1)
} else {
await load()
}
} catch (e) {
message.error('恢复失败: ' + errorText(e, '未知错误'))
} finally {
setRestoring(false)
}
}
const confirmStatusCounts = countByStatus(confirmRows || [])
const confirmDangerous = (confirmRows || []).filter((row) =>
DANGEROUS_STATUSES.has(row.status),
).length
const columns: ColumnsType<RockTokenItem> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '0666 编号',
dataIndex: 'upstream_token_id',
width: 180,
render: (value?: string) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (value: string) => (
<Tag color={statusTagColor(value)}>{statusLabel(value)}</Tag>
),
},
{
title: '订单号',
dataIndex: 'assigned_order_no',
width: 200,
render: (value?: string) => value || '-',
},
{
title: '角色',
dataIndex: 'actual_role_name',
width: 140,
render: (value?: string) => value || '-',
},
{
title: '删除时间',
dataIndex: 'deleted_at',
width: 170,
render: (value?: string | null) => formatDateTime(value),
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (value?: string) => value || '-',
},
{
title: '操作',
key: 'action',
width: 96,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
size="small"
icon={<UndoOutlined />}
disabled={restoring}
onClick={() => openConfirm([record])}
>
恢复
</Button>
),
},
]
return (
<>
<Drawer
open={open}
onClose={handleClose}
width={1100}
title="回收站 · 已删除 Token"
styles={{ body: { paddingTop: 16 } }}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="恢复说明"
description="恢复会把 Token 放回列表与统计,状态保持删除前的值;若同一上游编号已在用、或同一订单已有在用 Token,该条会被自动跳过,避免产生重复数据。"
/>
{lastResult && (
<Alert
type="warning"
showIcon
closable
onClose={() => setLastResult(null)}
message={`已恢复 ${lastResult.restored} 条,跳过 ${lastResult.skipped} 条`}
description={
<Space direction="vertical" size={2}>
{lastResult.skipped_items.map((item) => (
<Text key={item.id} type="secondary">
#{item.id} {SKIP_REASONS[item.reason] || item.reason}
</Text>
))}
</Space>
}
/>
)}
<Space wrap>
<Input.Search
placeholder="搜索 ID / 0666 编号 / 订单号 / UID"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onSearch={(value) => {
setSearchKeyword(value.trim())
setPage(1)
setSelectedIds([])
setSelectedRows([])
}}
allowClear
style={{ width: 320 }}
/>
<Select
value={status}
options={STATUS_OPTIONS}
style={{ width: 140 }}
onChange={(value) => {
setStatus(value)
setPage(1)
setSelectedIds([])
setSelectedRows([])
}}
/>
<Text type="secondary">共 {total} 条已删除记录</Text>
<Button
type="primary"
icon={<UndoOutlined />}
disabled={!selectedIds.length || restoring}
onClick={() => openConfirm(selectedRows)}
>
批量恢复{selectedIds.length ? `(${selectedIds.length})` : ''}
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={rows}
loading={loading}
scroll={{ x: 1100 }}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys, selected) => {
setSelectedIds(keys.map((key) => Number(key)))
setSelectedRows(selected)
},
getCheckboxProps: () => ({ disabled: restoring }),
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (value) => `回收站共 ${value} 条`,
onChange: (nextPage, nextPageSize) => {
setSelectedIds([])
setSelectedRows([])
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Space>
</Drawer>
<Modal
open={Boolean(confirmRows?.length)}
title={`恢复 ${confirmRows?.length ?? 0} 条 Token`}
onCancel={() => {
if (restoring) return
setConfirmRows(null)
setAcknowledged(false)
}}
okText={restoring ? '恢复中…' : '确认恢复'}
cancelText="取消"
okButtonProps={{ disabled: !acknowledged, loading: restoring }}
onOk={handleRestore}
width={560}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Text type="secondary">状态分布:</Text>
<Space size={[6, 6]} wrap style={{ marginTop: 6 }}>
{confirmStatusCounts.map((item) => (
<Tag key={item.status} color={statusTagColor(item.status)}>
{statusLabel(item.status)} {item.count}
</Tag>
))}
</Space>
</div>
{confirmDangerous > 0 && (
<Alert
type="warning"
showIcon
message={`包含 ${confirmDangerous} 条使用中/已绑定/绑定错误的 Token`}
description="恢复后会重新出现在进行中统计里,并可能重新关联原订单;请确认对应订单已处理或已人工接管。"
/>
)}
<Text type="secondary">
同一上游编号或同一订单已被其它 Token 占用时,该条会自动跳过,不会产生重复数据。
</Text>
<Checkbox
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
>
我已确认恢复后的状态与影响
</Checkbox>
</Space>
</Modal>
</>
)
}