Files
hfb_sys/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue
T
yml2213andCursor 1d21b3f548 feat(distribution): 分站发布群只使用自己的二维码池
主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:35:34 +08:00

521 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, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus, Refresh, UploadFilled } from '@element-plus/icons-vue'
import {
batchCreateSiteQrCodes,
deleteSiteQrCode,
fetchSiteQrCodeStats,
fetchSiteQrCodes,
getOCRJobResult,
submitOCRJob,
updateSiteQrCode,
type ChatQrCode,
type CreateQrCodePayload,
type QrCodeStats,
type QrCodeStatus,
} from '@/features/admin/api/adminQrCodes'
import {
fetchDistributionSites,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { uploadAdminFile } from '@/shared/api/files'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
import { optimizeImageForUpload } from '@/shared/utils/imageUpload'
import { useAdminSessionStore } from '@/stores/adminSession'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue'
const adminSession = useAdminSessionStore()
const canManage = computed(() => adminSession.hasPermission('qrcode:manage'))
const sites = ref<DistributionSite[]>([])
const siteId = ref<number | undefined>()
const loading = ref(false)
const qrcodes = ref<ChatQrCode[]>([])
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(20)
const statusFilter = ref<QrCodeStatus | ''>('')
const stats = ref<QrCodeStats>({
unused_count: 0,
used_count: 0,
disabled_count: 0,
total_count: 0,
})
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 map: Record<string, { label: string; type: string }> = {}
statusOptions.forEach(option => {
if (option.value) map[option.value] = { label: option.label, type: option.type }
})
return map
})
const uploadVisible = ref(false)
const uploadSaving = ref(false)
const uploadPendingCount = ref(0)
const uploadNote = ref('')
type UploadedQrImage = {
id: string
url: string
previewUrl: string
file: File
groupName: string
}
const uploadedImages = ref<UploadedQrImage[]>([])
const maxBatchUploadCount = 20
const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value))
async function loadSites() {
sites.value = await fetchDistributionSites()
const firstSite = sites.value[0]
if (!siteId.value && firstSite) {
siteId.value = firstSite.id
}
}
async function loadPool() {
if (!siteId.value) {
qrcodes.value = []
total.value = 0
return
}
loading.value = true
try {
const [list, nextStats] = await Promise.all([
fetchSiteQrCodes(siteId.value, {
status: statusFilter.value || undefined,
page: currentPage.value,
limit: currentPageSize.value,
}),
fetchSiteQrCodeStats(siteId.value),
])
qrcodes.value = list.items
total.value = list.total
stats.value = nextStats
} finally {
loading.value = false
}
}
function handleSiteChange() {
currentPage.value = 1
loadPool()
}
function handleFilter() {
currentPage.value = 1
loadPool()
}
async function recognizeGroupName(file: File) {
const { job_id } = await submitOCRJob(file)
const delays = [1000, 2000, 3000, 3000, 3000]
for (const delay of delays) {
await new Promise(resolve => setTimeout(resolve, delay))
const record = await getOCRJobResult(job_id)
if (record.status === 'done' && record.result) return record.result.group_name || ''
if (record.status === 'failed') throw new Error(record.error || 'OCR 识别失败')
}
throw new Error('OCR 识别超时')
}
async function handleFileUpload(options: { file: File }) {
if (!siteId.value) {
ElMessage.warning('请先选择分站')
return
}
if (uploadedImages.value.length >= maxBatchUploadCount) {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张`)
return
}
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('只能上传图片')
return
}
uploadPendingCount.value += 1
const item: UploadedQrImage = {
id: `${Date.now()}_${Math.random().toString(36).slice(2)}`,
url: '',
previewUrl: URL.createObjectURL(options.file),
file: options.file,
groupName: '',
}
uploadedImages.value.push(item)
try {
const normalized = await optimizeImageForUpload(options.file, 'qrcode')
const [uploaded, groupName] = await Promise.all([
uploadAdminFile(normalized, 'qrcode', { optimize: false }),
recognizeGroupName(normalized).catch(error => {
ElMessage.warning(readError(error, '群名识别失败,可手动填写'))
return ''
}),
])
item.url = uploaded.url
item.groupName = groupName
} catch {
removeImage(item.id)
ElMessage.error('图片上传失败')
} finally {
uploadPendingCount.value = Math.max(0, uploadPendingCount.value - 1)
}
}
function removeImage(id: string) {
const index = uploadedImages.value.findIndex(item => item.id === id)
if (index < 0) return
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
}
function openUpload() {
if (!canManage.value || !siteId.value) return
clearUploadedImages()
uploadNote.value = ''
uploadVisible.value = true
}
async function submitUpload() {
if (!siteId.value) return
if (uploadPendingCount.value > 0) {
ElMessage.warning('图片还在上传,请稍后再保存')
return
}
const ready = uploadedImages.value.filter(item => item.url)
if (ready.length === 0) {
ElMessage.warning('请先上传二维码图片')
return
}
uploadSaving.value = true
try {
const items: CreateQrCodePayload[] = ready.map(item => ({
image_url: item.url,
group_name: item.groupName,
note: uploadNote.value,
}))
await batchCreateSiteQrCodes(siteId.value, items)
ElMessage.success(`已放入${selectedSite.value?.site_name || '该分站'}码池 ${items.length} 张`)
uploadVisible.value = false
await loadPool()
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
uploadSaving.value = false
}
}
async function handleDisable(row: ChatQrCode) {
if (!siteId.value || !canManage.value || row.status === 'disabled') return
await ElMessageBox.confirm('停用后这张码不会再发给该分站的新发布群。', '停用确认', {
type: 'warning',
})
await updateSiteQrCode(siteId.value, row.id, { status: 'disabled' })
ElMessage.success('已停用')
await loadPool()
}
async function handleDelete(row: ChatQrCode) {
if (!siteId.value || !canManage.value) return
await ElMessageBox.confirm('确定删除这张二维码?已发出的记录也会从池里移除。', '删除确认', {
type: 'warning',
})
await deleteSiteQrCode(siteId.value, row.id)
ElMessage.success('已删除')
await loadPool()
}
watch(uploadVisible, visible => {
if (!visible) clearUploadedImages()
})
onMounted(async () => {
await loadSites()
await loadPool()
})
onBeforeUnmount(() => {
clearUploadedImages()
})
</script>
<template>
<section class="site-qr-page">
<header class="page-head">
<div>
<h1>分站二维码池</h1>
<p>每个分站只用自己的池。池空了会等主站补码,不会发主站或其他分站的二维码。</p>
</div>
<el-button
v-if="canManage"
type="primary"
:icon="Plus"
:disabled="!siteId"
@click="openUpload"
>
上传到当前分站
</el-button>
</header>
<div class="toolbar">
<el-select
v-model="siteId"
filterable
placeholder="选择分站"
class="site-select"
@change="handleSiteChange"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name}(${site.site_code})`"
:value="site.id"
/>
</el-select>
<el-select
v-model="statusFilter"
placeholder="状态"
clearable
class="status-select"
@change="handleFilter"
>
<el-option
v-for="option in statusOptions"
:key="option.label"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :icon="Refresh" @click="loadPool">刷新</el-button>
</div>
<div class="stats">
<div>
<strong>{{ stats.unused_count }}</strong>
<span>待用</span>
</div>
<div>
<strong>{{ stats.used_count }}</strong>
<span>已发放</span>
</div>
<div>
<strong>{{ stats.disabled_count }}</strong>
<span>已停用</span>
</div>
<div>
<strong>{{ stats.total_count }}</strong>
<span>合计</span>
</div>
</div>
<el-table v-loading="loading" :data="qrcodes" empty-text="这个分站还没有二维码">
<el-table-column label="二维码" width="96">
<template #default="{ row }">
<AuthImage
:source="row.image_url"
fit="contain"
:image-style="{ width: '64px', height: '64px', background: '#f7f9fc' }"
:preview-src-list="[row.image_url]"
/>
</template>
</el-table-column>
<el-table-column label="群名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.group_name || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="
(statusMap[row.status]?.type as 'success' | 'primary' | 'danger' | 'info') || 'info'
"
size="small"
>
{{ statusMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定群" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{
row.bound_conversation_title || (row.conversation_id ? `#${row.conversation_id}` : '-')
}}
</template>
</el-table-column>
<el-table-column label="过期时间" min-width="170">
<template #default="{ row }">{{
row.expires_at ? formatDateTime(row.expires_at) : '永久'
}}</template>
</el-table-column>
<el-table-column label="备注" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column v-if="canManage" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
link
type="warning"
:disabled="row.status === 'disabled'"
@click="handleDisable(row)"
>
停用
</el-button>
<el-button link type="danger" :icon="Delete" @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="loadPool"
/>
<el-dialog v-model="uploadVisible" title="上传到当前分站" width="640px" destroy-on-close>
<p class="upload-tip">
这些码只发给 {{ selectedSite?.site_name || '当前分站' }} 的发布群。默认 7 天后过期。
</p>
<el-upload
drag
multiple
accept="image/*"
:show-file-list="false"
:http-request="(opt: any) => handleFileUpload(opt)"
:disabled="uploadSaving || uploadedImages.length >= maxBatchUploadCount"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>拖入或点击上传企微群二维码</div>
</el-upload>
<div v-if="uploadedImages.length" class="upload-list">
<div v-for="item in uploadedImages" :key="item.id" class="upload-item">
<img :src="item.previewUrl" alt="" />
<el-input v-model="item.groupName" placeholder="群名,识别失败可手填" />
<el-button link type="danger" @click="removeImage(item.id)">移除</el-button>
</div>
</div>
<el-input
v-model="uploadNote"
class="upload-note"
placeholder="备注,可选,会写到本批每一张"
/>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button
type="primary"
:loading="uploadSaving || uploadPendingCount > 0"
@click="submitUpload"
>
放入该分站码池
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.site-qr-page {
display: grid;
gap: 16px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-head h1 {
margin: 0 0 6px;
font-size: 20px;
}
.page-head p,
.upload-tip {
margin: 0;
color: #64748b;
font-size: 13px;
}
.toolbar,
.stats {
display: flex;
gap: 12px;
align-items: center;
}
.site-select {
width: 280px;
}
.status-select {
width: 140px;
}
.stats div {
min-width: 88px;
padding: 10px 14px;
border: 1px solid #e8eef5;
border-radius: 10px;
background: #fff;
}
.stats strong {
display: block;
font-size: 18px;
}
.stats span {
color: #64748b;
font-size: 12px;
}
.upload-icon {
margin-bottom: 8px;
font-size: 28px;
color: #94a3b8;
}
.upload-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.upload-item {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 10px;
align-items: center;
}
.upload-item img {
width: 56px;
height: 56px;
object-fit: contain;
background: #f8fafc;
}
.upload-note {
margin-top: 12px;
}
</style>