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

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{
"data": qrcodes,
"pagination": gin.H{
"total": total,
"page": req.Page,
"limit": req.Limit,
"data": PaginatedResult{
Items: qrcodes,
Total: total,
Page: req.Page,
PageSize: req.Limit,
},
})
}
@@ -12,8 +12,8 @@ import (
// 会话类型常量
const (
ConversationTypeOrderGroup = "order_group"
ConversationTypeListingGroup = "listing_group"
ConversationTypeOrderGroup = "order_group"
ConversationTypeListingGroup = "listing_group"
ConversationTypeGeneralSupport = "general_support"
)
@@ -297,6 +297,7 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
var count int64
if err := tx.Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", time.Now()).
Count(&count).Error; err != nil {
return err
}
@@ -319,6 +320,7 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
// 发送站内信给所有客服
var entries []interface{}
now := time.Now()
for _, admin := range csAdmins {
entries = append(entries, map[string]interface{}{
"admin_user_id": admin.ID,
@@ -326,13 +328,13 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
"title": "二维码库存预警",
"content": alertContent,
"is_read": false,
"created_at": time.Now(),
"updated_at": time.Now(),
"created_at": now,
"updated_at": now,
})
}
if len(entries) > 0 {
// 批量插入通知
// 批量插入管理员通知
if err := tx.Table("admin_notifications").Create(entries).Error; err != nil {
return err
}
+30 -26
View File
@@ -36,9 +36,11 @@ type BatchCreateQrCodeRequest struct {
// UpdateQrCodeRequest 更新二维码请求
type UpdateQrCodeRequest struct {
Note *string `json:"note"`
Status *string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
ImageURL *string `json:"image_url"`
Note *string `json:"note"`
Status *string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
ClearExpiresAt bool `json:"clear_expires_at"`
}
// QrCodeListRequest 列表查询请求
@@ -140,32 +142,29 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]
// GetQrCodeStats 获取二维码统计信息
func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
stats := &QrCodeStats{}
now := time.Now()
// 统计各状态数量
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 {
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Count(&stats.TotalCount).Error; err != nil {
return nil, err
}
for _, r := range results {
stats.TotalCount += r.Count
switch r.Status {
case QrCodeStatusUnused:
stats.UnusedCount = r.Count
case QrCodeStatusUsed:
stats.UsedCount = r.Count
case QrCodeStatusDisabled:
stats.DisabledCount = r.Count
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUsed).
Count(&stats.UsedCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusDisabled).
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
@@ -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 {
updates := make(map[string]interface{})
if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL
}
if req.Note != nil {
updates["note"] = *req.Note
}
@@ -187,6 +189,8 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo
}
if req.ExpiresAt != nil {
updates["expires_at"] = req.ExpiresAt
} else if req.ClearExpiresAt {
updates["expires_at"] = nil
}
if len(updates) == 0 {
@@ -113,7 +113,7 @@ func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) {
if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
repo := NewRepository(db)
repo := NewRepository(db, nil)
req := CreateRequest{
Title: "测试账号",
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 {
image_url?: string
note?: string
status?: QrCodeStatus
expires_at?: string | null
clear_expires_at?: boolean
}
export async function fetchQrCodes(query: QrCodeListQuery = {}) {
@@ -49,17 +51,7 @@ export async function fetchQrCodes(query: QrCodeListQuery = {}) {
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>
return data.data
}
export async function fetchQrCodeStats() {
@@ -39,8 +39,10 @@ const uploadForm = reactive({
// 编辑弹窗
const editVisible = ref(false)
const editUploading = ref(false)
const editForm = reactive({
id: 0,
image_url: '',
note: '',
status: 'unused' as QrCodeStatus,
expires_at: '' as string,
@@ -144,17 +146,37 @@ async function submitUpload() {
function openEdit(row: ChatQrCode) {
editForm.id = row.id
editForm.image_url = row.image_url
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
}
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() {
await updateQrCode(editForm.id, {
image_url: editForm.image_url,
note: editForm.note,
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('已更新')
editVisible.value = false
@@ -233,61 +255,67 @@ onMounted(reloadAll)
<!-- 列表 -->
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="qrcodes" class="table-panel">
<el-table-column label="二维码" width="120">
<el-table v-loading="loading" :data="qrcodes" class="table-panel qrcode-table" :fit="true">
<el-table-column label="二维码" class-name="qr-col" align="center">
<template #default="{ row }">
<AuthImage
:source="row.image_url"
fit="cover"
:image-style="{ width: '72px', height: '72px', borderRadius: '6px' }"
:preview-src-list="[row.image_url]"
/>
<div class="qrcode-thumb">
<AuthImage
:source="row.image_url"
fit="cover"
:image-style="{ width: '56px', height: '56px', borderRadius: '6px' }"
:preview-src-list="[row.image_url]"
/>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="状态" class-name="status-col" align="center">
<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 }}
</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">
<el-table-column label="备注" class-name="note-col" show-overflow-tooltip>
<template #default="{ row }">
{{ row.conversation_id ? `#${row.conversation_id}` : '-' }}
<span class="note-cell">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="过期时间" width="170">
<el-table-column label="绑定群" class-name="group-col" align="center">
<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) : '永久' }}
</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">
<el-table-column label="操作" class-name="action-col" align="center">
<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
>
<div class="table-actions">
<el-button text type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="row.status !== 'disabled'"
text
type="warning"
size="small"
@click="handleDisable(row)"
>停用</el-button
>
<el-button
v-if="row.status === 'unused'"
text
type="danger"
size="small"
@click="handleDelete(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
@@ -355,6 +383,27 @@ onMounted(reloadAll)
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑二维码" width="440px">
<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-select v-model="editForm.status" style="width: 100%">
<el-option label="待用" value="unused" />
@@ -365,14 +414,17 @@ onMounted(reloadAll)
<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%"
/>
<div class="expire-editor">
<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-button :disabled="!editForm.expires_at" @click="clearEditExpiresAt">清空</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -400,6 +452,15 @@ onMounted(reloadAll)
text-align: center;
}
.toolbar-card,
.table-card {
border-radius: 10px;
}
.table-card :deep(.el-card__body) {
padding: 20px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
@@ -426,6 +487,7 @@ onMounted(reloadAll)
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.filter-area,
@@ -435,6 +497,101 @@ onMounted(reloadAll)
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 {
display: flex;
flex-direction: column;
@@ -463,4 +620,17 @@ onMounted(reloadAll)
.expired-tag {
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>