完善二维码池 OCR 配置与识别

This commit is contained in:
yml
2026-06-18 15:22:46 +08:00
parent e8bdf574f4
commit 35f2d93fdf
16 changed files with 1025 additions and 48 deletions
@@ -6,12 +6,15 @@ export type QrCodeStatus = 'unused' | 'used' | 'disabled'
export interface ChatQrCode {
id: number
image_url: string
group_name: string
status: QrCodeStatus
conversation_id: number | null
bound_conversation_title: string
used_at: string | null
expires_at: string | null
created_by: number
note: string
wecom_renamed: boolean
created_at: string
updated_at: string
}
@@ -23,6 +26,12 @@ export interface QrCodeStats {
total_count: number
}
export interface QrCodeOcrResult {
group_name: string
candidates: string[]
raw_text: string
}
export interface QrCodeListQuery {
status?: QrCodeStatus
page?: number
@@ -31,14 +40,17 @@ export interface QrCodeListQuery {
export interface CreateQrCodePayload {
image_url: string
group_name?: string
note?: string
expires_at?: string | null
}
export interface UpdateQrCodePayload {
image_url?: string
group_name?: string
note?: string
status?: QrCodeStatus
wecom_renamed?: boolean
expires_at?: string | null
clear_expires_at?: boolean
}
@@ -73,6 +85,20 @@ export async function batchCreateQrCodes(items: CreateQrCodePayload[]) {
return data.data
}
export async function recognizeQrCodeGroupName(file: File) {
const form = new FormData()
form.append('file', file)
const { data } = await apiClient.post<ApiResponse<QrCodeOcrResult>>(
'/admin/chats/qrcodes/ocr-group-name',
form,
{
headers: { 'Content-Type': 'multipart/form-data' },
silent: true,
}
)
return data.data
}
export async function updateQrCode(id: number, payload: UpdateQrCodePayload) {
const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>(
`/admin/chats/qrcodes/${id}`,
@@ -39,6 +39,8 @@ const isStructuredConfig = computed(() => {
return trimmed.startsWith('{') || trimmed.startsWith('[')
})
const isSecretConfig = computed(() => props.config.key.includes('token'))
const selectOptions = computed<SystemConfigOption[] | null>(() => {
const options = getSystemConfigSelectOptions(props.config.key)
if (!options) return null
@@ -92,7 +94,12 @@ async function handleSave() {
</el-select>
</el-form-item>
<el-form-item v-else label="配置值" class="full-control">
<el-input v-model="value" placeholder="配置值" />
<el-input
v-model="value"
:type="isSecretConfig ? 'password' : 'text'"
:show-password="isSecretConfig"
placeholder="配置值"
/>
</el-form-item>
<el-form-item label="配置说明" class="desc-item">
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'
import { Check, CopyDocument, Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'
import {
batchCreateQrCodes,
deleteQrCode,
fetchQrCodeStats,
fetchQrCodes,
recognizeQrCodeGroupName,
updateQrCode,
type ChatQrCode,
type CreateQrCodePayload,
@@ -18,6 +19,7 @@ import { uploadAdminFile } from '@/shared/api/files'
import { formatDateTime } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const qrcodes = ref<ChatQrCode[]>([])
@@ -32,7 +34,15 @@ const statusFilter = ref<QrCodeStatus | ''>('')
const uploadVisible = ref(false)
const uploadPendingCount = ref(0)
const uploadSaving = ref(false)
const uploadedImages = ref<{ url: string; file: File }[]>([])
type UploadedQrImage = {
url: string
previewUrl: string
file: File
groupName: string
ocrStatus: 'recognizing' | 'done' | 'failed'
}
const uploadedImages = ref<UploadedQrImage[]>([])
const uploadForm = reactive({
note: '',
expires_at: '' as string,
@@ -44,8 +54,10 @@ const editUploading = ref(false)
const editForm = reactive({
id: 0,
image_url: '',
group_name: '',
note: '',
status: 'unused' as QrCodeStatus,
wecom_renamed: false,
expires_at: '' as string,
was_issued: false,
})
@@ -75,6 +87,7 @@ const maxBatchUploadCount = 20
const uploading = computed(() => uploadPendingCount.value > 0)
const uploadBusy = computed(() => uploading.value || uploadSaving.value)
const uploadedImageCountText = computed(() => `${uploadedImages.value.length}/${maxBatchUploadCount}`)
const renameLoadingIds = ref(new Set<number>())
async function loadList() {
loading.value = true
@@ -108,6 +121,39 @@ function handlePageChange() {
loadList()
}
function parseGroupNameFromOcrText(text: string) {
const lines = text
.split(/\r?\n/)
.map(line => line.replace(/\s+/g, '').replace(/[|]/g, ''))
.map(line => line.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_()()《》\-—·]+/g, ''))
.filter(Boolean)
const excludedPatterns = [
/使用.*微信/,
/企业微信/,
/扫码/,
/加入/,
/二维码/,
/有效/,
/天内/,
/前/,
/^\d+$/,
]
const candidates = lines.filter(line => {
if (line.length < 3 || line.length > 64) return false
if (!/[\u4e00-\u9fa5]/.test(line)) return false
return !excludedPatterns.some(pattern => pattern.test(line))
})
return candidates.find(line => line.includes('群')) || candidates.find(line => /[-]/.test(line)) || candidates[0] || ''
}
async function recognizeGroupName(file: File) {
const result = await recognizeQrCodeGroupName(file)
return result.group_name || parseGroupNameFromOcrText(result.raw_text || '')
}
// 上传:逐个上传图片拿 URL
async function handleFileUpload(options: { file: File }) {
if (uploadedImages.value.length + uploadPendingCount.value >= maxBatchUploadCount) {
@@ -121,17 +167,54 @@ async function handleFileUpload(options: { file: File }) {
uploadPendingCount.value += 1
try {
const uploaded = await uploadAdminFile(options.file, 'qrcode')
uploadedImages.value.push({ url: uploaded.url, file: options.file })
const item: UploadedQrImage = {
url: '',
previewUrl: URL.createObjectURL(options.file),
file: options.file,
groupName: '',
ocrStatus: 'recognizing',
}
uploadedImages.value.push(item)
const [uploaded, groupName] = await Promise.all([
uploadAdminFile(options.file, 'qrcode'),
recognizeGroupName(options.file).catch(error => {
console.warn('二维码群名 OCR 失败', error)
ElMessage.warning(readError(error, '二维码群名 OCR 失败'))
return ''
}),
])
item.url = uploaded.url
item.groupName = groupName
item.ocrStatus = groupName ? 'done' : 'failed'
if (!groupName) {
console.warn('二维码群名 OCR 未识别到有效群名', options.file.name)
}
} catch {
const index = uploadedImages.value.findIndex(item => item.file === options.file && !item.url)
if (index >= 0) removeImage(index)
ElMessage.error('图片上传失败')
} finally {
uploadPendingCount.value = Math.max(0, uploadPendingCount.value - 1)
}
}
function imagePreviewSources() {
return uploadedImages.value.map(item => item.url).filter(Boolean)
}
function removeImage(index: number) {
uploadedImages.value.splice(index, 1)
const [removed] = uploadedImages.value.splice(index, 1)
if (removed?.previewUrl) {
URL.revokeObjectURL(removed.previewUrl)
}
}
function clearUploadedImages() {
uploadedImages.value.forEach(item => {
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
})
uploadedImages.value = []
}
function handleUploadExceed() {
@@ -139,7 +222,7 @@ function handleUploadExceed() {
}
function openUpload() {
uploadedImages.value = []
clearUploadedImages()
uploadForm.note = ''
uploadForm.expires_at = ''
uploadVisible.value = true
@@ -161,6 +244,7 @@ async function submitUpload() {
: null
const items: CreateQrCodePayload[] = uploadedImages.value.map(img => ({
image_url: img.url,
group_name: img.groupName,
note: uploadForm.note,
expires_at: expiresAt,
}))
@@ -173,11 +257,17 @@ async function submitUpload() {
}
}
onBeforeUnmount(() => {
clearUploadedImages()
})
function openEdit(row: ChatQrCode) {
editForm.id = row.id
editForm.image_url = row.image_url
editForm.group_name = row.group_name || ''
editForm.note = row.note
editForm.status = row.status
editForm.wecom_renamed = row.wecom_renamed
editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : ''
editForm.was_issued = row.status === 'used' || Boolean(row.conversation_id || row.used_at)
editVisible.value = true
@@ -203,8 +293,10 @@ async function handleEditImageUpload(options: { file: File }) {
async function submitEdit() {
await updateQrCode(editForm.id, {
image_url: editForm.image_url,
group_name: editForm.group_name,
note: editForm.note,
status: editForm.status,
wecom_renamed: editForm.wecom_renamed,
expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : undefined,
clear_expires_at: !editForm.expires_at,
})
@@ -213,6 +305,51 @@ async function submitEdit() {
await reloadAll()
}
async function copyText(text: string, successMessage: string) {
if (!text) {
ElMessage.warning('暂无可复制内容')
return
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success(successMessage)
} catch {
ElMessage.error('复制失败')
}
}
function boundConversationLabel(row: ChatQrCode) {
if (row.bound_conversation_title) return row.bound_conversation_title
if (row.conversation_id) return `#${row.conversation_id}`
return ''
}
function setRenameLoading(id: number, loading: boolean) {
const next = new Set(renameLoadingIds.value)
if (loading) {
next.add(id)
} else {
next.delete(id)
}
renameLoadingIds.value = next
}
async function handleRenameFlagChange(row: ChatQrCode, value: string | number | boolean) {
const nextValue = Boolean(value)
const previous = !nextValue
row.wecom_renamed = nextValue
setRenameLoading(row.id, true)
try {
await updateQrCode(row.id, { wecom_renamed: nextValue })
ElMessage.success(nextValue ? '已标记企微已改名' : '已取消企微改名标记')
} catch {
row.wecom_renamed = previous
ElMessage.error('标记失败')
} finally {
setRenameLoading(row.id, false)
}
}
async function handleDisable(row: ChatQrCode) {
await ElMessageBox.confirm('确定停用该二维码?停用后不再发放给新发布群。', '停用确认', {
type: 'warning',
@@ -305,14 +442,48 @@ onMounted(reloadAll)
</el-tag>
</template>
</el-table-column>
<el-table-column label="群名" class-name="name-col" show-overflow-tooltip>
<template #default="{ row }">
<button
class="copy-cell-button"
type="button"
:disabled="!row.group_name"
@click="copyText(row.group_name, '群名已复制')"
>
<span>{{ row.group_name || '-' }}</span>
<el-icon v-if="row.group_name"><CopyDocument /></el-icon>
</button>
</template>
</el-table-column>
<el-table-column label="备注" class-name="note-col" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-cell">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="绑定群" class-name="group-col" align="center">
<el-table-column label="绑定群" class-name="group-col" show-overflow-tooltip>
<template #default="{ row }">
<span class="group-cell">{{ row.conversation_id ? `#${row.conversation_id}` : '-' }}</span>
<button
class="copy-cell-button"
type="button"
:disabled="!boundConversationLabel(row)"
@click="copyText(boundConversationLabel(row), '绑定群聊名称已复制')"
>
<span>{{ boundConversationLabel(row) || '-' }}</span>
<el-icon v-if="boundConversationLabel(row)"><CopyDocument /></el-icon>
</button>
</template>
</el-table-column>
<el-table-column label="企微改名" class-name="rename-col" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.wecom_renamed"
:loading="renameLoadingIds.has(row.id)"
inline-prompt
:active-icon="Check"
active-text="已改"
inactive-text="未改"
@change="(value: string | number | boolean) => handleRenameFlagChange(row, value)"
/>
</template>
</el-table-column>
<el-table-column label="过期时间" class-name="expire-col" align="center">
@@ -360,7 +531,7 @@ onMounted(reloadAll)
</el-card>
<!-- 上传弹窗 -->
<el-dialog v-model="uploadVisible" title="批量添加企业微信群二维码" width="680px">
<el-dialog v-model="uploadVisible" title="批量添加企业微信群二维码" width="680px" @close="clearUploadedImages">
<el-form class="qrcode-upload-form" label-width="108px">
<el-form-item label="二维码图片" required>
<div class="batch-upload-area">
@@ -386,23 +557,50 @@ onMounted(reloadAll)
</el-upload>
<div v-if="uploadedImages.length" class="uploaded-list">
<div v-for="(img, idx) in uploadedImages" :key="idx" class="uploaded-item">
<div class="uploaded-thumb">
<AuthImage
:source="img.url"
fit="cover"
:image-style="{ width: '52px', height: '52px', borderRadius: '6px' }"
:preview-src-list="uploadedImages.map(item => item.url)"
<div class="uploaded-main">
<div class="uploaded-thumb">
<AuthImage
v-if="img.url"
:source="img.url"
fit="cover"
:image-style="{ width: '52px', height: '52px', borderRadius: '6px' }"
:preview-src-list="imagePreviewSources()"
/>
<img
v-else
class="uploaded-local-preview"
:src="img.previewUrl"
alt=""
/>
<button
class="uploaded-remove"
type="button"
:disabled="uploadBusy"
@click="removeImage(idx)"
>
×
</button>
</div>
<div class="uploaded-meta">
<div class="uploaded-name" :title="img.file.name">{{ img.file.name }}</div>
<el-tag v-if="img.ocrStatus === 'recognizing'" size="small" type="info"
>识别中</el-tag
>
<el-tag v-if="img.ocrStatus === 'done'" size="small" type="success">OCR</el-tag>
<el-tag v-if="img.ocrStatus === 'failed'" size="small" type="warning"
>待校对</el-tag
>
</div>
</div>
<div class="uploaded-group-editor">
<el-input
v-model="img.groupName"
size="small"
placeholder="群名"
maxlength="64"
clearable
/>
<button
class="uploaded-remove"
type="button"
:disabled="uploadBusy"
@click="removeImage(idx)"
>
×
</button>
</div>
<div class="uploaded-name" :title="img.file.name">{{ img.file.name }}</div>
</div>
</div>
</div>
@@ -467,6 +665,12 @@ onMounted(reloadAll)
/>
</el-select>
</el-form-item>
<el-form-item label="群名">
<el-input v-model="editForm.group_name" placeholder="企业微信群名" maxlength="64" clearable />
</el-form-item>
<el-form-item label="企微改名">
<el-switch v-model="editForm.wecom_renamed" inline-prompt active-text="已改" inactive-text="未改" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.note" placeholder="7月企微群A" maxlength="50" />
</el-form-item>
@@ -588,31 +792,39 @@ onMounted(reloadAll)
.qrcode-table :deep(.el-table__header-wrapper),
.qrcode-table :deep(.el-table__body-wrapper) {
overflow-x: hidden;
overflow-x: auto;
}
.qrcode-table :deep(.qr-col) {
width: 12%;
width: 8%;
}
.qrcode-table :deep(.status-col) {
width: 10%;
width: 8%;
}
.qrcode-table :deep(.name-col) {
width: 18%;
}
.qrcode-table :deep(.note-col) {
width: 40%;
width: 18%;
}
.qrcode-table :deep(.group-col) {
width: 10%;
width: 18%;
}
.qrcode-table :deep(.rename-col) {
width: 8%;
}
.qrcode-table :deep(.expire-col) {
width: 16%;
width: 12%;
}
.qrcode-table :deep(.action-col) {
width: 12%;
width: 10%;
}
.qrcode-thumb {
@@ -631,12 +843,46 @@ onMounted(reloadAll)
font-weight: 500;
}
.group-cell,
.expire-cell {
color: #5f6b7a;
font-size: 13px;
}
.copy-cell-button {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
color: #303846;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 20px;
text-align: left;
}
.copy-cell-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copy-cell-button .el-icon {
flex: 0 0 auto;
color: var(--el-color-primary);
font-size: 14px;
}
.copy-cell-button:disabled {
color: #a8b0bd;
cursor: default;
}
.table-actions {
display: flex;
justify-content: center;
@@ -703,10 +949,10 @@ onMounted(reloadAll)
.uploaded-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 10px;
max-height: 224px;
max-height: 320px;
overflow-y: auto;
padding: 10px;
border: 1px solid #eef1f5;
@@ -716,6 +962,17 @@ onMounted(reloadAll)
.uploaded-item {
min-width: 0;
padding: 10px;
border: 1px solid #e7ebf2;
border-radius: 8px;
background: #ffffff;
}
.uploaded-main {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.uploaded-thumb {
@@ -725,12 +982,20 @@ onMounted(reloadAll)
justify-content: center;
width: 60px;
height: 60px;
margin: 0 auto;
flex: 0 0 auto;
border: 1px solid #e7ebf2;
border-radius: 8px;
background: #ffffff;
}
.uploaded-local-preview {
display: block;
width: 52px;
height: 52px;
border-radius: 6px;
object-fit: cover;
}
.uploaded-remove {
position: absolute;
top: -7px;
@@ -756,16 +1021,31 @@ onMounted(reloadAll)
}
.uploaded-name {
margin-top: 5px;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 16px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.uploaded-meta {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.uploaded-meta .el-tag {
height: 20px;
}
.uploaded-group-editor {
margin-top: 8px;
}
.expired-tag {
color: var(--el-color-danger);
}
@@ -105,6 +105,23 @@ const listingGroupWelcomeConfig = computed(
const renterRetentionConfig = computed(
() => configs.value.find(item => item.key === 'chat.renter_retention_days_after_order_end') || null
)
const paddleOcrTokenConfig = computed(() =>
systemConfigByKey(
'integration.paddle_ocr_token',
'',
'PaddleOCR API Token,用于二维码群名自动识别'
)
)
const paddleOcrJobUrlConfig = computed(() =>
systemConfigByKey(
'integration.paddle_ocr_job_url',
'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs',
'PaddleOCR 异步任务接口地址'
)
)
const paddleOcrModelConfig = computed(() =>
systemConfigByKey('integration.paddle_ocr_model', 'PaddleOCR-VL-1.6', 'PaddleOCR 识别模型')
)
const regularConfigs = computed(() =>
configs.value.filter(
@@ -118,7 +135,10 @@ const regularConfigs = computed(() =>
item.key !== 'profile.post_rental_notice' &&
item.key !== 'chat.auto_welcome_message' &&
item.key !== 'chat.listing_group_welcome' &&
item.key !== 'chat.renter_retention_days_after_order_end'
item.key !== 'chat.renter_retention_days_after_order_end' &&
item.key !== 'integration.paddle_ocr_token' &&
item.key !== 'integration.paddle_ocr_job_url' &&
item.key !== 'integration.paddle_ocr_model'
)
)
@@ -188,6 +208,19 @@ async function loadConfigs() {
}
}
function systemConfigByKey(key: string, fallbackValue: string, description: string): SystemConfig {
const existing = configs.value.find(item => item.key === key)
if (existing) return existing
return {
id: 0,
key,
value: fallbackValue,
description,
created_at: '',
updated_at: '',
}
}
function openEdit(row: SystemConfig) {
currentEditingConfig.value = row
if (row.key === 'listing.publish_options') {
@@ -309,6 +342,9 @@ function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) {
}
function formatConfigValue(row: SystemConfig) {
if (row.key.includes('token')) {
return row.value.trim() ? '已配置' : '未配置'
}
const trimmed = row.value.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return 'JSON 配置'
@@ -599,6 +635,41 @@ function formatConfigValue(row: SystemConfig) {
</div>
</section>
<section class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">PaddleOCR</p>
<h2>二维码群名识别配置</h2>
<span>配置企业微信群二维码上传时使用的 OCR 识别接口保存后无需重新构建前端</span>
</div>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ paddleOcrTokenConfig.value.trim() ? '已配置' : '未配置' }}</strong>
<span>API Token</span>
<el-button size="small" @click="openEdit(paddleOcrTokenConfig)">编辑</el-button>
</div>
<div class="publish-stat">
<strong>接口</strong>
<span>{{ paddleOcrJobUrlConfig.value || '未配置' }}</span>
<el-button size="small" @click="openEdit(paddleOcrJobUrlConfig)">编辑</el-button>
</div>
<div class="publish-stat">
<strong>{{ paddleOcrModelConfig.value || '未配置' }}</strong>
<span>识别模型</span>
<el-button size="small" @click="openEdit(paddleOcrModelConfig)">编辑</el-button>
</div>
<div class="publish-stat">
<strong>直连</strong>
<span>二维码池上传时调用</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(paddleOcrTokenConfig, '未初始化') }}</span>
</div>
</div>
</section>
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
<el-table-column prop="key" label="配置项" min-width="260" />
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
@@ -747,6 +818,7 @@ function formatConfigValue(row: SystemConfig) {
.config-value {
color: #4b5563;
font-size: 13px;
overflow-wrap: anywhere;
}
/* 按钮文字清晰度优化 */
@@ -58,6 +58,9 @@ export const systemConfigSelectOptions: Record<string, SystemConfigOption[]> = {
{ label: '5 天', value: '5' },
{ label: '7 天', value: '7' },
],
'integration.paddle_ocr_model': [
{ label: 'PaddleOCR-VL-1.6', value: 'PaddleOCR-VL-1.6' },
],
}
export function getSystemConfigSelectOptions(key: string) {