修复二维码库存与过期时间编辑

This commit is contained in:
yml
2026-06-17 20:02:41 +08:00
parent 12f589f0df
commit ac98841992
7 changed files with 286 additions and 95 deletions
@@ -58,11 +58,11 @@ func (h *Handler) ListQrCodesHandler(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": qrcodes, "data": PaginatedResult{
"pagination": gin.H{ Items: qrcodes,
"total": total, Total: total,
"page": req.Page, Page: req.Page,
"limit": req.Limit, PageSize: req.Limit,
}, },
}) })
} }
@@ -12,8 +12,8 @@ import (
// 会话类型常量 // 会话类型常量
const ( const (
ConversationTypeOrderGroup = "order_group" ConversationTypeOrderGroup = "order_group"
ConversationTypeListingGroup = "listing_group" ConversationTypeListingGroup = "listing_group"
ConversationTypeGeneralSupport = "general_support" ConversationTypeGeneralSupport = "general_support"
) )
@@ -297,6 +297,7 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
var count int64 var count int64
if err := tx.Model(&model.ChatQrCode{}). if err := tx.Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUnused). Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", time.Now()).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return err return err
} }
@@ -319,6 +320,7 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
// 发送站内信给所有客服 // 发送站内信给所有客服
var entries []interface{} var entries []interface{}
now := time.Now()
for _, admin := range csAdmins { for _, admin := range csAdmins {
entries = append(entries, map[string]interface{}{ entries = append(entries, map[string]interface{}{
"admin_user_id": admin.ID, "admin_user_id": admin.ID,
@@ -326,13 +328,13 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
"title": "二维码库存预警", "title": "二维码库存预警",
"content": alertContent, "content": alertContent,
"is_read": false, "is_read": false,
"created_at": time.Now(), "created_at": now,
"updated_at": time.Now(), "updated_at": now,
}) })
} }
if len(entries) > 0 { if len(entries) > 0 {
// 批量插入通知 // 批量插入管理员通知
if err := tx.Table("admin_notifications").Create(entries).Error; err != nil { if err := tx.Table("admin_notifications").Create(entries).Error; err != nil {
return err return err
} }
+30 -26
View File
@@ -36,9 +36,11 @@ type BatchCreateQrCodeRequest struct {
// UpdateQrCodeRequest 更新二维码请求 // UpdateQrCodeRequest 更新二维码请求
type UpdateQrCodeRequest struct { type UpdateQrCodeRequest struct {
Note *string `json:"note"` ImageURL *string `json:"image_url"`
Status *string `json:"status"` Note *string `json:"note"`
ExpiresAt *time.Time `json:"expires_at"` Status *string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
ClearExpiresAt bool `json:"clear_expires_at"`
} }
// QrCodeListRequest 列表查询请求 // QrCodeListRequest 列表查询请求
@@ -140,32 +142,29 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]
// GetQrCodeStats 获取二维码统计信息 // GetQrCodeStats 获取二维码统计信息
func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
stats := &QrCodeStats{} stats := &QrCodeStats{}
now := time.Now()
// 统计各状态数量 if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Count(&stats.TotalCount).Error; err != nil {
type CountResult struct {
Status string
Count int64
}
var results []CountResult
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Select("status, COUNT(*) as count").
Group("status").
Find(&results).Error; err != nil {
return nil, err return nil, err
} }
if err := r.db.WithContext(ctx).
for _, r := range results { Model(&model.ChatQrCode{}).
stats.TotalCount += r.Count Where("status = ?", QrCodeStatusUsed).
switch r.Status { Count(&stats.UsedCount).Error; err != nil {
case QrCodeStatusUnused: return nil, err
stats.UnusedCount = r.Count }
case QrCodeStatusUsed: if err := r.db.WithContext(ctx).
stats.UsedCount = r.Count Model(&model.ChatQrCode{}).
case QrCodeStatusDisabled: Where("status = ?", QrCodeStatusDisabled).
stats.DisabledCount = r.Count Count(&stats.DisabledCount).Error; err != nil {
} return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Count(&stats.UnusedCount).Error; err != nil {
return nil, err
} }
return stats, nil return stats, nil
@@ -175,6 +174,9 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error { func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error {
updates := make(map[string]interface{}) updates := make(map[string]interface{})
if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL
}
if req.Note != nil { if req.Note != nil {
updates["note"] = *req.Note updates["note"] = *req.Note
} }
@@ -187,6 +189,8 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo
} }
if req.ExpiresAt != nil { if req.ExpiresAt != nil {
updates["expires_at"] = req.ExpiresAt updates["expires_at"] = req.ExpiresAt
} else if req.ClearExpiresAt {
updates["expires_at"] = nil
} }
if len(updates) == 0 { if len(updates) == 0 {
@@ -113,7 +113,7 @@ func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) {
if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil { if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil {
t.Fatalf("failed to migrate test db: %v", err) t.Fatalf("failed to migrate test db: %v", err)
} }
repo := NewRepository(db) repo := NewRepository(db, nil)
req := CreateRequest{ req := CreateRequest{
Title: "测试账号", Title: "测试账号",
ServerRegion: "QQ", ServerRegion: "QQ",
@@ -0,0 +1,23 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS admin_notifications (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '接收管理员ID',
type VARCHAR(32) NOT NULL COMMENT '通知类型: system系统, chat聊天等',
title VARCHAR(128) NOT NULL COMMENT '通知标题',
content TEXT NULL COMMENT '通知内容',
is_read TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_admin_notifications_user_read_created (admin_user_id, is_read, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员通知消息表';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS admin_notifications;
-- +goose StatementEnd
@@ -36,9 +36,11 @@ export interface CreateQrCodePayload {
} }
export interface UpdateQrCodePayload { export interface UpdateQrCodePayload {
image_url?: string
note?: string note?: string
status?: QrCodeStatus status?: QrCodeStatus
expires_at?: string | null expires_at?: string | null
clear_expires_at?: boolean
} }
export async function fetchQrCodes(query: QrCodeListQuery = {}) { export async function fetchQrCodes(query: QrCodeListQuery = {}) {
@@ -49,17 +51,7 @@ export async function fetchQrCodes(query: QrCodeListQuery = {}) {
silent: true, silent: true,
} }
) )
// 后端返回 { data: [...], pagination: { total, page, limit } },适配为 PaginatedResult return data.data
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() { export async function fetchQrCodeStats() {
@@ -39,8 +39,10 @@ const uploadForm = reactive({
// 编辑弹窗 // 编辑弹窗
const editVisible = ref(false) const editVisible = ref(false)
const editUploading = ref(false)
const editForm = reactive({ const editForm = reactive({
id: 0, id: 0,
image_url: '',
note: '', note: '',
status: 'unused' as QrCodeStatus, status: 'unused' as QrCodeStatus,
expires_at: '' as string, expires_at: '' as string,
@@ -144,17 +146,37 @@ async function submitUpload() {
function openEdit(row: ChatQrCode) { function openEdit(row: ChatQrCode) {
editForm.id = row.id editForm.id = row.id
editForm.image_url = row.image_url
editForm.note = row.note editForm.note = row.note
editForm.status = row.status editForm.status = row.status
editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : '' editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : ''
editVisible.value = true 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() { async function submitEdit() {
await updateQrCode(editForm.id, { await updateQrCode(editForm.id, {
image_url: editForm.image_url,
note: editForm.note, note: editForm.note,
status: editForm.status, status: editForm.status,
expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : null, expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : undefined,
clear_expires_at: !editForm.expires_at,
}) })
ElMessage.success('已更新') ElMessage.success('已更新')
editVisible.value = false editVisible.value = false
@@ -233,61 +255,67 @@ onMounted(reloadAll)
<!-- 列表 --> <!-- 列表 -->
<el-card shadow="never" class="table-card"> <el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="qrcodes" class="table-panel"> <el-table v-loading="loading" :data="qrcodes" class="table-panel qrcode-table" :fit="true">
<el-table-column label="二维码" width="120"> <el-table-column label="二维码" class-name="qr-col" align="center">
<template #default="{ row }"> <template #default="{ row }">
<AuthImage <div class="qrcode-thumb">
:source="row.image_url" <AuthImage
fit="cover" :source="row.image_url"
:image-style="{ width: '72px', height: '72px', borderRadius: '6px' }" fit="cover"
:preview-src-list="[row.image_url]" :image-style="{ width: '56px', height: '56px', borderRadius: '6px' }"
/> :preview-src-list="[row.image_url]"
/>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" class-name="status-col" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="(statusMap[row.status]?.type as any) || 'info'"> <el-tag :type="(statusMap[row.status]?.type as any) || 'info'" size="small">
{{ statusMap[row.status]?.label || row.status }} {{ statusMap[row.status]?.label || row.status }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" min-width="160" show-overflow-tooltip> <el-table-column label="备注" class-name="note-col" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="绑定发布群" width="120">
<template #default="{ row }"> <template #default="{ row }">
{{ row.conversation_id ? `#${row.conversation_id}` : '-' }} <span class="note-cell">{{ row.note || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="过期时间" width="170"> <el-table-column label="绑定群" class-name="group-col" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span :class="{ 'expired-tag': row.expires_at && new Date(row.expires_at) < new Date() }"> <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) : '永久' }} {{ row.expires_at ? formatDateTime(row.expires_at) : '永久' }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="创建时间" width="170"> <el-table-column label="操作" class-name="action-col" align="center">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button> <div class="table-actions">
<el-button <el-button text type="primary" size="small" @click="openEdit(row)">编辑</el-button>
v-if="row.status !== 'disabled'" <el-button
link v-if="row.status !== 'disabled'"
type="warning" text
size="small" type="warning"
@click="handleDisable(row)" size="small"
>停用</el-button @click="handleDisable(row)"
> >停用</el-button
<el-button >
v-if="row.status === 'unused'" <el-button
link v-if="row.status === 'unused'"
type="danger" text
size="small" type="danger"
@click="handleDelete(row)" size="small"
>删除</el-button @click="handleDelete(row)"
> >删除</el-button
>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -355,6 +383,27 @@ onMounted(reloadAll)
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑二维码" width="440px"> <el-dialog v-model="editVisible" title="编辑二维码" width="440px">
<el-form label-width="90px"> <el-form label-width="90px">
<el-form-item label="二维码图片">
<div class="edit-image-area">
<AuthImage
v-if="editForm.image_url"
:source="editForm.image_url"
fit="cover"
:image-style="{ width: '96px', height: '96px', borderRadius: '6px' }"
/>
<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-form-item label="状态">
<el-select v-model="editForm.status" style="width: 100%"> <el-select v-model="editForm.status" style="width: 100%">
<el-option label="待用" value="unused" /> <el-option label="待用" value="unused" />
@@ -365,14 +414,17 @@ onMounted(reloadAll)
<el-input v-model="editForm.note" placeholder="7月企微群A" maxlength="50" /> <el-input v-model="editForm.note" placeholder="7月企微群A" maxlength="50" />
</el-form-item> </el-form-item>
<el-form-item label="过期时间"> <el-form-item label="过期时间">
<el-date-picker <div class="expire-editor">
v-model="editForm.expires_at" <el-date-picker
type="datetime" v-model="editForm.expires_at"
placeholder="留空为永久" type="datetime"
format="YYYY-MM-DD HH:mm" placeholder="留空为永久"
value-format="YYYY-MM-DDTHH:mm" format="YYYY-MM-DD HH:mm"
style="width: 100%" 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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -400,6 +452,15 @@ onMounted(reloadAll)
text-align: center; text-align: center;
} }
.toolbar-card,
.table-card {
border-radius: 10px;
}
.table-card :deep(.el-card__body) {
padding: 20px;
}
.stat-value { .stat-value {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 600;
@@ -426,6 +487,7 @@ onMounted(reloadAll)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px;
} }
.filter-area, .filter-area,
@@ -435,6 +497,101 @@ onMounted(reloadAll)
align-items: center; 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 { .upload-preview {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -463,4 +620,17 @@ onMounted(reloadAll)
.expired-tag { .expired-tag {
color: var(--el-color-danger); color: var(--el-color-danger);
} }
.edit-image-area {
display: flex;
align-items: center;
gap: 12px;
}
.expire-editor {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
</style> </style>