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

800 lines
21 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 uploadPendingCount = ref(0)
const uploadSaving = ref(false)
const uploadedImages = ref<{ url: string; file: File }[]>([])
const uploadForm = reactive({
note: '',
expires_at: '' as string,
})
// 编辑弹窗
const editVisible = ref(false)
const editUploading = ref(false)
const editForm = reactive({
id: 0,
image_url: '',
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
})
const maxBatchUploadCount = 20
const uploading = computed(() => uploadPendingCount.value > 0)
const uploadBusy = computed(() => uploading.value || uploadSaving.value)
const uploadedImageCountText = computed(() => `${uploadedImages.value.length}/${maxBatchUploadCount}`)
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 }) {
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 uploaded = await uploadAdminFile(options.file, 'qrcode')
uploadedImages.value.push({ url: uploaded.url, file: options.file })
} catch {
ElMessage.error('图片上传失败')
} finally {
uploadPendingCount.value = Math.max(0, uploadPendingCount.value - 1)
}
}
function removeImage(index: number) {
uploadedImages.value.splice(index, 1)
}
function handleUploadExceed() {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张二维码`)
}
function openUpload() {
uploadedImages.value = []
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,
note: uploadForm.note,
expires_at: expiresAt,
}))
await batchCreateQrCodes(items)
ElMessage.success(`已批量添加 ${items.length} 张二维码`)
uploadVisible.value = false
await reloadAll()
} finally {
uploadSaving.value = false
}
}
function openEdit(row: ChatQrCode) {
editForm.id = row.id
editForm.image_url = row.image_url
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
}
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,
note: editForm.note,
status: editForm.status,
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 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="note-col" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-cell">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="绑定群" class-name="group-col" align="center">
<template #default="{ row }">
<span class="group-cell">{{ row.conversation_id ? `#${row.conversation_id}` : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="过期时间" class-name="expire-col" align="center">
<template #default="{ row }">
<span
class="expire-cell"
: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="操作" 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">
<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-thumb">
<AuthImage
:source="img.url"
fit="cover"
:image-style="{ width: '52px', height: '52px', borderRadius: '6px' }"
:preview-src-list="uploadedImages.map(item => item.url)"
/>
<button
class="uploaded-remove"
type="button"
:disabled="uploadBusy"
@click="removeImage(idx)"
>
×
</button>
</div>
<div class="uploaded-name" :title="img.file.name">{{ img.file.name }}</div>
</div>
</div>
</div>
</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 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="过期时间">
<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: hidden;
}
.qrcode-table :deep(.qr-col) {
width: 12%;
}
.qrcode-table :deep(.status-col) {
width: 10%;
}
.qrcode-table :deep(.note-col) {
width: 40%;
}
.qrcode-table :deep(.group-col) {
width: 10%;
}
.qrcode-table :deep(.expire-col) {
width: 16%;
}
.qrcode-table :deep(.action-col) {
width: 12%;
}
.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;
}
.group-cell,
.expire-cell {
color: #5f6b7a;
font-size: 13px;
}
.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(72px, 1fr));
gap: 10px;
margin-top: 10px;
max-height: 224px;
overflow-y: auto;
padding: 10px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fafbfc;
}
.uploaded-item {
min-width: 0;
}
.uploaded-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
margin: 0 auto;
border: 1px solid #e7ebf2;
border-radius: 8px;
background: #ffffff;
}
.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 {
margin-top: 5px;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 16px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.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>