287 lines
9.9 KiB
TypeScript
287 lines
9.9 KiB
TypeScript
import { useCallback, useState } from 'react'
|
||
import { Alert, Button, Empty, Input, Table, Tag, message } from 'antd'
|
||
import { SearchOutlined } from '@ant-design/icons'
|
||
import { rockApi, type RockTokenItem, type RockTokenStatusLog } from '../api'
|
||
import { PageHeader } from '../components/PageHeader'
|
||
import { formatDateTime } from '../utils/time'
|
||
import { copyVoucherDataUrlToClipboard, generateVoucherDataURL } from '../utils/voucher'
|
||
import { buildTokenColumns } from './platformRock/tokenColumns'
|
||
import { TokenStatusLogDrawer } from './platformRock/TokenStatusLogDrawer'
|
||
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
|
||
|
||
export default function SupportRockTokens() {
|
||
const [search, setSearch] = useState('')
|
||
const [query, setQuery] = useState('')
|
||
const [searched, setSearched] = useState(false)
|
||
const [tokens, setTokens] = useState<RockTokenItem[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(20)
|
||
const [loading, setLoading] = useState(false)
|
||
const [statusLogToken, setStatusLogToken] = useState<RockTokenItem | null>(null)
|
||
const [copyingVoucherId, setCopyingVoucherId] = useState<number | null>(null)
|
||
const [openingUpstreamId, setOpeningUpstreamId] = useState<number | null>(null)
|
||
const [previewVoucherUrl, setPreviewVoucherUrl] = useState('')
|
||
const [previewVoucherOpen, setPreviewVoucherOpen] = useState(false)
|
||
|
||
const fetchTokens = useCallback(
|
||
async (keyword: string, nextPage: number, nextSize: number) => {
|
||
const trimmed = keyword.trim()
|
||
if (!trimmed) return
|
||
setLoading(true)
|
||
try {
|
||
const res = await rockApi.listSupportBoundTokens({
|
||
page: nextPage,
|
||
page_size: nextSize,
|
||
search: trimmed,
|
||
})
|
||
setTokens(res.list || [])
|
||
setTotal(res.total || 0)
|
||
} catch (e) {
|
||
setTokens([])
|
||
setTotal(0)
|
||
message.error('查询失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
},
|
||
[],
|
||
)
|
||
|
||
const handleSearch = () => {
|
||
const trimmed = search.trim()
|
||
if (!trimmed) {
|
||
message.warning('请输入包ID、上游编号、提取码、兑换码、工单号、商户单号或角色 ID 后再查询')
|
||
return
|
||
}
|
||
setQuery(trimmed)
|
||
setPage(1)
|
||
setSearched(true)
|
||
void fetchTokens(trimmed, 1, pageSize)
|
||
}
|
||
|
||
const handleSearchChange = (value: string) => {
|
||
setSearch(value)
|
||
if (!value.trim()) {
|
||
// 清空输入框时回到未查询状态,不自动加载数据。
|
||
setSearch('')
|
||
setQuery('')
|
||
setSearched(false)
|
||
setTokens([])
|
||
setTotal(0)
|
||
setPage(1)
|
||
}
|
||
}
|
||
|
||
const handlePageChange = (nextPage: number, nextSize: number) => {
|
||
if (!query) return
|
||
setPage(nextPage)
|
||
setPageSize(nextSize)
|
||
void fetchTokens(query, nextPage, nextSize)
|
||
}
|
||
|
||
const handleOpenUpstream = async (id: number) => {
|
||
// 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。
|
||
const win = window.open('about:blank', '_blank')
|
||
setOpeningUpstreamId(id)
|
||
try {
|
||
const res = await rockApi.getTokenPackageLink(id)
|
||
if (!win) {
|
||
message.warning('浏览器拦截了新窗口,请允许弹窗后重试')
|
||
return
|
||
}
|
||
win.opener = null
|
||
win.location.href = res.url
|
||
} catch (e) {
|
||
win?.close()
|
||
message.error('获取上游包链接失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||
} finally {
|
||
setOpeningUpstreamId(null)
|
||
}
|
||
}
|
||
|
||
const loadStatusLogs = useCallback(
|
||
async (tokenId: number): Promise<RockTokenStatusLog[]> => {
|
||
const res = await rockApi.listSupportTokenStatusLogs(tokenId)
|
||
return res.list || []
|
||
},
|
||
[],
|
||
)
|
||
|
||
const handleCopyVoucher = async (record: RockTokenItem) => {
|
||
setCopyingVoucherId(record.id)
|
||
try {
|
||
const isRockBlue = record.target_skin?.includes('蓝')
|
||
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
|
||
const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤')
|
||
const channel = record.actual_channel || record.region || '安卓-微信'
|
||
const timeStr = record.redeemed_at || record.bound_at || record.created_at
|
||
const cleanRole = (record.actual_role_name || '').trim()
|
||
|
||
const dataURL = await generateVoucherDataURL({
|
||
productName,
|
||
productSKU: sku,
|
||
roleName: cleanRole && cleanRole !== '-' ? cleanRole : '',
|
||
roleUID: record.expected_uid || record.actual_role_id || '-',
|
||
channel,
|
||
currencyText: '幸运币 x 10',
|
||
redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()),
|
||
})
|
||
|
||
const copied = await copyVoucherDataUrlToClipboard(dataURL)
|
||
if (copied) {
|
||
message.success('发货结果图片已复制到剪贴板!可直接在微信/QQ聊天框粘贴 (Ctrl+V)')
|
||
} else {
|
||
setPreviewVoucherUrl(dataURL)
|
||
setPreviewVoucherOpen(true)
|
||
message.info('已生成凭据图片,已为您打开预览窗口,支持直接保存或右键复制')
|
||
}
|
||
} catch (e) {
|
||
message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||
} finally {
|
||
setCopyingVoucherId(null)
|
||
}
|
||
}
|
||
|
||
const handlePreviewVoucher = async (record: RockTokenItem) => {
|
||
try {
|
||
const isRockBlue = record.target_skin?.includes('蓝')
|
||
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
|
||
const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤')
|
||
const channel = record.actual_channel || record.region || '安卓-微信'
|
||
const timeStr = record.redeemed_at || record.bound_at || record.created_at
|
||
const cleanRole = (record.actual_role_name || '').trim()
|
||
|
||
const dataURL = await generateVoucherDataURL({
|
||
productName,
|
||
productSKU: sku,
|
||
roleName: cleanRole && cleanRole !== '-' ? cleanRole : '',
|
||
roleUID: record.expected_uid || record.actual_role_id || '-',
|
||
channel,
|
||
currencyText: '幸运币 x 10',
|
||
redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()),
|
||
})
|
||
|
||
setPreviewVoucherUrl(dataURL)
|
||
setPreviewVoucherOpen(true)
|
||
} catch (e) {
|
||
message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||
}
|
||
}
|
||
|
||
const columns = buildTokenColumns({
|
||
copyingVoucherId,
|
||
usingWarnDays: 3,
|
||
usingDangerDays: 5,
|
||
boundWarnDays: 2,
|
||
readOnly: true,
|
||
refreshingTokenId: null,
|
||
onRefreshBinding: () => {},
|
||
openingUpstreamId,
|
||
onOpenUpstream: handleOpenUpstream,
|
||
onCopyVoucher: handleCopyVoucher,
|
||
onPreviewVoucher: handlePreviewVoucher,
|
||
onEditUid: () => {},
|
||
onViewRawData: () => {},
|
||
onResetToken: () => {},
|
||
onReleaseToken: () => {},
|
||
onViewStatusLogs: setStatusLogToken,
|
||
onUpdateRemark: () => {},
|
||
onMarkAbnormal: () => {},
|
||
})
|
||
|
||
return (
|
||
<div style={{ padding: '0 4px' }}>
|
||
<PageHeader
|
||
title="客服查询 · 绑定中 Token"
|
||
subtitle="仅查询绑定中的 Token(status=bound),只读;请输入查询条件后查询,默认不加载数据。"
|
||
extra={searched ? <Tag color="blue">共 {total} 条</Tag> : undefined}
|
||
/>
|
||
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="仅显示绑定中的 Token"
|
||
description="不显示可用、使用中、已发货、绑定错误、已上报失效等状态;请输入查询条件后查询,默认不加载数据。备注、凭证与状态历史均为只读。"
|
||
/>
|
||
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 16,
|
||
}}
|
||
>
|
||
<Input
|
||
allowClear
|
||
value={search}
|
||
onChange={(e) => handleSearchChange(e.target.value)}
|
||
onPressEnter={handleSearch}
|
||
placeholder="包ID / 上游编号 / 提取码 / 兑换码 / 工单号 / 商户单号 / 角色 ID / 角色名"
|
||
style={{ flex: '1 1 360px', minWidth: 0 }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SearchOutlined />}
|
||
onClick={handleSearch}
|
||
disabled={!search.trim()}
|
||
loading={loading}
|
||
>
|
||
查询
|
||
</Button>
|
||
</div>
|
||
|
||
{!searched ? (
|
||
<div
|
||
style={{
|
||
padding: '72px 16px',
|
||
background: '#f8fafc',
|
||
border: '1px dashed #cbd5e1',
|
||
borderRadius: 8,
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<Empty description="请输入包ID、上游编号、提取码、兑换码、工单号、商户单号或角色 ID 后查询" />
|
||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 8 }}>
|
||
为避免一次展示过多数据,本页面不会默认加载绑定中的 Token 列表。
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Table
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={tokens}
|
||
loading={loading}
|
||
scroll={{ x: 1100 }}
|
||
locale={{ emptyText: '没有找到绑定中的 Token' }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: ['10', '20', '50', '100'],
|
||
showTotal: (count) => `共 ${count} 条绑定中 Token`,
|
||
onChange: handlePageChange,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<TokenStatusLogDrawer
|
||
open={Boolean(statusLogToken)}
|
||
token={statusLogToken}
|
||
onClose={() => setStatusLogToken(null)}
|
||
loadLogs={loadStatusLogs}
|
||
/>
|
||
|
||
<VoucherPreviewModal
|
||
open={previewVoucherOpen}
|
||
previewVoucherUrl={previewVoucherUrl}
|
||
onClose={() => setPreviewVoucherOpen(false)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|