feat(distribution): 分站发布群只使用自己的二维码池

主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-04 18:35:34 +08:00
co-authored by Cursor
parent 951d428364
commit 1d21b3f548
11 changed files with 1123 additions and 250 deletions
@@ -0,0 +1,520 @@
<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>