feat:rock增加软删除和恢复删除
This commit is contained in:
@@ -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`, {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user