feat(chat): 支持二维码批量导入与 OCR 识别,新增欢迎语配置页
This commit is contained in:
@@ -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}` : '编辑二维码'}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '维护企业微信群二维码,按未使用顺序自动发放。',
|
||||
|
||||
Reference in New Issue
Block a user