Files
hfb_sys/frontend/src/features/admin/views/AdminQrCodePoolView.vue
T
2026-06-19 17:08:57 +08:00

1101 lines
30 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, Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'
import {
batchCreateQrCodes,
deleteQrCode,
fetchQrCodeStats,
fetchQrCodes,
recognizeQrCodeGroupName,
updateQrCode,
type ChatQrCode,
type CreateQrCodePayload,
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'
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 currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const statusFilter = ref<QrCodeStatus | ''>('')
// 上传弹窗
const uploadVisible = ref(false)
const uploadPendingCount = ref(0)
const uploadSaving = ref(false)
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,
})
// 编辑弹窗
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 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>())
async function loadList() {
loading.value = true
try {
const res = await fetchQrCodes({
status: statusFilter.value || undefined,
page: currentPage.value,
limit: currentPageSize.value,
})
qrcodes.value = res.items
total.value = res.total
} 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 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) {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张二维码`)
return
}
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('只能上传图片文件')
return
}
uploadPendingCount.value += 1
try {
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) {
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() {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张二维码`)
}
function openUpload() {
clearUploadedImages()
uploadForm.note = ''
uploadForm.expires_at = ''
uploadVisible.value = true
}
async function submitUpload() {
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) {
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 }) {
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() {
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 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',
})
await updateQrCode(row.id, { status: 'disabled' })
ElMessage.success('已停用')
await reloadAll()
}
async function handleDelete(row: ChatQrCode) {
await ElMessageBox.confirm('确定删除该二维码?仅未使用的二维码可删除。', '删除确认', {
type: 'warning',
})
await deleteQrCode(row.id)
ElMessage.success('已删除')
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-select
v-model="statusFilter"
placeholder="状态筛选"
clearable
style="width: 140px"
@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-button :icon="Search" @click="handleFilter">筛选</el-button>
</div>
<div class="action-area">
<el-button :icon="Refresh" @click="reloadAll">刷新</el-button>
<el-button 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">
<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)"
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 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
v-if="row.status === 'unused'"
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="idx" 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="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
/>
</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: center;
justify-content: space-between;
gap: 16px;
}
.filter-area,
.action-area {
display: flex;
gap: 8px;
align-items: center;
}
.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(.qr-col) {
width: 8%;
}
.qrcode-table :deep(.status-col) {
width: 8%;
}
.qrcode-table :deep(.name-col) {
width: 18%;
}
.qrcode-table :deep(.note-col) {
width: 18%;
}
.qrcode-table :deep(.group-col) {
width: 18%;
}
.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;
}
.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>