Files
affiliate_dash/frontend/src/pages/platformRock/TokenDeleteModal.tsx
T

295 lines
9.3 KiB
TypeScript

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>
)
}