feat(chat): 新增主站二维码池管理页
This commit is contained in:
@@ -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
@@ -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`)。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '查看高风险操作与变更记录。' },
|
||||
|
||||
Reference in New Issue
Block a user