feat(chat): 新增主站二维码池管理页

This commit is contained in:
yml2213
2026-10-30 16:58:15 +08:00
parent fd95bff747
commit 8f0ec10e75
7 changed files with 763 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
# Phase 2 验收报告(主站二维码池)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:企业微信群二维码池的列表、统计、新增、编辑、单删与批删
> 结论:**页面与路由已落地,tsc/build 通过,只读接口回归 200;写操作未在验收中执行**
---
## 1. 页面与路由
- 页面:`frontend/src/features/admin/AdminQrCodePoolPage.tsx`
- 路由:`/console-jy8k3m/qrcode-pool`
- 权限:查看 `qrcode:view`,增删改 `qrcode:manage`
- 菜单:`frontend/src/layouts/AdminLayout.tsx` 客服组新增「二维码池」,`sectionMeta` 同步
## 2. 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/admin/chats/qrcodes` | 列表(`status`/`keyword`/`bound`/`expire_status`/`wecom_renamed`/`page`/`limit`) |
| GET | `/api/admin/chats/qrcodes/stats` | 未使用 / 已使用 / 已停用 / 总数 |
| POST | `/api/admin/chats/qrcodes` | 新增 |
| PATCH | `/api/admin/chats/qrcodes/:id` | 编辑(群名、备注、状态、企微改名、过期时间) |
| DELETE | `/api/admin/chats/qrcodes/:id` | 单条删除 |
| POST | `/api/admin/chats/qrcodes/batch-delete` | 批量删除(最多 100) |
> 注意:该组接口返回 `{ data: ... }` 结构(非 `code/message/data`),前端按 `{ data }` 解包,与其他后台接口不同。
## 3. 能力
- 顶部四项统计直接来自 `stats` 接口。
- 筛选:关键字(群名/备注)、状态、绑定情况、过期状态、企微改名。
- 列表:二维码缩略图、群名与备注、状态与「企微已改名」标签、绑定会话、发放/创建时间、过期时间。
- 新增:上传图片(`POST /api/files/upload`,scene `qrcode`)、群名称、备注、过期时间(留空后端默认 7 天)。
- 编辑:群名称、备注、状态、企微改名开关、过期时间;清空时间时提交 `clear_expires_at: true`。
- 批量删除:勾选后二次确认,调用 `batch-delete` 并提示实际删除数量。
## 4. 回归结果
```text
前端 http://localhost:5289
后端 http://127.0.0.1:18089
Cookie /tmp/jysh-admin-cookie2.txt
```
| 检查项 | 状态 |
|---|---|
| `GET /console-jy8k3m/qrcode-pool` | 200 |
| `GET /api/admin/chats/qrcodes?page=1&limit=5` | 200 |
| `GET /api/admin/chats/qrcodes/stats` | 200 |
`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `499.16 kB` / gzip `157.08 kB`(接近 500 kB 阈值,后续新增页面需关注拆包)。
## 5. 未覆盖
- 上传、新增、编辑、删除与批删未执行真实写操作(开发库二维码池为空)。
- OCR 识别群名(`/chats/qrcodes/ocr-group-name`)依赖 PaddleOCR Token,未接入页面。
- 未实现「批量添加」(`/chats/qrcodes/batch`)的多图批量录入界面。
+1 -1
View File
@@ -414,7 +414,7 @@ hfb_jysh/
- 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。
- 申诉仲裁、手工订单、提号、客服群、快捷回复。
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`),线下提号(`/pickup`、`/pickup/:id`,权限 `order:pickup`,口径统一为「线下提号」)。
- 待落地:主站二维码池 `/qrcode-pool`、自动欢迎与商品群欢迎配置。
- 待落地:自动欢迎与商品群欢迎配置、二维码池 OCR 与批量导入。
- 分销站点、规则、订单、结算、二维码池、分销用户、公告。
- 公告、邀请返利、推送、追踪链接。
- 已落地:公告管理、邀请返利、推广追踪(`/tracking-links`,权限 `ad_tracking:manage`)。
+6
View File
@@ -246,6 +246,11 @@ const AdminChatsPage = lazy(() =>
default: module.AdminChatsPage,
}))
)
const AdminQrCodePoolPage = lazy(() =>
import('@/features/admin/AdminQrCodePoolPage').then(module => ({
default: module.AdminQrCodePoolPage,
}))
)
const AdminPickupDetailPage = lazy(() =>
import('@/features/admin/AdminPickupDetailPage').then(module => ({
default: module.AdminPickupDetailPage,
@@ -414,6 +419,7 @@ export function AppRouter() {
<Route path="pickup" element={<AdminPickupsPage />} />
<Route path="pickup/:id" element={<AdminPickupDetailPage />} />
<Route path="chats" element={<AdminChatsPage />} />
<Route path="qrcode-pool" element={<AdminQrCodePoolPage />} />
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
@@ -0,0 +1,580 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
DatePicker,
Form,
Image,
Input,
Modal,
Select,
Space,
Switch,
Tag,
Typography,
Upload,
type TableColumnsType,
} from 'antd'
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons'
import dayjs, { type Dayjs } from 'dayjs'
import {
batchDeleteAdminQrCodes,
createAdminQrCode,
deleteAdminQrCode,
fetchAdminQrCodeStats,
fetchAdminQrCodes,
updateAdminQrCode,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import type { AdminQrCode } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { uploadFile } from '@/shared/api/files'
import { formatDateTime, formatText } from '@/shared/utils/format'
interface Filters {
status?: string
keyword: string
bound?: string
expire_status?: string
wecom_renamed?: string
}
interface CreateFormValues {
group_name?: string
note?: string
expires_at?: Dayjs | null
}
const emptyFilters: Filters = { keyword: '' }
const statusMeta: Record<string, { label: string; color: string }> = {
unused: { label: '未使用', color: 'green' },
used: { label: '已使用', color: 'blue' },
disabled: { label: '已停用', color: 'default' },
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string; error?: string } } })?.response
if (response?.data?.message) return response.data.message
if (response?.data?.error) return response.data.error
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
export function AdminQrCodePoolPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [createForm] = Form.useForm<CreateFormValues>()
const [editForm] = Form.useForm<{
group_name?: string
note?: string
status: string
wecom_renamed: boolean
expires_at?: Dayjs | null
}>()
const [filters, setFilters] = useState<Filters>(emptyFilters)
const [applied, setApplied] = useState<Filters>(emptyFilters)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<AdminQrCode>()
const [imageURL, setImageURL] = useState('')
const [uploading, setUploading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('qrcode:view'))
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('qrcode:manage'))
const query = useMemo(
() => ({
status: applied.status,
keyword: applied.keyword.trim() || undefined,
bound: applied.bound,
expire_status: applied.expire_status,
wecom_renamed: applied.wecom_renamed,
page,
limit,
}),
[applied, page, limit]
)
const qrcodesQuery = useQuery({
queryKey: ['admin-qrcodes', query],
queryFn: () => fetchAdminQrCodes(query),
enabled: canView,
placeholderData: keepPreviousData,
})
const statsQuery = useQuery({
queryKey: ['admin-qrcode-stats'],
queryFn: fetchAdminQrCodeStats,
enabled: canView,
})
const stats = statsQuery.data
const rows = qrcodesQuery.data?.items ?? []
const invalidate = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-qrcodes'] }),
queryClient.invalidateQueries({ queryKey: ['admin-qrcode-stats'] }),
])
const openCreate = () => {
setImageURL('')
createForm.setFieldsValue({ group_name: '', note: '', expires_at: null })
setCreateOpen(true)
}
const submitCreate = async () => {
const values = await createForm.validateFields()
if (!imageURL) {
message.warning('请先上传二维码图片')
return
}
setSubmitting(true)
try {
await createAdminQrCode({
image_url: imageURL,
group_name: values.group_name?.trim(),
note: values.note?.trim(),
expires_at: values.expires_at ? values.expires_at.toISOString() : null,
})
message.success('二维码已加入池')
setCreateOpen(false)
await invalidate()
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const openEdit = (row: AdminQrCode) => {
setEditing(row)
editForm.setFieldsValue({
group_name: row.group_name,
note: row.note,
status: row.status,
wecom_renamed: row.wecom_renamed,
expires_at: row.expires_at ? dayjs(row.expires_at) : null,
})
}
const submitEdit = async () => {
if (!editing) return
const values = await editForm.validateFields()
setSubmitting(true)
try {
await updateAdminQrCode(editing.id, {
group_name: values.group_name?.trim(),
note: values.note?.trim(),
status: values.status,
wecom_renamed: values.wecom_renamed,
expires_at: values.expires_at ? values.expires_at.toISOString() : null,
clear_expires_at: !values.expires_at,
})
message.success('二维码已更新')
setEditing(undefined)
await invalidate()
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const removeOne = (row: AdminQrCode) => {
modal.confirm({
title: `确认删除二维码 #${row.id}?`,
content: '删除后不可恢复;已发放给用户的图片不受影响。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminQrCode(row.id)
message.success('二维码已删除')
await invalidate()
} catch (error) {
message.error(errorText(error))
}
},
})
}
const batchRemove = () => {
if (selectedRowKeys.length === 0) return
modal.confirm({
title: `确认批量删除 ${selectedRowKeys.length} 张二维码?`,
content: '未使用与已停用的二维码会被直接删除,操作不可恢复。',
okText: '批量删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
const deleted = await batchDeleteAdminQrCodes(selectedRowKeys)
message.success(`已删除 ${deleted} 张二维码`)
setSelectedRowKeys([])
await invalidate()
} catch (error) {
message.error(errorText(error))
}
},
})
}
const upload = async (file: File) => {
setUploading(true)
try {
const uploaded = await uploadFile(file, 'qrcode')
setImageURL(uploaded.url)
message.success('图片已上传')
} catch (error) {
message.error(errorText(error))
} finally {
setUploading(false)
}
return false
}
const columns: TableColumnsType<AdminQrCode> = [
{
title: '二维码',
key: 'image',
width: 120,
render: (_, row) => (
<Image src={row.image_url} alt={`二维码 ${row.id}`} width={64} height={64} />
),
},
{
title: '群名 / 备注',
key: 'group',
width: 240,
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.group_name)}</strong>
<span className="admin-mono">{formatText(row.note)}</span>
</div>
),
},
{
title: '状态',
key: 'status',
width: 170,
render: (_, row) => {
const meta = statusMeta[row.status] ?? { label: row.status, color: 'default' }
return (
<Space size={4} wrap>
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
{row.wecom_renamed ? (
<Tag bordered={false} color="purple">
企微已改名
</Tag>
) : null}
</Space>
)
},
},
{
title: '绑定会话',
key: 'bound',
render: (_, row) => formatText(row.bound_conversation_title || row.conversation_id),
},
{
title: '发放 / 使用时间',
key: 'time',
width: 190,
render: (_, row) => (
<div className="admin-stack">
<span>{formatDateTime(row.used_at)}</span>
<span className="admin-mono">创建 {formatDateTime(row.created_at)}</span>
</div>
),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 170,
render: value => (value ? formatDateTime(value) : '—'),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canManage}
onClick={() => openEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canManage}
onClick={() => removeOne(row)}
>
删除
</Button>
</Space>
),
},
]
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 客服" title="二维码池" />
<Alert type="warning" showIcon message="当前账号没有二维码查看权限(qrcode:view)" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 客服"
title="二维码池"
description="维护企业微信群二维码,客服会话按未使用顺序自动发放。"
meta={
<Space size={8} wrap>
<Tag color="green">未使用 {stats?.unused_count ?? 0}</Tag>
<Tag color="blue">已使用 {stats?.used_count ?? 0}</Tag>
<Tag>已停用 {stats?.disabled_count ?? 0}</Tag>
<Tag color="geekblue">总数 {stats?.total_count ?? 0}</Tag>
</Space>
}
extra={
<Space>
<Button
danger
icon={<DeleteOutlined />}
disabled={!canManage || selectedRowKeys.length === 0}
onClick={batchRemove}
>
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
</Button>
<Button
icon={<ReloadOutlined />}
loading={qrcodesQuery.isFetching}
onClick={() => {
void qrcodesQuery.refetch()
void statsQuery.refetch()
}}
>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} disabled={!canManage} onClick={openCreate}>
添加二维码
</Button>
</Space>
}
/>
<FilterBar
loading={qrcodesQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}}
>
<FilterField label="关键字">
<Input
allowClear
value={filters.keyword}
placeholder="群名 / 备注"
onChange={event =>
setFilters(current => ({ ...current, keyword: event.target.value }))
}
/>
</FilterField>
<FilterField label="状态">
<Select
allowClear
value={filters.status}
placeholder="全部状态"
options={[
{ value: 'unused', label: '未使用' },
{ value: 'used', label: '已使用' },
{ value: 'disabled', label: '已停用' },
]}
onChange={value => setFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="绑定情况">
<Select
allowClear
value={filters.bound}
placeholder="全部"
options={[
{ value: 'bound', label: '已绑定会话' },
{ value: 'unbound', label: '未绑定' },
]}
onChange={value => setFilters(current => ({ ...current, bound: value }))}
/>
</FilterField>
<FilterField label="过期状态">
<Select
allowClear
value={filters.expire_status}
placeholder="全部"
options={[
{ value: 'expired', label: '已过期' },
{ value: 'valid', label: '有效' },
]}
onChange={value => setFilters(current => ({ ...current, expire_status: value }))}
/>
</FilterField>
<FilterField label="企微改名">
<Select
allowClear
value={filters.wecom_renamed}
placeholder="全部"
options={[
{ value: 'true', label: '已改名' },
{ value: 'false', label: '未改名' },
]}
onChange={value => setFilters(current => ({ ...current, wecom_renamed: value }))}
/>
</FilterField>
</FilterBar>
{qrcodesQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="二维码列表加载失败"
description={errorText(qrcodesQuery.error)}
/>
) : null}
<DataTable<AdminQrCode>
columns={columns}
dataSource={rows}
rowKey="id"
loading={qrcodesQuery.isFetching}
total={qrcodesQuery.data?.total ?? 0}
page={qrcodesQuery.data?.page ?? page}
pageSize={qrcodesQuery.data?.page_size ?? limit}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setLimit(nextSize)
}}
rowSelection={{
selectedRowKeys,
onChange: keys => setSelectedRowKeys(keys as number[]),
getCheckboxProps: () => ({ disabled: !canManage }),
}}
emptyDescription="二维码池为空,请先上传群二维码"
/>
<Modal
open={createOpen}
title="添加群二维码"
okText="加入池"
cancelText="取消"
confirmLoading={submitting}
onOk={() => void submitCreate()}
onCancel={() => (submitting ? undefined : setCreateOpen(false))}
maskClosable={!submitting}
>
<Form form={createForm} layout="vertical" requiredMark={false}>
<Form.Item label="二维码图片" required>
<Space align="start" size={12}>
{imageURL ? (
<Image src={imageURL} alt="二维码预览" width={96} height={96} />
) : null}
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={file => {
void upload(file as File)
return false
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
{imageURL ? '重新上传' : '上传图片'}
</Button>
</Upload>
</Space>
</Form.Item>
<Form.Item name="group_name" label="群名称">
<Input placeholder="企业微信群名称" maxLength={128} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input placeholder="用于内部识别,例如批次/客服" maxLength={255} />
</Form.Item>
<Form.Item name="expires_at" label="过期时间" extra="留空默认 7 天后过期">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
open={Boolean(editing)}
title={editing ? `编辑二维码 #${editing.id}` : '编辑二维码'}
okText="保存"
cancelText="取消"
confirmLoading={submitting}
onOk={() => void submitEdit()}
onCancel={() => (submitting ? undefined : setEditing(undefined))}
maskClosable={!submitting}
>
<Form form={editForm} layout="vertical" requiredMark={false}>
<Form.Item name="group_name" label="群名称">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input maxLength={255} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'unused', label: '未使用' },
{ value: 'used', label: '已使用' },
{ value: 'disabled', label: '已停用' },
]}
/>
</Form.Item>
<Form.Item name="wecom_renamed" label="企微已改名" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="expires_at" label="过期时间">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
共 {stats?.total_count ?? 0} 张二维码;停用的二维码不会自动发放。
</Typography.Paragraph>
</div>
)
}
+64
View File
@@ -115,6 +115,11 @@ import type {
AdminChatMessage,
AdminChatMessagePage,
AdminChatPage,
AdminQrCode,
AdminQrCodePage,
AdminQrCodePayload,
AdminQrCodeStats,
AdminQrCodeUpdatePayload,
ManualDisbursementPayload,
OperatingExpensePage,
OperatingExpensePayload,
@@ -1640,5 +1645,64 @@ export async function fetchAdminChatSupportAdmins() {
return data.data ?? []
}
export async function fetchAdminQrCodes(query: {
status?: string
keyword?: string
wecom_renamed?: string
bound?: string
expire_status?: string
page?: number
limit?: number
}) {
const { data } = await adminClient.get<{ data: Partial<AdminQrCodePage> }>(
'/admin/chats/qrcodes',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.limit ?? 20,
}
}
export async function fetchAdminQrCodeStats() {
const { data } = await adminClient.get<{ data: AdminQrCodeStats }>('/admin/chats/qrcodes/stats')
return data.data
}
export async function createAdminQrCode(payload: AdminQrCodePayload) {
const { data } = await adminClient.post<{ data: AdminQrCode }>('/admin/chats/qrcodes', payload)
return data.data
}
export async function batchCreateAdminQrCodes(items: AdminQrCodePayload[]) {
const { data } = await adminClient.post<{ data: AdminQrCode[] }>(
'/admin/chats/qrcodes/batch',
{ items }
)
return data.data ?? []
}
export async function batchDeleteAdminQrCodes(ids: number[]) {
const { data } = await adminClient.post<{ data: { deleted: number } }>(
'/admin/chats/qrcodes/batch-delete',
{ ids }
)
return data.data.deleted ?? 0
}
export async function updateAdminQrCode(id: number, payload: AdminQrCodeUpdatePayload) {
const { data } = await adminClient.patch<{ data: AdminQrCode }>(
`/admin/chats/qrcodes/${id}`,
payload
)
return data.data
}
export async function deleteAdminQrCode(id: number) {
await adminClient.delete(`/admin/chats/qrcodes/${id}`)
}
+48
View File
@@ -827,6 +827,54 @@ export interface AdminChatCounts {
stages: Record<string, number>
}
export interface AdminQrCode {
id: number
image_url: string
group_name: string
distribution_site_id?: number | null
status: string
conversation_id?: number | null
used_at?: string | null
expires_at?: string | null
created_by: number
note: string
wecom_renamed: boolean
created_at: string
updated_at: string
bound_conversation_title?: string
}
export interface AdminQrCodePage {
items: AdminQrCode[]
total: number
page: number
page_size: number
}
export interface AdminQrCodeStats {
unused_count: number
used_count: number
disabled_count: number
total_count: number
}
export interface AdminQrCodePayload {
image_url: string
group_name?: string
note?: string
expires_at?: string | null
}
export interface AdminQrCodeUpdatePayload {
image_url?: string
group_name?: string
note?: string
status?: string
wecom_renamed?: boolean
expires_at?: string | null
clear_expires_at?: boolean
}
export interface AdminAnnouncementQuery {
category?: string
status?: string
+5
View File
@@ -143,6 +143,7 @@ const allNavGroups: NavGroup[] = [
children: [
{ key: '/chat-support-groups', label: '客服群组', permission: 'chat:manage' },
{ key: '/chats', label: '客服会话', permission: 'chat:view' },
{ key: '/qrcode-pool', label: '二维码池', permission: 'qrcode:view' },
{ key: '/manual-entries', label: '手工录单', permission: 'cs_manual_entry:view' },
{ key: '/chat-quick-replies', label: '快捷回复', permission: 'chat:view' },
],
@@ -212,6 +213,10 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
title: '客服会话',
description: '按阶段筛选会话、回复用户并转接客服。',
},
'/qrcode-pool': {
title: '二维码池',
description: '维护企业微信群二维码,按未使用顺序自动发放。',
},
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
'/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' },
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },