修复二维码库存与过期时间编辑
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user