295 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|