Files
hfb_sys/frontend/src/features/admin/views/AdminQrCodePoolView.vue
T
yml 12f589f0df 二维码池批量上传与认证图片预览修复
- 后端新增批量创建二维码接口 POST /admin/chats/qrcodes/batch
- 前端二维码管理页支持多图拖拽批量上传
- 注册 qrcode 图片压缩场景(maxSide=2560, quality=0.95)
- 后端 normalizeScene 注册 qrcode scene
- 增强 AuthImage 组件支持 el-image 大图预览(previewSrcList)
- 修复 WithdrawalDetailDialog 提现凭证图片 401 问题
- 删除重复的 components/AuthImage.vue,统一使用共享组件
- 提交3剩余: 会话列表群类型图标、发布成功跳群、管理端路由等
2026-06-17 17:57:05 +08:00

467 lines
14 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, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'
import {
batchCreateQrCodes,
deleteQrCode,
fetchQrCodeStats,
fetchQrCodes,
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'
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 uploading = ref(false)
const uploadedImages = ref<{ url: string; file: File }[]>([])
const uploadForm = reactive({
note: '',
expires_at: '' as string,
})
// 编辑弹窗
const editVisible = ref(false)
const editForm = reactive({
id: 0,
note: '',
status: 'unused' as QrCodeStatus,
expires_at: '' as string,
})
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
})
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()
}
// 上传:逐个上传图片拿 URL
async function handleFileUpload(options: { file: File }) {
uploading.value = true
try {
const uploaded = await uploadAdminFile(options.file, 'qrcode')
uploadedImages.value.push({ url: uploaded.url, file: options.file })
ElMessage.success(`已上传 ${uploadedImages.value.length} 张图片`)
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeImage(index: number) {
uploadedImages.value.splice(index, 1)
}
function openUpload() {
uploadedImages.value = []
uploadForm.note = ''
uploadForm.expires_at = ''
uploadVisible.value = true
}
async function submitUpload() {
if (uploadedImages.value.length === 0) {
ElMessage.warning('请先上传二维码图片')
return
}
uploading.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,
note: uploadForm.note,
expires_at: expiresAt,
}))
await batchCreateQrCodes(items)
ElMessage.success(`已批量添加 ${items.length} 张二维码`)
uploadVisible.value = false
await reloadAll()
} finally {
uploading.value = false
}
}
function openEdit(row: ChatQrCode) {
editForm.id = row.id
editForm.note = row.note
editForm.status = row.status
editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : ''
editVisible.value = true
}
async function submitEdit() {
await updateQrCode(editForm.id, {
note: editForm.note,
status: editForm.status,
expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : null,
})
ElMessage.success('已更新')
editVisible.value = false
await reloadAll()
}
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">
<el-table-column label="二维码" width="120">
<template #default="{ row }">
<AuthImage
:source="row.image_url"
fit="cover"
:image-style="{ width: '72px', height: '72px', borderRadius: '6px' }"
:preview-src-list="[row.image_url]"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="(statusMap[row.status]?.type as any) || 'info'">
{{ statusMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="绑定发布群" width="120">
<template #default="{ row }">
{{ row.conversation_id ? `#${row.conversation_id}` : '-' }}
</template>
</el-table-column>
<el-table-column label="过期时间" width="170">
<template #default="{ row }">
<span :class="{ 'expired-tag': row.expires_at && new Date(row.expires_at) < new Date() }">
{{ row.expires_at ? formatDateTime(row.expires_at) : '永久' }}
</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="row.status !== 'disabled'"
link
type="warning"
size="small"
@click="handleDisable(row)"
>停用</el-button
>
<el-button
v-if="row.status === 'unused'"
link
type="danger"
size="small"
@click="handleDelete(row)"
>删除</el-button
>
</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="560px">
<el-form label-width="90px">
<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
:disabled="uploading"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">点击或拖拽上传,支持多选</div>
</el-upload>
<div v-if="uploadedImages.length" class="uploaded-list">
<div v-for="(img, idx) in uploadedImages" :key="idx" class="uploaded-item">
<AuthImage
:source="img.url"
fit="cover"
:image-style="{ width: '64px', height: '64px', borderRadius: '4px' }"
/>
<el-button link type="danger" size="small" @click="removeImage(idx)">移除</el-button>
</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 @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @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="状态">
<el-select v-model="editForm.status" style="width: 100%">
<el-option label="待用" value="unused" />
<el-option label="已停用" value="disabled" />
</el-select>
</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="过期时间">
<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-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;
}
.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;
}
.filter-area,
.action-area {
display: flex;
gap: 8px;
align-items: center;
}
.upload-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.batch-upload-area {
width: 100%;
}
.uploaded-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
}
.uploaded-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.expired-tag {
color: var(--el-color-danger);
}
</style>