feat(chat): 支持二维码批量导入与 OCR 识别,新增欢迎语配置页

This commit is contained in:
yml2213
2026-10-30 17:35:35 +08:00
parent de71e2a74a
commit f4fe2006db
5 changed files with 361 additions and 2 deletions
+6
View File
@@ -345,6 +345,11 @@ const AdminChatsPage = lazy(() =>
default: module.AdminChatsPage,
}))
)
const AdminChatWelcomePage = lazy(() =>
import('@/features/admin/AdminChatWelcomePage').then(module => ({
default: module.AdminChatWelcomePage,
}))
)
const AdminQrCodePoolPage = lazy(() =>
import('@/features/admin/AdminQrCodePoolPage').then(module => ({
default: module.AdminQrCodePoolPage,
@@ -554,6 +559,7 @@ export function AppRouter() {
<Route path="pickup" element={<AdminPickupsPage />} />
<Route path="pickup/:id" element={<AdminPickupDetailPage />} />
<Route path="chats" element={<AdminChatsPage />} />
<Route path="chat-welcome" element={<AdminChatWelcomePage />} />
<Route path="qrcode-pool" element={<AdminQrCodePoolPage />} />
<Route path="notifications" element={<AdminNotificationsPage />} />
<Route path="push" element={<AdminPushPage />} />
@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, App, Button, Card, Form, Input, Space, Tag, Typography } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import {
fetchChatAutoWelcome,
fetchListingGroupWelcome,
updateChatAutoWelcome,
updateListingGroupWelcome,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { useAdminSessionStore } from '@/stores/adminSession'
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
return response?.data?.message || (error instanceof Error ? error.message : '请求失败')
}
export function AdminChatWelcomePage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [autoForm] = Form.useForm<{ message: string }>()
const [groupForm] = Form.useForm<{ message: string }>()
const [saving, setSaving] = useState<'auto' | 'group' | null>(null)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('system_config:view'))
const canManage = Boolean(
permissions?.includes('*') || permissions?.includes('system_config:update')
)
const autoQuery = useQuery({
queryKey: ['chat-auto-welcome'],
queryFn: fetchChatAutoWelcome,
enabled: canView,
})
const groupQuery = useQuery({
queryKey: ['chat-listing-group-welcome'],
queryFn: fetchListingGroupWelcome,
enabled: canView,
})
useEffect(() => {
if (autoQuery.data != null) autoForm.setFieldsValue({ message: autoQuery.data })
}, [autoQuery.data, autoForm])
useEffect(() => {
if (groupQuery.data != null) groupForm.setFieldsValue({ message: groupQuery.data })
}, [groupQuery.data, groupForm])
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 客服" title="欢迎语配置" />
<Alert type="warning" showIcon message="当前账号没有系统配置查看权限" />
</div>
)
}
const save = async (kind: 'auto' | 'group') => {
const form = kind === 'auto' ? autoForm : groupForm
const values = await form.validateFields()
setSaving(kind)
try {
if (kind === 'auto') {
await updateChatAutoWelcome(values.message.trim())
await queryClient.invalidateQueries({ queryKey: ['chat-auto-welcome'] })
} else {
await updateListingGroupWelcome(values.message.trim())
await queryClient.invalidateQueries({ queryKey: ['chat-listing-group-welcome'] })
}
message.success('欢迎语已保存')
} catch (error) {
message.error(errorText(error))
} finally {
setSaving(null)
}
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 客服"
title="欢迎语配置"
description="维护订单群与账号群的自动欢迎语,客服建群后自动发送。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">订单群</Tag>
<Tag color="purple">账号群</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={autoQuery.isFetching || groupQuery.isFetching}
onClick={() => {
void autoQuery.refetch()
void groupQuery.refetch()
}}
>
刷新
</Button>
}
/>
<Card title="订单群欢迎语" className="admin-table-card">
<Form form={autoForm} layout="vertical" requiredMark={false}>
<Form.Item
name="message"
label="欢迎语"
rules={[{ required: true, message: '请输入欢迎语' }, { max: 500 }]}
extra="订单群建立后自动发送给租客与号主"
>
<Input.TextArea rows={4} maxLength={500} showCount disabled={!canManage} />
</Form.Item>
<Button
type="primary"
loading={saving === 'auto'}
disabled={!canManage}
onClick={() => void save('auto')}
>
保存订单群欢迎语
</Button>
</Form>
</Card>
<Card title="账号群欢迎语" className="admin-table-card" style={{ marginTop: 16 }}>
<Form form={groupForm} layout="vertical" requiredMark={false}>
<Form.Item
name="message"
label="欢迎语"
rules={[{ required: true, message: '请输入欢迎语' }, { max: 500 }]}
extra="账号群建立后自动发送,引导号主扫码进企业微信群"
>
<Input.TextArea rows={4} maxLength={500} showCount disabled={!canManage} />
</Form.Item>
<Button
type="primary"
loading={saving === 'group'}
disabled={!canManage}
onClick={() => void save('group')}
>
保存账号群欢迎语
</Button>
</Form>
</Card>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
欢迎语保存在系统配置中,修改后对新建立的群生效。
</Typography.Paragraph>
</div>
)
}
@@ -27,11 +27,13 @@ import {
import dayjs, { type Dayjs } from 'dayjs'
import {
batchCreateAdminQrCodes,
batchDeleteAdminQrCodes,
createAdminQrCode,
deleteAdminQrCode,
fetchAdminQrCodeStats,
fetchAdminQrCodes,
recognizeQrCodeGroupName,
updateAdminQrCode,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
@@ -39,7 +41,7 @@ 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 { uploadAdminFile } from '@/shared/api/files'
import { formatDateTime, formatText } from '@/shared/utils/format'
interface Filters {
@@ -89,6 +91,11 @@ export function AdminQrCodePoolPage() {
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<AdminQrCode>()
const [imageURL, setImageURL] = useState('')
const [imageFile, setImageFile] = useState<File>()
const [ocrLoading, setOcrLoading] = useState(false)
const [batchOpen, setBatchOpen] = useState(false)
const [batchFiles, setBatchFiles] = useState<Array<{ uid: string; file: File; name: string }>>([])
const [batchImporting, setBatchImporting] = useState(false)
const [uploading, setUploading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
@@ -237,8 +244,9 @@ export function AdminQrCodePoolPage() {
const upload = async (file: File) => {
setUploading(true)
setImageFile(file)
try {
const uploaded = await uploadFile(file, 'qrcode')
const uploaded = await uploadAdminFile(file, 'qrcode')
setImageURL(uploaded.url)
message.success('图片已上传')
} catch (error) {
@@ -385,6 +393,16 @@ export function AdminQrCodePoolPage() {
>
刷新
</Button>
<Button
icon={<UploadOutlined />}
disabled={!canManage}
onClick={() => {
setBatchFiles([])
setBatchOpen(true)
}}
>
批量导入
</Button>
<Button type="primary" icon={<PlusOutlined />} disabled={!canManage} onClick={openCreate}>
添加二维码
</Button>
@@ -523,6 +541,30 @@ export function AdminQrCodePoolPage() {
{imageURL ? '重新上传' : '上传图片'}
</Button>
</Upload>
<Button
loading={ocrLoading}
disabled={!imageFile}
onClick={async () => {
if (!imageFile) return
setOcrLoading(true)
try {
const result = await recognizeQrCodeGroupName(imageFile)
const name = (result?.group_name || result?.text || '').trim()
if (!name) {
message.warning('未识别到群名,请手动填写')
} else {
createForm.setFieldValue('group_name', name)
message.success('已识别群名')
}
} catch (error) {
message.error(errorText(error))
} finally {
setOcrLoading(false)
}
}}
>
识别群名
</Button>
</Space>
</Form.Item>
<Form.Item name="group_name" label="群名称">
@@ -537,6 +579,118 @@ export function AdminQrCodePoolPage() {
</Form>
</Modal>
<Modal
open={batchOpen}
title="批量导入二维码"
okText={`导入 ${batchFiles.length || ''} 张`}
cancelText="取消"
width={640}
confirmLoading={batchImporting}
okButtonProps={{ disabled: batchFiles.length === 0 }}
onCancel={() => (batchImporting ? undefined : setBatchOpen(false))}
onOk={async () => {
if (batchFiles.length === 0) return
setBatchImporting(true)
try {
const items: Array<{ image_url: string; group_name?: string }> = []
for (const item of batchFiles) {
const uploaded = await uploadAdminFile(item.file, 'qrcode')
items.push({ image_url: uploaded.url, group_name: item.name || undefined })
}
await batchCreateAdminQrCodes(items)
message.success(`已导入 ${items.length} 张二维码`)
setBatchOpen(false)
setBatchFiles([])
await invalidate()
} catch (error) {
message.error(errorText(error))
} finally {
setBatchImporting(false)
}
}}
maskClosable={!batchImporting}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="一次最多导入 20 张,上传后可用「识别群名」自动填充"
/>
<Upload
accept="image/*"
multiple
showUploadList={false}
beforeUpload={file => {
const f = file as File
setBatchFiles(current =>
current.length >= 20
? current
: [...current, { uid: `${Date.now()}-${Math.random()}`, file: f, name: '' }]
)
return false
}}
>
<Button icon={<UploadOutlined />}>选择图片(可多选)</Button>
</Upload>
<div className="admin-stack" style={{ gap: 10, marginTop: 12, maxHeight: 320, overflowY: 'auto' }}>
{batchFiles.map(item => (
<Space key={item.uid} size={8} align="center" style={{ width: '100%' }}>
<Typography.Text ellipsis style={{ maxWidth: 180 }}>
{item.file.name}
</Typography.Text>
<Input
size="small"
placeholder="群名称(可选)"
value={item.name}
onChange={event =>
setBatchFiles(current =>
current.map(entry =>
entry.uid === item.uid ? { ...entry, name: event.target.value } : entry
)
)
}
/>
<Button
size="small"
loading={ocrLoading}
onClick={async () => {
setOcrLoading(true)
try {
const result = await recognizeQrCodeGroupName(item.file)
const name = (result?.group_name || result?.text || '').trim()
if (name) {
setBatchFiles(current =>
current.map(entry => (entry.uid === item.uid ? { ...entry, name } : entry))
)
} else {
message.warning('未识别到群名')
}
} catch (error) {
message.error(errorText(error))
} finally {
setOcrLoading(false)
}
}}
>
识别
</Button>
<Button
size="small"
danger
onClick={() =>
setBatchFiles(current => current.filter(entry => entry.uid !== item.uid))
}
>
移除
</Button>
</Space>
))}
{batchFiles.length === 0 ? (
<Typography.Text type="secondary">还没有选择图片</Typography.Text>
) : null}
</div>
</Modal>
<Modal
open={Boolean(editing)}
title={editing ? `编辑二维码 #${editing.id}` : '编辑二维码'}
+41
View File
@@ -2026,3 +2026,44 @@ export async function updateCrashConfig(payload: Partial<AdminCrashConfig>) {
)
return data.data
}
export async function fetchChatAutoWelcome() {
const { data } = await adminClient.get<ApiResponse<{ message: string }>>(
'/admin/chats/auto-welcome'
)
return data.data.message ?? ''
}
export async function updateChatAutoWelcome(message: string) {
const { data } = await adminClient.put<ApiResponse<{ message: string }>>(
'/admin/chats/auto-welcome',
{ message }
)
return data.data
}
export async function fetchListingGroupWelcome() {
const { data } = await adminClient.get<ApiResponse<{ message: string }>>(
'/admin/chats/listing-group-welcome'
)
return data.data.message ?? ''
}
export async function updateListingGroupWelcome(message: string) {
const { data } = await adminClient.put<ApiResponse<{ message: string }>>(
'/admin/chats/listing-group-welcome',
{ message }
)
return data.data
}
export async function recognizeQrCodeGroupName(file: File) {
const form = new FormData()
form.append('file', file)
const { data } = await adminClient.post<{ data: { group_name?: string; text?: string } }>(
'/admin/chats/qrcodes/ocr-group-name',
form,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return data.data
}
+5
View File
@@ -164,6 +164,7 @@ const allNavGroups: NavGroup[] = [
{ key: '/qrcode-pool', label: '二维码池', permission: 'qrcode:view' },
{ key: '/manual-entries', label: '手工录单', permission: 'cs_manual_entry:view' },
{ key: '/chat-quick-replies', label: '快捷回复', permission: 'chat:view' },
{ key: '/chat-welcome', label: '欢迎语配置', permission: 'system_config:view' },
],
},
{
@@ -242,6 +243,10 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
title: '客服会话',
description: '按阶段筛选会话、回复用户并转接客服。',
},
'/chat-welcome': {
title: '欢迎语配置',
description: '维护订单群与账号群的自动欢迎语。',
},
'/qrcode-pool': {
title: '二维码池',
description: '维护企业微信群二维码,按未使用顺序自动发放。',