feat: 新增平台客服角色与客服查询菜单
This commit is contained in:
+23
-1
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }}>
|
||||
商户
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface AuthState {
|
||||
merchantLoading: boolean
|
||||
refreshMerchantContext: () => Promise<void>
|
||||
isAdmin: boolean
|
||||
isPlatformSupport: boolean
|
||||
}
|
||||
|
||||
// Context 与 useAuth 单独放在本文件:auth.tsx 因此只导出 AuthProvider 组件,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user