完善二维码池 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
@@ -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);
}