Files
affiliate_dash/frontend/src/pages/SupportRockTokens.tsx
T

287 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}