feat: 新增平台客服角色与客服查询菜单

This commit is contained in:
2026-11-09 18:18:19 +08:00
parent 38cbb37a08
commit 08884270b7
23 changed files with 1075 additions and 106 deletions
+23 -1
View File
@@ -23,6 +23,7 @@ const PlatformOrders = lazy(() => import('./pages/PlatformOrders'))
const PlatformProducts = lazy(() => import('./pages/PlatformProducts'))
const PlatformUsers = lazy(() => import('./pages/PlatformUsers'))
const PlatformRock = lazy(() => import('./pages/PlatformRock'))
const SupportRockTokens = lazy(() => import('./pages/SupportRockTokens'))
// RouteFallback 是懒加载页面与权限校验的统一占位,避免切换时白屏。
function RouteFallback() {
@@ -59,8 +60,21 @@ function MerchantPermissionRoute({ permission, children }: { permission?: string
return merchantPermissions.includes(permission) ? <>{children}</> : <Navigate to="/" replace />
}
function SupportQueryRoute({ children }: { children: ReactNode }) {
const { isAdmin, isPlatformSupport } = useAuth()
return isAdmin || isPlatformSupport ? <>{children}</> : <Navigate to="/" replace />
}
function HomeRoute() {
const { isPlatformSupport } = useAuth()
if (isPlatformSupport) return <Navigate to="/support-rock-tokens" replace />
return <Dashboard />
}
function MerchantCenterRoute() {
const { isPlatformSupport } = useAuth()
const location = useLocation()
if (isPlatformSupport) return <Navigate to="/merchant-orders" replace />
const requestedTab = new URLSearchParams(location.search).get('tab') || 'products'
const tab = requestedTab in tabPermissions ? requestedTab : 'products'
return (
@@ -83,7 +97,7 @@ function AppRoutes() {
</PrivateRoute>
}
>
<Route index element={<Dashboard />} />
<Route index element={<HomeRoute />} />
<Route path="merchant-center" element={<MerchantCenterRoute />} />
<Route path="merchant-products" element={<MerchantPermissionRoute permission="products:manage"><MerchantCenter fixedTab="products" title="商品列表" /></MerchantPermissionRoute>} />
<Route path="merchant-orders" element={<MerchantPermissionRoute permission="orders:manage"><MerchantCenter fixedTab="orders" title="发货订单" /></MerchantPermissionRoute>} />
@@ -125,6 +139,14 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="support-rock-tokens"
element={
<SupportQueryRoute>
<SupportRockTokens />
</SupportQueryRoute>
}
/>
<Route path="platform-users" element={<AdminRoute><PlatformUsers /></AdminRoute>} />
<Route path="open-api" element={<MerchantPermissionRoute permission="api:manage"><OpenApiDocs /></MerchantPermissionRoute>} />
<Route
+27 -6
View File
@@ -99,15 +99,20 @@ export const userApi = {
request
.get("/platform/admins", { params })
.then((r) => r.data.data as PageResult<User>),
create: (data: { username: string; password: string; nickname?: string }) =>
request.post("/platform/admins", data).then((r) => r.data.data as User),
create: (data: {
username: string
password: string
nickname?: string
role?: 'admin' | 'platform_support'
}) => request.post("/platform/admins", data).then((r) => r.data.data as User),
update: (
id: number,
data: {
username: string;
nickname?: string;
password?: string;
status: number;
username: string
nickname?: string
password?: string
status: number
role?: 'admin' | 'platform_support'
},
) =>
request
@@ -893,6 +898,22 @@ export const rockApi = {
request
.post("/platform/rock/token-batches/refresh-stale")
.then((r) => r.data.data as RockStaleRefreshResult),
listSupportBoundTokens: (params: { page?: number; page_size?: number; search: string }) =>
request
.get("/platform/rock/support/bound-tokens", { params })
.then(
(r) =>
r.data.data as {
list: RockTokenItem[];
total: number;
page: number;
page_size: number;
},
),
listSupportTokenStatusLogs: (id: number) =>
request
.get(`/platform/rock/support/tokens/${id}/status-logs`)
.then((r) => r.data.data as { list: RockTokenStatusLog[] }),
previewTokenDelete: (params?: { status?: string; search?: string }) =>
request
.get("/platform/rock/token-batches/preview", { params })
+30 -6
View File
@@ -70,6 +70,7 @@ const adminSections: SidebarSection[] = [
{ key: 'merchant-products', label: '商品列表', path: '/merchant-products' },
{ key: 'platform-products', label: '渠道与路由配置', path: '/platform-products' },
{ key: 'platform-rock', label: 'Rock发货与库存风控', path: '/platform-rock' },
{ key: 'support-rock', label: '客服查询', path: '/support-rock-tokens' },
],
},
{
@@ -122,6 +123,21 @@ const adminSections: SidebarSection[] = [
},
]
const platformSupportSections: SidebarSection[] = [
{
key: 'products',
label: '商品管理',
icon: <AppstoreOutlined />,
children: [{ key: 'support-rock', label: '客服查询', path: '/support-rock-tokens' }],
},
{
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
},
]
const merchantSections: SidebarSection[] = [
{
key: 'dashboard',
@@ -174,6 +190,7 @@ function getSelectedKey(pathname: string, search: string) {
if (pathname.startsWith('/merchant-products')) return 'merchant-products'
if (pathname.startsWith('/platform-products')) return 'platform-products'
if (pathname.startsWith('/platform-rock')) return 'platform-rock'
if (pathname.startsWith('/support-rock-tokens')) return 'support-rock'
if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders'
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge'
@@ -228,13 +245,13 @@ export default function MainLayout() {
const [collapsed, setCollapsed] = useState<boolean>(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
})
const { user, logout, refreshProfile, isAdmin, merchant, merchantPermissions } = useAuth()
const { user, logout, refreshProfile, isAdmin, isPlatformSupport, merchant, merchantPermissions } = useAuth()
useEffect(() => {
// 管理员始终保持平台全局视角,清除任何残留的商户上下文
if (isAdmin && localStorage.getItem('merchant_id')) {
// 平台角色保持全局视角,清除任何残留的商户上下文
if ((isAdmin || isPlatformSupport) && localStorage.getItem('merchant_id')) {
localStorage.removeItem('merchant_id')
}
}, [isAdmin])
}, [isAdmin, isPlatformSupport])
const [passwordOpen, setPasswordOpen] = useState(false)
const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordForm] = Form.useForm()
@@ -244,6 +261,7 @@ export default function MainLayout() {
const sections = useMemo(() => {
if (isAdmin) return adminSections
if (isPlatformSupport) return platformSupportSections
const features = new Set((merchant?.features || '').split(/[\s,]+/).filter(Boolean))
return merchantSections.flatMap((section) => {
if (!section.children) return [section]
@@ -253,7 +271,7 @@ export default function MainLayout() {
))
return children.length > 0 ? [{ ...section, children }] : []
})
}, [isAdmin, merchant?.features, merchantPermissions])
}, [isAdmin, isPlatformSupport, merchant?.features, merchantPermissions])
const selectedKey = getSelectedKey(location.pathname, location.search)
const [openKeys, setOpenKeys] = useState<string[]>(() => loadStoredOpenKeys(sections, selectedKey))
@@ -324,7 +342,9 @@ export default function MainLayout() {
label: (
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 600, color: '#0f172a' }}>{user?.nickname || user?.username}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>角色:{isAdmin ? '平台超级管理员' : '商户成员'}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>
角色:{isAdmin ? '平台超级管理员' : isPlatformSupport ? '平台客服' : '商户成员'}
</div>
</div>
),
},
@@ -459,6 +479,10 @@ export default function MainLayout() {
<Tag color="blue" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
管理员
</Tag>
) : isPlatformSupport ? (
<Tag color="purple" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
平台客服
</Tag>
) : (
<Tag color="cyan" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
商户
+4 -2
View File
@@ -60,7 +60,7 @@ export default function MerchantCenter({
fixedTab,
title = "商户中心",
}: MerchantCenterProps = {}) {
const { isAdmin, user } = useAuth();
const { isAdmin, isPlatformSupport, user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const routeTab = useMemo(
@@ -893,7 +893,9 @@ export default function MerchantCenter({
title={title}
subtitle={
merchant
? `${merchant.name} (${merchant.code}) · 当前身份: ${merchantRole ? roleText(merchantRole) : "-"}`
? isPlatformSupport
? `${merchant.name} (${merchant.code}) · 平台客服`
: `${merchant.name} (${merchant.code}) · 当前身份: ${merchantRole ? roleText(merchantRole) : "-"}`
: "商户配置与管理中心"
}
breadcrumbs={computedBreadcrumbs}
+48 -13
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tabs, Tag, Typography, message } from 'antd'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography, message } from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { platformApi, userApi } from '../api'
@@ -15,6 +15,16 @@ type AdminFormValues = {
nickname?: string
password?: string
status: boolean
role: 'admin' | 'platform_support'
}
const platformRoleOptions = [
{ value: 'admin', label: '平台管理员' },
{ value: 'platform_support', label: '平台客服' },
]
function platformRoleLabel(role: string) {
return platformRoleOptions.find((item) => item.value === role)?.label || role
}
function maskPhone(value?: string) {
@@ -69,13 +79,19 @@ export default function PlatformUsers() {
const openCreate = () => {
setEditingAdmin(null)
form.setFieldsValue({ username: '', nickname: '', password: '', status: true })
form.setFieldsValue({ username: '', nickname: '', password: '', status: true, role: 'admin' })
setModalOpen(true)
}
const openEdit = useCallback((record: User) => {
setEditingAdmin(record)
form.setFieldsValue({ username: record.username, nickname: record.nickname, password: '', status: record.status === 1 })
form.setFieldsValue({
username: record.username,
nickname: record.nickname,
password: '',
status: record.status === 1,
role: record.role === 'platform_support' ? 'platform_support' : 'admin',
})
setModalOpen(true)
}, [form])
@@ -89,11 +105,17 @@ export default function PlatformUsers() {
nickname: values.nickname || '',
password: values.password || undefined,
status: values.status ? 1 : 0,
role: values.role,
})
message.success('平台管理员已更新')
message.success('平台账号已更新,角色变更需该账号重新登录生效')
} else {
await userApi.create({ username: values.username, password: values.password || '', nickname: values.nickname || '' })
message.success('平台管理员已创建')
await userApi.create({
username: values.username,
password: values.password || '',
nickname: values.nickname || '',
role: values.role,
})
message.success('平台账号已创建')
}
setModalOpen(false)
form.resetFields()
@@ -108,7 +130,7 @@ export default function PlatformUsers() {
const remove = useCallback(async (record: User) => {
try {
await userApi.remove(record.id)
message.success('平台管理员已删除')
message.success('平台账号已删除')
void loadAdmins()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
@@ -206,6 +228,16 @@ export default function PlatformUsers() {
const columns: ColumnsType<User> = useMemo(() => [
{ title: '用户名', dataIndex: 'username', width: 220, render: (value) => <Typography.Text strong>{value}</Typography.Text> },
{ title: '昵称', dataIndex: 'nickname', width: 180, render: (value) => value || '-' },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (role) => (
<Tag color={role === 'platform_support' ? 'purple' : 'blue'}>
{platformRoleLabel(role)}
</Tag>
),
},
{ title: '手机号', width: 170, render: (_, record) => phoneCell(record) },
{ title: '绑定设备', width: 200, render: (_, record) => deviceCell(record) },
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => value === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag> },
@@ -216,7 +248,7 @@ export default function PlatformUsers() {
render: (_, record) => record.id === currentUser?.id ? <Typography.Text type="secondary">当前账号</Typography.Text> : (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
<Popconfirm title={`确认删除平台管理员「${record.username}」?`} description="删除后无法登录平台,且不会影响任何商户员工。" onConfirm={() => remove(record)} okButtonProps={{ danger: true }}>
<Popconfirm title={`确认删除平台账号「${record.username}」?`} description="删除后无法登录平台;平台客服的订单与客服查询权限会同时失效。" onConfirm={() => remove(record)} okButtonProps={{ danger: true }}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
@@ -281,7 +313,7 @@ export default function PlatformUsers() {
const items = useMemo(() => [
{
key: 'admins',
label: `平台管理员 (${admins.total})`,
label: `平台账号 (${admins.total})`,
children: <Table
rowKey="id"
loading={loading}
@@ -311,13 +343,13 @@ export default function PlatformUsers() {
<div>
<PageHeader
title="账号管理"
subtitle="平台管理员独立管理;商户账号按所属商户展示,下游员工由各商户自行维护。"
subtitle="平台管理员与平台客服统一管理;平台客服仅可操作自营商户发货订单与查询全部绑定中 Token。"
breadcrumbs={[{ title: '账号管理' }]}
extra={<Space><Button icon={<ReloadOutlined />} loading={loading} onClick={() => tab === 'admins' ? void loadAdmins() : void loadMerchantAccounts()}>刷新</Button>{tab === 'admins' && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建平台管理员</Button>}</Space>}
extra={<Space><Button icon={<ReloadOutlined />} loading={loading} onClick={() => tab === 'admins' ? void loadAdmins() : void loadMerchantAccounts()}>刷新</Button>{tab === 'admins' && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建平台账号</Button>}</Space>}
/>
<Tabs activeKey={tab} onChange={setTab} items={items} />
<Modal
title={editingAdmin ? '编辑平台管理员' : '新建平台管理员'}
title={editingAdmin ? '编辑平台账号' : '新建平台账号'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
@@ -325,13 +357,16 @@ export default function PlatformUsers() {
okText="保存"
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false} initialValues={{ status: true }}>
<Form form={form} layout="vertical" preserve={false} initialValues={{ status: true, role: 'admin' }}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }, { max: 64, message: '用户名不能超过 64 个字符' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="昵称" name="nickname" rules={[{ max: 64, message: '昵称不能超过 64 个字符' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]} extra={editingAdmin ? '角色变更后,该账号需重新登录生效' : undefined}>
<Select options={platformRoleOptions} />
</Form.Item>
<Form.Item label={editingAdmin ? '新密码' : '密码'} name="password" rules={editingAdmin ? [{ min: 6, message: '密码至少 6 位' }] : [{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少 6 位' }]} extra={editingAdmin ? '留空表示不修改密码' : undefined}>
<Input.Password autoComplete="new-password" />
</Form.Item>
+286
View File
@@ -0,0 +1,286 @@
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>
)
}
@@ -11,6 +11,7 @@ export interface TokenStatusLogDrawerProps {
open: boolean
token: RockTokenItem | null
onClose: () => void
loadLogs?: (tokenId: number) => Promise<RockTokenStatusLog[]>
}
const EVENT_META: Record<string, { label: string; color: string }> = {
@@ -66,6 +67,7 @@ export function TokenStatusLogDrawer({
open,
token,
onClose,
loadLogs,
}: TokenStatusLogDrawerProps) {
const screens = Grid.useBreakpoint()
const isMobile = !screens.md
@@ -78,14 +80,16 @@ export function TokenStatusLogDrawer({
setLoading(true)
setError('')
try {
const res = await rockApi.listTokenStatusLogs(token.id)
setLogs(res.list || [])
const list = loadLogs
? await loadLogs(token.id)
: (await rockApi.listTokenStatusLogs(token.id)).list || []
setLogs(list)
} catch (e) {
setError(e instanceof Error ? e.message : '加载状态历史失败')
} finally {
setLoading(false)
}
}, [token])
}, [token, loadLogs])
useEffect(() => {
if (!open || !token) return
@@ -5,6 +5,7 @@ import {
EyeOutlined,
FileTextOutlined,
HistoryOutlined,
MessageOutlined,
StopOutlined,
SyncOutlined,
UndoOutlined,
@@ -80,6 +81,7 @@ export interface TokenColumnHandlers {
usingWarnDays: number
usingDangerDays: number
boundWarnDays: number
readOnly?: boolean
refreshingTokenId: number | null
onRefreshBinding: (id: number) => void
openingUpstreamId: number | null
@@ -100,6 +102,7 @@ export function buildTokenColumns({
usingWarnDays,
usingDangerDays,
boundWarnDays,
readOnly,
refreshingTokenId,
onRefreshBinding,
openingUpstreamId,
@@ -362,14 +365,31 @@ export function buildTokenColumns({
title: '备注',
width: 44,
align: 'center',
render: (_, record) => (
<TokenRemarkCell
record={record}
onUpdateRemark={onUpdateRemark}
onMarkAbnormal={onMarkAbnormal}
compact
/>
),
render: (_, record) =>
readOnly ? (
record.remark ? (
<Tooltip
title={
<div style={{ maxWidth: 280, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{record.remark}
</div>
}
>
<span style={{ display: 'inline-flex', cursor: 'default' }}>
<MessageOutlined style={{ color: '#1677ff', fontSize: 16 }} />
</span>
</Tooltip>
) : (
<Text type="secondary">-</Text>
)
) : (
<TokenRemarkCell
record={record}
onUpdateRemark={onUpdateRemark}
onMarkAbnormal={onMarkAbnormal}
compact
/>
),
},
{
title: '凭证',
@@ -448,24 +468,28 @@ export function buildTokenColumns({
)
},
},
{
title: '原始',
width: 44,
align: 'center',
render: (_, record) =>
record.raw_data ? (
<Tooltip title="查看原始数据">
<Button
size="small"
type="text"
icon={<FileTextOutlined />}
onClick={() => onViewRawData(record)}
/>
</Tooltip>
) : (
<Text type="secondary">-</Text>
),
},
...(readOnly
? []
: [
{
title: '原始',
width: 44,
align: 'center' as const,
render: (_: unknown, record: RockTokenItem) =>
record.raw_data ? (
<Tooltip title="查看原始数据">
<Button
size="small"
type="text"
icon={<FileTextOutlined />}
onClick={() => onViewRawData(record)}
/>
</Tooltip>
) : (
<Text type="secondary">-</Text>
),
},
]),
{
title: '操作',
key: 'action',
@@ -513,6 +537,14 @@ export function buildTokenColumns({
历史
</Button>
)
if (readOnly) {
return (
<Space size={4} wrap>
{upstreamButton}
{historyButton}
</Space>
)
}
if (record.status === 'mismatch') {
return (
<Space size={4} wrap>
+1
View File
@@ -104,6 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
merchantLoading,
refreshMerchantContext,
isAdmin: user?.role === 'admin',
isPlatformSupport: user?.role === 'platform_support',
}),
[token, user, login, applyLoginResult, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext],
)
+1
View File
@@ -14,6 +14,7 @@ export interface AuthState {
merchantLoading: boolean
refreshMerchantContext: () => Promise<void>
isAdmin: boolean
isPlatformSupport: boolean
}
// Context 与 useAuth 单独放在本文件:auth.tsx 因此只导出 AuthProvider 组件,
+1 -1
View File
@@ -2,7 +2,7 @@ export interface User {
id: number
username: string
nickname: string
role: 'admin' | 'merchant'
role: 'admin' | 'merchant' | 'platform_support'
status: number
created_at: string
phone?: string