1101 lines
30 KiB
Vue
1101 lines
30 KiB
Vue
<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>
|