二维码池批量上传与认证图片预览修复
- 后端新增批量创建二维码接口 POST /admin/chats/qrcodes/batch - 前端二维码管理页支持多图拖拽批量上传 - 注册 qrcode 图片压缩场景(maxSide=2560, quality=0.95) - 后端 normalizeScene 注册 qrcode scene - 增强 AuthImage 组件支持 el-image 大图预览(previewSrcList) - 修复 WithdrawalDetailDialog 提现凭证图片 401 问题 - 删除重复的 components/AuthImage.vue,统一使用共享组件 - 提交3剩余: 会话列表群类型图标、发布成功跳群、管理端路由等
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||
|
||||
export type QrCodeStatus = 'unused' | 'used' | 'disabled'
|
||||
|
||||
export interface ChatQrCode {
|
||||
id: number
|
||||
image_url: string
|
||||
status: QrCodeStatus
|
||||
conversation_id: number | null
|
||||
used_at: string | null
|
||||
expires_at: string | null
|
||||
created_by: number
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface QrCodeStats {
|
||||
unused_count: number
|
||||
used_count: number
|
||||
disabled_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface QrCodeListQuery {
|
||||
status?: QrCodeStatus
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CreateQrCodePayload {
|
||||
image_url: string
|
||||
note?: string
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateQrCodePayload {
|
||||
note?: string
|
||||
status?: QrCodeStatus
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export async function fetchQrCodes(query: QrCodeListQuery = {}) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatQrCode>>>(
|
||||
'/admin/chats/qrcodes',
|
||||
{
|
||||
params: { status: query.status || undefined, page: query.page || 1, limit: query.limit || 20 },
|
||||
silent: true,
|
||||
}
|
||||
)
|
||||
// 后端返回 { data: [...], pagination: { total, page, limit } },适配为 PaginatedResult
|
||||
const resp = data.data as unknown as {
|
||||
data: ChatQrCode[]
|
||||
pagination: { total: number; page: number; limit: number }
|
||||
}
|
||||
return {
|
||||
items: resp.data,
|
||||
total: resp.pagination.total,
|
||||
page: resp.pagination.page,
|
||||
page_size: resp.pagination.limit,
|
||||
} as PaginatedResult<ChatQrCode>
|
||||
}
|
||||
|
||||
export async function fetchQrCodeStats() {
|
||||
const { data } = await apiClient.get<ApiResponse<QrCodeStats>>('/admin/chats/qrcodes/stats', {
|
||||
silent: true,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createQrCode(payload: CreateQrCodePayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<ChatQrCode>>('/admin/chats/qrcodes', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function batchCreateQrCodes(items: CreateQrCodePayload[]) {
|
||||
const { data } = await apiClient.post<ApiResponse<ChatQrCode[]>>('/admin/chats/qrcodes/batch', {
|
||||
items,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateQrCode(id: number, payload: UpdateQrCodePayload) {
|
||||
const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>(
|
||||
`/admin/chats/qrcodes/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteQrCode(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<null>>(`/admin/chats/qrcodes/${id}`)
|
||||
return data.data
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ref } from 'vue'
|
||||
import type { WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
|
||||
import { reviewWithdrawal, confirmPayment } from '@/features/admin/api/adminWithdrawal'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -200,14 +201,14 @@ function accountTypeLabel(type: string) {
|
||||
:span="2"
|
||||
>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap">
|
||||
<el-image
|
||||
<AuthImage
|
||||
v-for="(url, idx) in withdrawal.certificate_urls"
|
||||
:key="idx"
|
||||
:src="url"
|
||||
:source="url"
|
||||
:admin="true"
|
||||
:preview-src-list="withdrawal.certificate_urls"
|
||||
:initial-index="idx"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px; border-radius: 4px; cursor: pointer"
|
||||
:image-style="{ width: '100px', height: '100px', borderRadius: '4px', cursor: 'pointer' }"
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user