Files
hfb_sys/frontend/src/features/admin/views/AdminQrCodePoolView.vue
T

1430 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Check,
CopyDocument,
Delete,
Plus,
Refresh,
Search,
UploadFilled,
} from '@element-plus/icons-vue'
import {
batchCreateQrCodes,
batchDeleteQrCodes,
deleteQrCode,
fetchQrCodeStats,
fetchQrCodes,
getOCRJobResult,
submitOCRJob,
updateQrCode,
type ChatQrCode,
type CreateQrCodePayload,
type QrCodeBooleanFilter,
type QrCodeExpireStatus,
type QrCodeStatus,
type QrCodeStats,
} from '@/features/admin/api/adminQrCodes'
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'
import { useAdminSessionStore } from '@/stores/adminSession'
const adminSession = useAdminSessionStore()
const loading = ref(false)
const qrcodes = ref<ChatQrCode[]>([])
const stats = ref<QrCodeStats>({
unused_count: 0,
used_count: 0,
disabled_count: 0,
total_count: 0,
})
const canManageQrCodes = computed(() => adminSession.hasPermission('qrcode:manage'))
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
type QrCodeFilters = {
keyword: string
status: QrCodeStatus | ''
wecom_renamed: QrCodeBooleanFilter | ''
bound: QrCodeBooleanFilter | ''
expire_status: QrCodeExpireStatus | ''
created_range: string[]
}
const filters = reactive<QrCodeFilters>({
keyword: '',
status: '',
wecom_renamed: '',
bound: '',
expire_status: '',
created_range: [],
})
// 上传弹窗
const uploadVisible = ref(false)
const uploadPendingCount = ref(0)
const uploadSaving = ref(false)
type UploadedQrImage = {
_id: string
url: string
previewUrl: string
file: File
groupName: string
ocrStatus: 'recognizing' | 'done' | 'failed'
}
const uploadedImages = ref<UploadedQrImage[]>([])
const uploadForm = reactive({
note: '',
expires_at: '' as string,
})
// 编辑弹窗
const editVisible = ref(false)
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,
})
const statusOptions: { label: string; value: QrCodeStatus | ''; type: string }[] = [
{ label: '全部', value: '', type: 'info' },
{ label: '待用', value: 'unused', type: 'success' },
{ label: '已发放', value: 'used', type: 'primary' },
{ label: '已停用', value: 'disabled', type: 'danger' },
]
const renameFilterOptions: { label: string; value: QrCodeBooleanFilter }[] = [
{ label: '已改名', value: 'true' },
{ label: '未改名', value: 'false' },
]
const boundFilterOptions: { label: string; value: QrCodeBooleanFilter }[] = [
{ label: '已绑定', value: 'true' },
{ label: '未绑定', value: 'false' },
]
const expireStatusOptions: { label: string; value: QrCodeExpireStatus }[] = [
{ label: '未过期', value: 'valid' },
{ label: '即将过期', value: 'expiring_soon' },
{ label: '已过期', value: 'expired' },
{ label: '永久有效', value: 'permanent' },
]
const statusMap = computed(() => {
const m: Record<string, { label: string; type: string }> = {}
statusOptions.forEach(o => {
if (o.value) m[o.value] = { label: o.label, type: o.type }
})
return m
})
const editStatusOptions = computed(() =>
editForm.was_issued
? statusOptions.filter(option => option.value === 'used' || option.value === 'disabled')
: statusOptions.filter(option => option.value === 'unused' || option.value === 'disabled')
)
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>())
const selectedRows = ref<ChatQrCode[]>([])
const selectedCount = computed(() => selectedRows.value.length)
const selectedIssuedCount = computed(() => selectedRows.value.filter(qrcodeWasIssued).length)
function filterDateToISO(value?: string) {
if (!value) return undefined
const date = new Date(value)
if (Number.isNaN(date.getTime())) return undefined
return date.toISOString()
}
async function loadList() {
loading.value = true
try {
const [createdStart, createdEnd] = filters.created_range || []
const res = await fetchQrCodes({
status: filters.status || undefined,
keyword: filters.keyword.trim() || undefined,
wecom_renamed: filters.wecom_renamed || undefined,
bound: filters.bound || undefined,
expire_status: filters.expire_status || undefined,
created_start: filterDateToISO(createdStart),
created_end: filterDateToISO(createdEnd),
page: currentPage.value,
limit: currentPageSize.value,
})
qrcodes.value = res.items
total.value = res.total
selectedRows.value = []
} finally {
loading.value = false
}
}
async function loadStats() {
stats.value = await fetchQrCodeStats()
}
async function reloadAll() {
await Promise.all([loadList(), loadStats()])
}
function handleFilter() {
currentPage.value = 1
loadList()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.wecom_renamed = ''
filters.bound = ''
filters.expire_status = ''
filters.created_range = []
handleFilter()
}
function handlePageChange() {
loadList()
}
function handleSelectionChange(rows: ChatQrCode[]) {
selectedRows.value = rows
}
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] ||
''
)
}
const ocrConcurrencyLimit = 4
let ocrRunningCount = 0
const ocrWaitQueue: (() => void)[] = []
function ocrAcquireSlot(): Promise<void> {
if (ocrRunningCount < ocrConcurrencyLimit) {
ocrRunningCount++
return Promise.resolve()
}
return new Promise(resolve => {
ocrWaitQueue.push(() => {
ocrRunningCount++
resolve()
})
})
}
function ocrReleaseSlot() {
ocrRunningCount = Math.max(0, ocrRunningCount - 1)
const next = ocrWaitQueue.shift()
if (next) next()
}
function ocrResetSlots() {
ocrRunningCount = 0
while (ocrWaitQueue.length > 0) {
const next = ocrWaitQueue.shift()
next?.()
}
}
async function recognizeGroupName(file: File) {
const { job_id } = await submitOCRJob(file)
const delays = [1000, 2000, 3000, 3000, 3000]
for (let i = 0; i < delays.length; i++) {
await new Promise(resolve => setTimeout(resolve, delays[i]))
const record = await getOCRJobResult(job_id)
if (record.status === 'done' && record.result) {
return record.result.group_name || parseGroupNameFromOcrText(record.result.raw_text || '')
}
if (record.status === 'failed') {
throw new Error(record.error || 'OCR 识别失败')
}
}
throw new Error('OCR 识别超时')
}
async function recognizeGroupNameWithLimit(file: File) {
await ocrAcquireSlot()
try {
return await recognizeGroupName(file)
} finally {
ocrReleaseSlot()
}
}
// 上传:逐个上传图片拿 URL
async function handleFileUpload(options: { file: File }) {
if (uploadedImages.value.length + uploadPendingCount.value >= maxBatchUploadCount) {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张二维码`)
return
}
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('只能上传图片文件')
return
}
uploadPendingCount.value += 1
try {
const item: UploadedQrImage = {
_id: `${Date.now()}_${Math.random().toString(36).slice(2)}`,
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'),
recognizeGroupNameWithLimit(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 removeImage(index: number) {
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 = []
uploadPendingCount.value = 0
ocrResetSlots()
}
function handleUploadExceed() {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张二维码`)
}
function openUpload() {
if (!canManageQrCodes.value) return
clearUploadedImages()
uploadForm.note = ''
uploadForm.expires_at = ''
uploadVisible.value = true
}
async function submitUpload() {
if (!canManageQrCodes.value) return
if (uploadPendingCount.value > 0) {
ElMessage.warning('图片还在上传中,请稍后保存')
return
}
if (uploadedImages.value.length === 0) {
ElMessage.warning('请先上传二维码图片')
return
}
uploadSaving.value = true
try {
const expiresAt = uploadForm.expires_at ? new Date(uploadForm.expires_at).toISOString() : null
const items: CreateQrCodePayload[] = uploadedImages.value.map(img => ({
image_url: img.url,
group_name: img.groupName,
note: uploadForm.note,
expires_at: expiresAt,
}))
await batchCreateQrCodes(items)
ElMessage.success(`已批量添加 ${items.length} 张二维码`)
uploadVisible.value = false
await reloadAll()
} finally {
uploadSaving.value = false
}
}
onBeforeUnmount(() => {
clearUploadedImages()
})
function openEdit(row: ChatQrCode) {
if (!canManageQrCodes.value) return
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
}
function clearEditExpiresAt() {
editForm.expires_at = ''
}
async function handleEditImageUpload(options: { file: File }) {
if (!canManageQrCodes.value) return
editUploading.value = true
try {
const uploaded = await uploadAdminFile(options.file, 'qrcode')
editForm.image_url = uploaded.url
ElMessage.success('图片已上传')
} catch {
ElMessage.error('图片上传失败')
} finally {
editUploading.value = false
}
}
async function submitEdit() {
if (!canManageQrCodes.value) return
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,
})
ElMessage.success('已更新')
editVisible.value = false
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 qrcodeWasIssued(row: ChatQrCode) {
return row.status === 'used' || Boolean(row.conversation_id || row.used_at)
}
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) {
if (!canManageQrCodes.value) return
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) {
if (!canManageQrCodes.value) return
await ElMessageBox.confirm('确定停用该二维码?停用后不再发放给新发布群。', '停用确认', {
type: 'warning',
})
await updateQrCode(row.id, { status: 'disabled' })
ElMessage.success('已停用')
await reloadAll()
}
async function handleDelete(row: ChatQrCode) {
if (!canManageQrCodes.value) return
const tip = qrcodeWasIssued(row)
? '确定删除这条已发放二维码记录?删除后仅清理二维码池旧记录,不会影响已发送到群聊里的图片消息。'
: '确定删除该二维码?删除后将从二维码池移除。'
await ElMessageBox.confirm(tip, '删除确认', {
type: 'warning',
})
await deleteQrCode(row.id)
ElMessage.success('已删除')
await reloadAll()
}
async function handleBatchDelete() {
if (!canManageQrCodes.value) return
if (!selectedCount.value) {
ElMessage.warning('请先选择要删除的二维码')
return
}
const tip = selectedIssuedCount.value
? `确定删除选中的 ${selectedCount.value} 条二维码记录?其中 ${selectedIssuedCount.value} 条已发放,删除后仅清理二维码池旧记录,不会影响已发送到群聊里的图片消息。`
: `确定删除选中的 ${selectedCount.value} 条二维码?删除后将从二维码池移除。`
await ElMessageBox.confirm(tip, '批量删除确认', {
type: 'warning',
})
const result = await batchDeleteQrCodes(selectedRows.value.map(row => row.id))
selectedRows.value = []
ElMessage.success(`已删除 ${result.deleted} 条`)
await reloadAll()
}
onMounted(reloadAll)
</script>
<template>
<div class="qrcode-pool-view">
<!-- 库存统计卡片 -->
<div class="stats-cards">
<el-card shadow="hover" class="stat-card">
<div class="stat-value success">{{ stats.unused_count }}</div>
<div class="stat-label">待用库存</div>
</el-card>
<el-card shadow="hover" class="stat-card">
<div class="stat-value primary">{{ stats.used_count }}</div>
<div class="stat-label">已发放</div>
</el-card>
<el-card shadow="hover" class="stat-card">
<div class="stat-value danger">{{ stats.disabled_count }}</div>
<div class="stat-label">已停用</div>
</el-card>
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.total_count }}</div>
<div class="stat-label">合计</div>
</el-card>
</div>
<!-- 工具栏 -->
<el-card shadow="never" class="toolbar-card">
<div class="toolbar">
<div class="filter-area">
<el-input
v-model="filters.keyword"
clearable
class="keyword-filter"
:prefix-icon="Search"
placeholder="群名 / 备注 / 绑定群 / ID"
@clear="handleFilter"
@keyup.enter="handleFilter"
/>
<el-select
v-model="filters.status"
placeholder="状态筛选"
clearable
class="compact-filter"
@change="handleFilter"
>
<el-option
v-for="opt in statusOptions.filter(o => o.value)"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filters.wecom_renamed"
placeholder="企微改名"
clearable
class="compact-filter"
@change="handleFilter"
>
<el-option
v-for="opt in renameFilterOptions"
:key="`rename-${opt.value}`"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filters.bound"
placeholder="绑定群"
clearable
class="compact-filter"
@change="handleFilter"
>
<el-option
v-for="opt in boundFilterOptions"
:key="`bound-${opt.value}`"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filters.expire_status"
placeholder="过期状态"
clearable
class="compact-filter"
@change="handleFilter"
>
<el-option
v-for="opt in expireStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-model="filters.created_range"
type="datetimerange"
range-separator="至"
start-placeholder="上传开始"
end-placeholder="上传结束"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
class="date-range-filter"
@change="handleFilter"
/>
<el-button type="primary" :icon="Search" @click="handleFilter">筛选</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<div class="action-area">
<span v-if="selectedCount" class="selection-summary">已选 {{ selectedCount }} 条</span>
<el-button
v-if="canManageQrCodes"
type="danger"
:icon="Delete"
:disabled="!selectedCount"
@click="handleBatchDelete"
>
批量删除
</el-button>
<el-button :icon="Refresh" @click="reloadAll">刷新</el-button>
<el-button v-if="canManageQrCodes" type="primary" :icon="Plus" @click="openUpload">
添加二维码
</el-button>
</div>
</div>
</el-card>
<!-- 列表 -->
<el-card shadow="never" class="table-card">
<el-table
v-loading="loading"
:data="qrcodes"
class="table-panel qrcode-table"
:fit="true"
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column
v-if="canManageQrCodes"
type="selection"
class-name="selection-col"
align="center"
/>
<el-table-column label="二维码" class-name="qr-col" align="center">
<template #default="{ row }">
<div class="qrcode-thumb">
<AuthImage
:source="row.image_url"
fit="cover"
:image-style="{ width: '56px', height: '56px', borderRadius: '6px' }"
:preview-src-list="[row.image_url]"
/>
</div>
</template>
</el-table-column>
<el-table-column label="状态" class-name="status-col" align="center">
<template #default="{ row }">
<el-tag :type="(statusMap[row.status]?.type as any) || 'info'" size="small">
{{ statusMap[row.status]?.label || row.status }}
</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" show-overflow-tooltip>
<template #default="{ row }">
<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)"
:disabled="!canManageQrCodes"
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="time-col" align="center">
<template #default="{ row }">
<div class="time-stack">
<span>上传 {{ formatDateTime(row.created_at) }}</span>
<span>{{ row.used_at ? `使用 ${formatDateTime(row.used_at)}` : '使用 -' }}</span>
<span
:class="{ 'expired-tag': row.expires_at && new Date(row.expires_at) < new Date() }"
>
{{ row.expires_at ? `过期 ${formatDateTime(row.expires_at)}` : '过期 永久' }}
</span>
</div>
</template>
</el-table-column>
<el-table-column
v-if="canManageQrCodes"
label="操作"
class-name="action-col"
align="center"
>
<template #default="{ row }">
<div class="table-actions">
<el-button text type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="row.status !== 'disabled'"
text
type="warning"
size="small"
@click="handleDisable(row)"
>停用</el-button
>
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="handlePageChange"
/>
</el-card>
<!-- 上传弹窗 -->
<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">
<el-upload
:auto-upload="true"
:show-file-list="false"
:http-request="(opt: any) => handleFileUpload(opt)"
accept="image/*"
multiple
drag
:limit="maxBatchUploadCount"
:disabled="uploadBusy || uploadedImages.length >= maxBatchUploadCount"
:on-exceed="handleUploadExceed"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">点击或拖拽上传,支持多选</div>
<template #tip>
<div class="upload-tip">
已选择 {{ uploadedImageCountText }}
<span v-if="uploadPendingCount > 0">,{{ uploadPendingCount }} 张上传中</span>
</div>
</template>
</el-upload>
<div v-if="uploadedImages.length" class="uploaded-list">
<div v-for="(img, idx) in uploadedImages" :key="img._id" class="uploaded-item">
<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="[img.url]"
/>
<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
/>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="uploadForm.note"
placeholder="如:7月企微群A(统一备注,适用于同一批次)"
maxlength="50"
/>
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker
v-model="uploadForm.expires_at"
type="datetime"
placeholder="留空默认7天后过期"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="uploadBusy" @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploadBusy" @click="submitUpload">
保存{{ uploadedImages.length > 0 ? `(${uploadedImages.length} 张)` : '' }}
</el-button>
</template>
</el-dialog>
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑二维码" width="440px">
<el-form label-width="90px">
<el-form-item label="二维码图片">
<div class="edit-image-area">
<div class="edit-image-preview">
<AuthImage
v-if="editForm.image_url"
:source="editForm.image_url"
fit="cover"
:preview-src-list="[editForm.image_url]"
:image-style="{ width: '100%', height: '100%', borderRadius: '6px' }"
/>
<div v-else class="edit-image-empty">暂无图片</div>
</div>
<el-upload
:auto-upload="true"
:show-file-list="false"
:http-request="(opt: any) => handleEditImageUpload(opt)"
accept="image/*"
:disabled="editUploading"
>
<el-button size="small" :loading="editUploading">
{{ editForm.image_url ? '替换图片' : '上传图片' }}
</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editForm.status" style="width: 100%">
<el-option
v-for="option in editStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</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>
<el-form-item label="过期时间">
<div class="expire-editor">
<el-date-picker
v-model="editForm.expires_at"
type="datetime"
placeholder="留空为永久"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
<el-button :disabled="!editForm.expires_at" @click="clearEditExpiresAt">清空</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.qrcode-pool-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-card {
text-align: center;
}
.toolbar-card,
.table-card {
border-radius: 10px;
}
.table-card :deep(.el-card__body) {
padding: 20px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
line-height: 1.2;
}
.stat-value.success {
color: var(--el-color-success);
}
.stat-value.primary {
color: var(--el-color-primary);
}
.stat-value.danger {
color: var(--el-color-danger);
}
.stat-label {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.filter-area,
.action-area {
display: flex;
gap: 8px;
align-items: center;
}
.filter-area {
flex: 1 1 auto;
flex-wrap: wrap;
}
.action-area {
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.keyword-filter {
width: 240px;
}
.compact-filter {
width: 128px;
}
.date-range-filter {
width: 330px;
}
.qrcode-table {
border-radius: 10px;
box-shadow: none;
padding: 0;
}
.qrcode-table :deep(.el-table__inner-wrapper) {
min-width: 0;
}
.qrcode-table :deep(.el-table__header th) {
height: 42px;
background: #f8f9fb;
}
.qrcode-table :deep(.el-table__cell) {
padding: 10px 0;
}
.qrcode-table :deep(.cell) {
padding: 0 14px;
}
.qrcode-table :deep(.el-table__fixed-right) {
box-shadow: -8px 0 12px rgba(31, 45, 61, 0.04);
}
.qrcode-table :deep(.el-table__header),
.qrcode-table :deep(.el-table__body) {
width: 100% !important;
}
.qrcode-table :deep(.el-table__header-wrapper),
.qrcode-table :deep(.el-table__body-wrapper) {
overflow-x: auto;
}
.qrcode-table :deep(.selection-col) {
width: 4%;
}
.qrcode-table :deep(.qr-col) {
width: 8%;
}
.qrcode-table :deep(.status-col) {
width: 8%;
}
.qrcode-table :deep(.name-col) {
width: 17%;
}
.qrcode-table :deep(.note-col) {
width: 17%;
}
.qrcode-table :deep(.group-col) {
width: 17%;
}
.qrcode-table :deep(.rename-col) {
width: 8%;
}
.qrcode-table :deep(.time-col) {
width: 12%;
}
.qrcode-table :deep(.action-col) {
width: 10%;
}
.qrcode-thumb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #ffffff;
}
.note-cell {
color: #303846;
font-weight: 500;
}
.time-stack {
display: inline-flex;
flex-direction: column;
gap: 2px;
color: #5f6b7a;
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: nowrap;
}
.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;
gap: 6px;
white-space: nowrap;
}
.table-actions :deep(.el-button) {
margin-left: 0;
padding: 4px 6px;
}
@media (max-width: 1280px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.action-area {
justify-content: flex-start;
}
}
@media (max-width: 720px) {
.keyword-filter,
.compact-filter,
.date-range-filter {
width: 100%;
}
.filter-area,
.action-area {
align-items: stretch;
}
}
.upload-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.batch-upload-area {
width: 100%;
}
.qrcode-upload-form :deep(.el-form-item__label) {
align-items: center;
padding-right: 18px;
line-height: 32px;
white-space: nowrap;
word-break: keep-all;
}
.qrcode-upload-form :deep(.el-form-item) {
margin-bottom: 18px;
}
.qrcode-upload-form :deep(.el-form-item__content) {
min-width: 0;
}
.batch-upload-area :deep(.el-upload) {
width: 100%;
}
.batch-upload-area :deep(.el-upload-dragger) {
width: 100%;
padding: 12px 18px;
}
.batch-upload-area :deep(.el-icon--upload) {
margin-bottom: 4px;
font-size: 28px;
line-height: 1;
}
.batch-upload-area :deep(.el-upload__text) {
line-height: 20px;
}
.upload-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 18px;
}
.uploaded-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 10px;
max-height: 320px;
overflow-y: auto;
padding: 10px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fafbfc;
}
.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 {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
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;
right: -7px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 1px solid #ffffff;
border-radius: 50%;
background: var(--el-color-danger);
color: #ffffff;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.uploaded-remove:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.uploaded-name {
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 16px;
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);
}
.edit-image-area {
display: flex;
align-items: center;
gap: 12px;
}
.edit-image-preview {
flex: 0 0 auto;
width: 88px;
height: 88px;
overflow: hidden;
border: 1px solid #e7ebf2;
border-radius: 8px;
background: #fafbfc;
}
.edit-image-preview :deep(.el-image),
.edit-image-preview :deep(img) {
display: block;
width: 100%;
height: 100%;
}
.edit-image-empty {
display: grid;
width: 100%;
height: 100%;
place-items: center;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.expire-editor {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
</style>