二维码池批量上传与认证图片预览修复

- 后端新增批量创建二维码接口 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:
yml
2026-06-17 17:57:05 +08:00
parent 54f550b8be
commit 12f589f0df
20 changed files with 715 additions and 33 deletions
@@ -25,6 +25,24 @@ func (h *Handler) CreateQrCodeHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": qrcode})
}
// BatchCreateQrCodeHandler 批量创建二维码
func (h *Handler) BatchCreateQrCodeHandler(c *gin.Context) {
var req BatchCreateQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
adminID := c.GetUint64("admin_id")
qrcodes, err := h.service.repo.BatchCreateQrCode(c.Request.Context(), adminID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": qrcodes})
}
// ListQrCodesHandler 列表查询二维码
func (h *Handler) ListQrCodesHandler(c *gin.Context) {
var req QrCodeListRequest
+31
View File
@@ -29,6 +29,11 @@ type CreateQrCodeRequest struct {
ExpiresAt *time.Time `json:"expires_at"`
}
// BatchCreateQrCodeRequest 批量创建二维码请求
type BatchCreateQrCodeRequest struct {
Items []CreateQrCodeRequest `json:"items" binding:"required,min=1,max=20"`
}
// UpdateQrCodeRequest 更新二维码请求
type UpdateQrCodeRequest struct {
Note *string `json:"note"`
@@ -74,6 +79,32 @@ func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req Creat
return &qrcode, nil
}
// BatchCreateQrCode 批量创建二维码
func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) {
var qrcodes []model.ChatQrCode
defaultExpires := time.Now().Add(7 * 24 * time.Hour)
for _, item := range req.Items {
qr := model.ChatQrCode{
ImageURL: item.ImageURL,
Status: QrCodeStatusUnused,
CreatedBy: adminID,
Note: item.Note,
ExpiresAt: item.ExpiresAt,
}
if qr.ExpiresAt == nil {
qr.ExpiresAt = &defaultExpires
}
qrcodes = append(qrcodes, qr)
}
if err := r.db.WithContext(ctx).Create(&qrcodes).Error; err != nil {
return nil, err
}
return qrcodes, nil
}
// ListQrCodes 列表查询二维码
func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]model.ChatQrCode, int64, error) {
if req.Page < 1 {
+1 -1
View File
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene))
switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement":
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode":
return scene
default:
return "misc"
+1
View File
@@ -553,6 +553,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
// 二维码池管理
adminRoutes.POST("/chats/qrcodes", requirePerm("chat:manage"), chatHandler.CreateQrCodeHandler)
adminRoutes.POST("/chats/qrcodes/batch", requirePerm("chat:manage"), chatHandler.BatchCreateQrCodeHandler)
adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler)
adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler)
adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler)
+2
View File
@@ -11,6 +11,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AuthImage: typeof import('./src/components/AuthImage.vue')['default']
ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
@@ -62,6 +63,7 @@ declare module 'vue' {
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
GuideImage: typeof import('./src/components/GuideImage.vue')['default']
InAppBrowserPrompt: typeof import('./src/components/InAppBrowserPrompt.vue')['default']
MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default']
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vue-tsc -b --noEmit --watch & vite --host 0.0.0.0",
"dev": "vue-tsc -b --noEmit --watch --preserveWatchOutput & vite --host 0.0.0.0",
"build": "vue-tsc -b --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "vue-tsc -b --noEmit",
@@ -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>
+1 -1
View File
@@ -40,7 +40,7 @@ export interface ChatMessage {
sender_avatar: string
is_self: boolean
is_read: boolean
content_type: 'text' | 'system'
content_type: 'text' | 'image' | 'system'
content: string
attachment_urls: string[]
created_at: string
@@ -296,6 +296,9 @@ function handleKeydown(e: KeyboardEvent) {
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
<div v-if="item.attachment_urls.length > 0" class="system-attachments">
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
</div>
</template>
<template v-else>
<div class="avatar" :class="{ self: item.is_self }">
@@ -570,6 +573,11 @@ function handleKeydown(e: KeyboardEvent) {
text-align: center;
}
.system-attachments {
margin-top: 6px;
max-width: 200px;
}
.composer {
flex-shrink: 0;
padding: 12px 16px;
@@ -75,6 +75,13 @@ function roleLabel(role: string) {
return map[role] || '成员'
}
// 按会话类型返回图标前缀与标签:发布群 / 订单群 / 平台客服
function conversationTypeMeta(item: ChatConversation) {
if (item.type === 'listing_group') return { icon: '📢', label: '账号群' }
if (item.type === 'order_group' || item.order_id) return { icon: '📦', label: '订单' }
return { icon: '🎧', label: '平台客服' }
}
function previewText(item: ChatConversation) {
return (
item.last_message_preview ||
@@ -124,16 +131,17 @@ function previewText(item: ChatConversation) {
</div>
<div class="conversation-body">
<div class="conversation-head">
<h2>{{ item.title }}</h2>
<h2>
<span class="conv-type-icon">{{ conversationTypeMeta(item).icon }}</span>
{{ item.title }}
</h2>
<span class="conversation-time">{{
formatDateMinute(item.last_message_at || item.created_at)
}}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span class="order-id">{{
item.order_id ? `订单 #${item.order_id}` : '平台客服'
}}</span>
<span class="order-id">{{ conversationTypeMeta(item).label }}</span>
</div>
<p class="conversation-preview">{{ previewText(item) }}</p>
</div>
@@ -263,6 +271,10 @@ function previewText(item: ChatConversation) {
white-space: nowrap;
}
.conv-type-icon {
margin-right: 4px;
}
.conversation-time {
flex: none;
color: #9ca3af;
@@ -277,6 +277,9 @@ function senderLabel(message: ChatMessage) {
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
<div v-if="item.attachment_urls.length > 0" class="system-attachments">
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
</div>
</template>
<template v-else>
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
@@ -531,6 +534,10 @@ function senderLabel(message: ChatMessage) {
text-align: center;
}
.system-attachments {
margin-top: 6px;
max-width: 180px;
}
.composer {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 42px;
@@ -27,6 +27,7 @@ export interface Listing {
review_status: ListingReviewStatus
review_reason: string
published_at?: string
listing_group_conversation_id?: number
created_at: string
updated_at: string
}
@@ -699,7 +699,12 @@ export function usePublishForm(options: UsePublishFormOptions) {
? '修改成功,等待后台审核'
: '发布成功,等待后台审核'
)
// 新建发布成功后,若已自动建立发布群,跳进该群让号主立即看到欢迎语+二维码
if (!isEditMode.value && listing.listing_group_conversation_id) {
await router.push(`/messages/${listing.listing_group_conversation_id}`)
} else {
await router.push(options.submitSuccessPath)
}
} catch (error) {
options.notifyError(
readError(
+2
View File
@@ -13,6 +13,7 @@ import {
Lock,
Money,
Operation,
Picture,
ScaleToOriginal,
Service,
Shop,
@@ -98,6 +99,7 @@ const allNavGroups: NavGroup[] = [
permission: 'dispute:view',
},
{ label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' },
{ label: '二维码池', to: adminPath('qrcode-pool'), icon: Picture, permission: 'chat:view' },
],
},
{
+6
View File
@@ -66,6 +66,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminChatsView.vue'),
meta: adminMeta,
},
{
path: adminPath('qrcode-pool'),
name: 'admin-qrcode-pool',
component: () => import('@/features/admin/views/AdminQrCodePoolView.vue'),
meta: adminMeta,
},
{
path: adminPath('finance/dashboard'),
name: 'admin-finance-dashboard',
@@ -11,6 +11,10 @@ const props = withDefaults(
fallbackClass?: string
loading?: 'eager' | 'lazy'
decoding?: 'async' | 'sync' | 'auto'
previewSrcList?: string[]
previewTeleported?: boolean
fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
imageStyle?: any
}>(),
{
alt: '',
@@ -19,13 +23,16 @@ const props = withDefaults(
fallbackClass: '',
loading: 'lazy',
decoding: 'async',
previewTeleported: true,
}
)
const imageURL = ref('')
const previewURLs = ref<string[]>([])
const failed = ref(false)
let createdObjectURL = ''
const createdURLs: string[] = []
const usePreview = computed(() => !!props.previewSrcList?.length)
const fallbackText = computed(() => (failed.value ? '图片加载失败' : '图片加载中'))
function extractObjectKey(value: string) {
@@ -41,15 +48,25 @@ function shouldFetchWithAuth(value: string) {
return value.includes('/api/files/object') || value.includes('/api/admin/files/object')
}
function revokeCurrentURL() {
if (!createdObjectURL) return
URL.revokeObjectURL(createdObjectURL)
createdObjectURL = ''
async function resolveURL(url: string): Promise<string> {
if (!url || !shouldFetchWithAuth(url)) return url
const key = extractObjectKey(url)
const blob =
props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(url)
const blobURL = URL.createObjectURL(blob)
createdURLs.push(blobURL)
return blobURL
}
function cleanup() {
createdURLs.forEach((u) => URL.revokeObjectURL(u))
createdURLs.length = 0
}
async function loadImage() {
revokeCurrentURL()
cleanup()
imageURL.value = ''
previewURLs.value = []
failed.value = false
if (!props.source) {
@@ -57,30 +74,35 @@ async function loadImage() {
return
}
if (!shouldFetchWithAuth(props.source)) {
imageURL.value = props.source
return
}
try {
const key = extractObjectKey(props.source)
const blob =
props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(props.source)
createdObjectURL = URL.createObjectURL(blob)
imageURL.value = createdObjectURL
imageURL.value = await resolveURL(props.source)
if (props.previewSrcList?.length) {
previewURLs.value = await Promise.all(props.previewSrcList.map(resolveURL))
}
} catch {
failed.value = true
}
}
watch(() => [props.source, props.admin] as const, loadImage, { immediate: true })
watch(() => [props.source, props.admin, props.previewSrcList] as const, loadImage, {
immediate: true,
deep: true,
})
onBeforeUnmount(revokeCurrentURL)
onBeforeUnmount(cleanup)
</script>
<template>
<el-image
v-if="imageURL && usePreview"
:src="imageURL"
:fit="fit"
:style="imageStyle"
:preview-src-list="previewURLs"
:preview-teleported="previewTeleported"
/>
<img
v-if="imageURL"
v-else-if="imageURL"
:class="imageClass"
:src="imageURL"
:alt="alt"
+5 -2
View File
@@ -7,6 +7,7 @@ const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
dispute: 1920,
handoff: 1920,
realname: 1920,
qrcode: 2560,
}
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
@@ -18,6 +19,7 @@ const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
dispute: 0.86,
handoff: 0.86,
realname: 0.86,
qrcode: 0.95,
}
export async function optimizeImageForUpload(file: File, scene: string) {
@@ -25,10 +27,11 @@ export async function optimizeImageForUpload(file: File, scene: string) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) return file
if (typeof document === 'undefined') return file
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.82
try {
const image = await loadImage(file)
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] || 1600
const quality = IMAGE_UPLOAD_QUALITY[scene] || 0.82
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide)
const canvas = document.createElement('canvas')
canvas.width = width
+2
View File
@@ -52,6 +52,8 @@ function stripVantRemoteIconFont() {
}
export default defineConfig({
// 禁止 vite 清屏,避免把后端启动日志(GIN 路由、迁移等)从终端滚动缓冲里抹掉
clearScreen: false,
plugins: [
vue(),
stripVantRemoteIconFont(),