feat(distribution): 分站发布群只使用自己的二维码池

主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-04 18:35:34 +08:00
co-authored by Cursor
parent 951d428364
commit 1d21b3f548
11 changed files with 1123 additions and 250 deletions
@@ -258,3 +258,168 @@ func (h *Handler) DeleteQrCodeHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
func (h *Handler) siteIDParam(c *gin.Context) (uint64, bool) {
siteID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || siteID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分站"})
return 0, false
}
return siteID, true
}
func writeSiteQrCodeError(c *gin.Context, err error) bool {
switch {
case errors.Is(err, ErrQrCodeSiteNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "分销站点不存在"})
case errors.Is(err, ErrQrCodeNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
case errors.Is(err, ErrQrCodeCannotReenable):
c.JSON(http.StatusBadRequest, gin.H{"error": "已发放的二维码不能改回待用"})
default:
if err != nil && err.Error() == "无效的状态值" {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return true
}
return false
}
return true
}
func (h *Handler) ListSiteQrCodesHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
var req QrCodeListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
qrcodes, total, err := h.service.repo.ListSiteQrCodes(c.Request.Context(), siteID, req)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{
"data": PaginatedResult{
Items: qrcodes,
Total: total,
Page: req.Page,
PageSize: req.Limit,
},
})
}
func (h *Handler) GetSiteQrCodeStatsHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
stats, err := h.service.repo.GetSiteQrCodeStats(c.Request.Context(), siteID)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"data": stats})
}
func (h *Handler) BatchCreateSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
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.BatchCreateSiteQrCode(c.Request.Context(), adminID, siteID, req)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"data": qrcodes})
}
func (h *Handler) UpdateSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("qrcodeId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var req UpdateQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.repo.UpdateSiteQrCode(c.Request.Context(), siteID, id, req); err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
func (h *Handler) DeleteSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("qrcodeId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
if err := h.service.repo.DeleteSiteQrCode(c.Request.Context(), siteID, id); err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
func (h *Handler) BatchDeleteSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
var req BatchDeleteQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
deleted, err := h.service.repo.BatchDeleteSiteQrCodes(c.Request.Context(), siteID, req.IDs)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"deleted": deleted}})
}
+36 -35
View File
@@ -96,7 +96,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr
}
}
// 4. 取二维码:分站优先用站点固定码(可复用);否则消耗对应码池。
// 4. 取二维码:主站只用主站池,分站只用自己的池。池空则稍后补发,不跨池借用。
qrcode, qrcodeImageURL, err := resolveListingQrCode(tx, siteID)
if err != nil {
return nil, err
@@ -338,39 +338,15 @@ func resolveListingDistributionSiteID(tx *gorm.DB, listing model.RentalListing)
}
func resolveListingQrCode(tx *gorm.DB, siteID uint64) (*model.ChatQrCode, string, error) {
var scope *uint64
if siteID > 0 {
var site model.DistributionSite
err := tx.Select("id", "listing_qrcode_url").First(&site, siteID).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", err
}
if err == nil {
if url := strings.TrimSpace(site.ListingQrcodeURL); url != "" {
return nil, url, nil
}
}
qrcode, err := fetchUnusedQrCodeForSite(tx, &siteID)
if err != nil {
return nil, "", err
}
if qrcode != nil {
return qrcode, qrcode.ImageURL, nil
}
// 分站没有固定码、也没有自己的码池时,沿用主站二维码池。
mainQR, err := fetchUnusedQrCode(tx)
if err != nil || mainQR == nil {
return nil, "", err
}
return mainQR, mainQR.ImageURL, nil
scope = &siteID
}
qrcode, err := fetchUnusedQrCodeForSite(tx, nil)
if err != nil {
qrcode, err := fetchUnusedQrCodeForSite(tx, scope)
if err != nil || qrcode == nil {
return nil, "", err
}
if qrcode != nil {
return qrcode, qrcode.ImageURL, nil
}
return nil, "", nil
return qrcode, qrcode.ImageURL, nil
}
func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, error) {
@@ -399,6 +375,18 @@ func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, e
return &qrcode, nil
}
func fetchUnusedQrCodeForConversation(tx *gorm.DB, conversationID uint64) (*model.ChatQrCode, error) {
var conversation model.ChatConversation
if err := tx.Select("id", "distribution_site_id").First(&conversation, conversationID).Error; err != nil {
return nil, err
}
var siteID *uint64
if conversation.DistributionSiteID != nil && *conversation.DistributionSiteID > 0 {
siteID = conversation.DistributionSiteID
}
return fetchUnusedQrCodeForSite(tx, siteID)
}
func markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error {
now := time.Now()
return tx.Model(&model.ChatQrCode{}).
@@ -446,12 +434,22 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
return nil, nil
}
// 统计未使用的二维码数量
var count int64
if err := tx.Model(&model.ChatQrCode{}).
// 只统计当前会话所属池子,分站库存不影响主站预警。
countQuery := tx.Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", time.Now()).
Count(&count).Error; err != nil {
Where("expires_at IS NULL OR expires_at > ?", time.Now())
poolLabel := ""
if conversation != nil && conversation.DistributionSiteID != nil && *conversation.DistributionSiteID > 0 {
countQuery = countQuery.Where("distribution_site_id = ?", *conversation.DistributionSiteID)
var site model.DistributionSite
if err := tx.Select("site_name").First(&site, *conversation.DistributionSiteID).Error; err == nil {
poolLabel = strings.TrimSpace(site.SiteName)
}
} else {
countQuery = countQuery.Where("distribution_site_id IS NULL")
}
var count int64
if err := countQuery.Count(&count).Error; err != nil {
return nil, err
}
@@ -471,6 +469,9 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
}
alertContent := strings.ReplaceAll(messageTemplate, "{{.Count}}", fmt.Sprintf("%d", count))
if poolLabel != "" {
alertContent = "分站「" + poolLabel + "」" + alertContent
}
entries := make([]map[string]interface{}, 0, len(csAdmins))
now := time.Now()
+216 -17
View File
@@ -29,6 +29,7 @@ const (
var (
ErrQrCodeNotFound = errors.New("二维码不存在")
ErrQrCodeCannotReenable = errors.New("已发放的二维码不能改回待用")
ErrQrCodeSiteNotFound = errors.New("分销站点不存在")
)
// CreateQrCodeRequest 创建二维码请求
@@ -180,7 +181,8 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]
return applyQrCodeListFilters(
r.db.WithContext(ctx).
Table("chat_qrcode_pool AS q").
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id"),
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id").
Where("q.distribution_site_id IS NULL"),
req,
now,
)
@@ -294,23 +296,24 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
stats := &QrCodeStats{}
now := time.Now()
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Count(&stats.TotalCount).Error; err != nil {
mainPool := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("distribution_site_id IS NULL")
if err := mainPool.Count(&stats.TotalCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).
Where("distribution_site_id IS NULL").
Where("status = ?", QrCodeStatusUsed).
Count(&stats.UsedCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).
Where("distribution_site_id IS NULL").
Where("status = ?", QrCodeStatusDisabled).
Count(&stats.DisabledCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).
Where("distribution_site_id IS NULL").
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Count(&stats.UnusedCount).Error; err != nil {
@@ -351,7 +354,7 @@ func (r *Repository) GetQrCodeOCRConfig(ctx context.Context) (*QrCodeOCRConfig,
// UpdateQrCode 更新二维码
func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error {
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil {
if err := r.db.WithContext(ctx).Where("distribution_site_id IS NULL").First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
@@ -406,7 +409,7 @@ func qrcodeWasIssued(qrcode model.ChatQrCode) bool {
// DeleteQrCode 删除二维码
func (r *Repository) DeleteQrCode(ctx context.Context, id uint64) error {
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil {
if err := r.db.WithContext(ctx).Where("distribution_site_id IS NULL").First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
@@ -422,7 +425,7 @@ func (r *Repository) BatchDeleteQrCodes(ctx context.Context, ids []uint64) (int6
return 0, nil
}
result := r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&model.ChatQrCode{})
result := r.db.WithContext(ctx).Where("id IN ? AND distribution_site_id IS NULL", ids).Delete(&model.ChatQrCode{})
if result.Error != nil {
return 0, result.Error
}
@@ -465,24 +468,28 @@ func (r *Repository) markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversation
func deliverPendingQrCodes(tx *gorm.DB) ([]model.ChatMessage, error) {
deliveredMessages := make([]model.ChatMessage, 0)
skipped := make([]uint64, 0)
for {
var task model.ChatQrCodeDeliveryTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ?", "pending").
Order("created_at ASC, id ASC").
First(&task).Error; err != nil {
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ?", "pending")
if len(skipped) > 0 {
query = query.Where("id NOT IN ?", skipped)
}
if err := query.Order("created_at ASC, id ASC").First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return deliveredMessages, nil
}
return nil, err
}
qrcode, err := fetchUnusedQrCode(tx)
qrcode, err := fetchUnusedQrCodeForConversation(tx, task.ConversationID)
if err != nil {
return nil, err
}
if qrcode == nil {
return deliveredMessages, nil
skipped = append(skipped, task.ID)
continue
}
if err := markQrCodeAsUsed(tx, qrcode.ID, task.ConversationID); err != nil {
@@ -516,6 +523,198 @@ func deliverPendingQrCodes(tx *gorm.DB) ([]model.ChatMessage, error) {
}
}
func (r *Repository) ensureDistributionSite(ctx context.Context, siteID uint64) error {
if siteID == 0 {
return ErrQrCodeSiteNotFound
}
var count int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}).Where("id = ?", siteID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return ErrQrCodeSiteNotFound
}
return nil
}
func (r *Repository) ListSiteQrCodes(ctx context.Context, siteID uint64, req QrCodeListRequest) ([]QrCodeListItem, int64, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return nil, 0, err
}
if req.Page < 1 {
req.Page = 1
}
if req.Limit < 1 || req.Limit > 100 {
req.Limit = 20
}
now := time.Now()
baseQuery := func() *gorm.DB {
return applyQrCodeListFilters(
r.db.WithContext(ctx).
Table("chat_qrcode_pool AS q").
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id").
Where("q.distribution_site_id = ?", siteID),
req,
now,
)
}
var total int64
if err := baseQuery().Count(&total).Error; err != nil {
return nil, 0, err
}
var qrcodes []QrCodeListItem
if err := baseQuery().
Select("q.*, COALESCE(c.title, '') AS bound_conversation_title").
Order("q.id DESC").
Offset((req.Page - 1) * req.Limit).
Limit(req.Limit).
Scan(&qrcodes).Error; err != nil {
return nil, 0, err
}
return qrcodes, total, nil
}
func (r *Repository) GetSiteQrCodeStats(ctx context.Context, siteID uint64) (*QrCodeStats, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return nil, err
}
stats := &QrCodeStats{}
now := time.Now()
base := func() *gorm.DB {
return r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("distribution_site_id = ?", siteID)
}
if err := base().Count(&stats.TotalCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ?", QrCodeStatusUsed).Count(&stats.UsedCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ?", QrCodeStatusDisabled).Count(&stats.DisabledCount).Error; err != nil {
return nil, err
}
if err := base().
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Count(&stats.UnusedCount).Error; err != nil {
return nil, err
}
return stats, nil
}
func (r *Repository) BatchCreateSiteQrCode(ctx context.Context, adminID, siteID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return nil, err
}
var qrcodes []model.ChatQrCode
var deliveredMessages []model.ChatMessage
defaultExpires := time.Now().Add(7 * 24 * time.Hour)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range req.Items {
qr := model.ChatQrCode{
ImageURL: item.ImageURL,
GroupName: item.GroupName,
DistributionSiteID: &siteID,
Status: QrCodeStatusUnused,
CreatedBy: adminID,
Note: item.Note,
ExpiresAt: item.ExpiresAt,
}
if qr.ExpiresAt == nil {
expires := defaultExpires
qr.ExpiresAt = &expires
}
qrcodes = append(qrcodes, qr)
}
if err := tx.Create(&qrcodes).Error; err != nil {
return err
}
var err error
deliveredMessages, err = deliverPendingQrCodes(tx)
return err
})
if err != nil {
return nil, err
}
r.notifyDeliveredQrCodeMessages(deliveredMessages)
return qrcodes, nil
}
func (r *Repository) UpdateSiteQrCode(ctx context.Context, siteID, id uint64, req UpdateQrCodeRequest) error {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return err
}
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
return err
}
return r.applyQrCodeUpdates(ctx, &qrcode, req)
}
func (r *Repository) applyQrCodeUpdates(ctx context.Context, qrcode *model.ChatQrCode, req UpdateQrCodeRequest) error {
updates := make(map[string]interface{})
if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL
}
if req.GroupName != nil {
updates["group_name"] = *req.GroupName
}
if req.Note != nil {
updates["note"] = *req.Note
}
if req.WecomRenamed != nil {
updates["wecom_renamed"] = *req.WecomRenamed
}
if req.Status != nil {
if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled {
return errors.New("无效的状态值")
}
if *req.Status == QrCodeStatusUnused && qrcodeWasIssued(*qrcode) {
return ErrQrCodeCannotReenable
}
updates["status"] = *req.Status
}
if req.ExpiresAt != nil {
updates["expires_at"] = req.ExpiresAt
} else if req.ClearExpiresAt {
updates["expires_at"] = nil
}
if len(updates) == 0 {
return nil
}
return r.db.WithContext(ctx).Model(qrcode).Updates(updates).Error
}
func (r *Repository) DeleteSiteQrCode(ctx context.Context, siteID, id uint64) error {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return err
}
result := r.db.WithContext(ctx).Where("id = ? AND distribution_site_id = ?", id, siteID).Delete(&model.ChatQrCode{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrQrCodeNotFound
}
return nil
}
func (r *Repository) BatchDeleteSiteQrCodes(ctx context.Context, siteID uint64, ids []uint64) (int64, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Where("id IN ? AND distribution_site_id = ?", ids, siteID).Delete(&model.ChatQrCode{})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func (r *Repository) notifyDeliveredQrCodeMessages(messages []model.ChatMessage) {
if r.hub == nil {
return
+115 -19
View File
@@ -480,7 +480,7 @@ func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) {
}
}
func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) {
func TestEnsureListingConversationIgnoresFixedQrAndUsesSitePool(t *testing.T) {
db := setupQrCodeTestDB(t)
site := model.DistributionSite{
SiteCode: "fx_qr_fixed",
@@ -521,26 +521,25 @@ func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) {
var message model.ChatMessage
if err := db.Where("conversation_id = ? AND content_type = ?", conv.ID, "image").First(&message).Error; err != nil {
t.Fatalf("未发送固定二维码: %v", err)
t.Fatalf("未发送分站池二维码: %v", err)
}
if !strings.Contains(string(message.AttachmentURLS), "site-fixed.png") {
t.Fatalf("二维码附件 = %s, want 分站固定码", message.AttachmentURLS)
if strings.Contains(string(message.AttachmentURLS), "site-fixed.png") || !strings.Contains(string(message.AttachmentURLS), "site-pool.png") {
t.Fatalf("二维码附件 = %s, want 分站码池而不是固定图", message.AttachmentURLS)
}
var unusedCount int64
if err := db.Model(&model.ChatQrCode{}).Where("status = ?", QrCodeStatusUnused).Count(&unusedCount).Error; err != nil {
t.Fatalf("统计码池失败: %v", err)
var siteQR model.ChatQrCode
if err := db.Where("distribution_site_id = ?", siteID).First(&siteQR).Error; err != nil {
t.Fatalf("查询分站码失败: %v", err)
}
if unusedCount != 2 {
t.Fatalf("固定码仍应保留码池库存, unused = %d, want 2", unusedCount)
if siteQR.Status != QrCodeStatusUsed {
t.Fatalf("分站码应被消耗, status = %q", siteQR.Status)
}
var taskCount int64
if err := db.Model(&model.ChatQrCodeDeliveryTask{}).Count(&taskCount).Error; err != nil {
t.Fatalf("统计补发任务失败: %v", err)
var mainQR model.ChatQrCode
if err := db.Where("distribution_site_id IS NULL").First(&mainQR).Error; err != nil {
t.Fatalf("查询主站码失败: %v", err)
}
if taskCount != 0 {
t.Fatalf("固定码场景不应创建补发任务, got %d", taskCount)
if mainQR.Status != QrCodeStatusUnused {
t.Fatalf("分站建群不得消耗主站码, status = %q", mainQR.Status)
}
listing2 := model.RentalListing{
@@ -555,11 +554,18 @@ func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) {
}); err != nil {
t.Fatalf("第二次建发布群失败: %v", err)
}
if err := db.Model(&model.ChatQrCode{}).Where("status = ?", QrCodeStatusUnused).Count(&unusedCount).Error; err != nil {
t.Fatalf("再次统计码池失败: %v", err)
if err := db.First(&mainQR, mainQR.ID).Error; err != nil {
t.Fatalf("再次查询主站码失败: %v", err)
}
if unusedCount != 2 {
t.Fatalf("同一分站固定码应可复用, unused = %d, want 2", unusedCount)
if mainQR.Status != QrCodeStatusUnused {
t.Fatalf("分站池空了仍不得借用主站码, status = %q", mainQR.Status)
}
var taskCount int64
if err := db.Model(&model.ChatQrCodeDeliveryTask{}).Where("status = ?", "pending").Count(&taskCount).Error; err != nil {
t.Fatalf("统计补发任务失败: %v", err)
}
if taskCount != 1 {
t.Fatalf("分站池空时应留下补发任务, got %d", taskCount)
}
}
@@ -621,3 +627,93 @@ func TestEnsureListingConversationSiteWithoutFixedQrUsesSitePool(t *testing.T) {
t.Fatalf("分站上架不得消耗主站码池, status = %q", mainQR.Status)
}
}
func TestListQrCodesExcludesSitePool(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
site := model.DistributionSite{
SiteCode: "fx_qr_hidden",
SiteName: "隐藏码池站",
Domain: "hidden-qr.example.com",
Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatalf("创建分销站点失败: %v", err)
}
siteID := site.ID
pool := []model.ChatQrCode{
{ImageURL: "/api/files/object?key=qrcode/main-only.png", Status: QrCodeStatusUnused, GroupName: "主站群"},
{ImageURL: "/api/files/object?key=qrcode/site-only.png", Status: QrCodeStatusUnused, GroupName: "分站群", DistributionSiteID: &siteID},
}
if err := db.Create(&pool).Error; err != nil {
t.Fatalf("创建码池失败: %v", err)
}
items, total, err := repo.ListQrCodes(t.Context(), QrCodeListRequest{Page: 1, Limit: 20})
if err != nil {
t.Fatalf("查询主站码池失败: %v", err)
}
if total != 1 || len(items) != 1 || items[0].GroupName != "主站群" {
t.Fatalf("主站码池 = %+v total %d, want 只有主站群", items, total)
}
siteItems, siteTotal, err := repo.ListSiteQrCodes(t.Context(), siteID, QrCodeListRequest{Page: 1, Limit: 20})
if err != nil {
t.Fatalf("查询分站码池失败: %v", err)
}
if siteTotal != 1 || len(siteItems) != 1 || siteItems[0].GroupName != "分站群" {
t.Fatalf("分站码池 = %+v total %d, want 只有分站群", siteItems, siteTotal)
}
}
func TestDeliverPendingUsesOwnSitePool(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
site := model.DistributionSite{
SiteCode: "fx_qr_pending",
SiteName: "待补发站",
Domain: "pending-qr.example.com",
Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatalf("创建分销站点失败: %v", err)
}
siteID := site.ID
conversation := model.ChatConversation{
Type: ConversationTypeListingGroup,
Title: "分站发布群",
Status: "active",
DistributionSiteID: &siteID,
}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建群聊失败: %v", err)
}
task := model.ChatQrCodeDeliveryTask{ConversationID: conversation.ID, Status: "pending"}
if err := db.Create(&task).Error; err != nil {
t.Fatalf("创建补发任务失败: %v", err)
}
mainQR := model.ChatQrCode{ImageURL: "/api/files/object?key=qrcode/main-pending.png", Status: QrCodeStatusUnused}
if err := db.Create(&mainQR).Error; err != nil {
t.Fatalf("创建主站码失败: %v", err)
}
if _, err := repo.BatchCreateSiteQrCode(t.Context(), 1, siteID, BatchCreateQrCodeRequest{
Items: []CreateQrCodeRequest{{ImageURL: "/api/files/object?key=qrcode/site-pending.png", GroupName: "分站补发"}},
}); err != nil {
t.Fatalf("补充分站码失败: %v", err)
}
var savedTask model.ChatQrCodeDeliveryTask
if err := db.First(&savedTask, task.ID).Error; err != nil {
t.Fatalf("查询补发任务失败: %v", err)
}
if savedTask.Status != "sent" {
t.Fatalf("分站补发任务状态 = %q, want sent", savedTask.Status)
}
if err := db.First(&mainQR, mainQR.ID).Error; err != nil {
t.Fatalf("查询主站码失败: %v", err)
}
if mainQR.Status != QrCodeStatusUnused {
t.Fatalf("补发分站群不得消耗主站码, status = %q", mainQR.Status)
}
}
+6
View File
@@ -687,6 +687,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal)
adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals)
adminRoutes.GET("/distribution/sites/:id/analytics", requirePerm("distribution:site_view"), distributionHandler.GetDashboardAnalytics)
adminRoutes.GET("/distribution/sites/:id/qrcodes", requirePerm("qrcode:view"), chatHandler.ListSiteQrCodesHandler)
adminRoutes.GET("/distribution/sites/:id/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetSiteQrCodeStatsHandler)
adminRoutes.POST("/distribution/sites/:id/qrcodes/batch", requirePerm("qrcode:manage"), chatHandler.BatchCreateSiteQrCodeHandler)
adminRoutes.POST("/distribution/sites/:id/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteSiteQrCodeHandler)
adminRoutes.PATCH("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.UpdateSiteQrCodeHandler)
adminRoutes.DELETE("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.DeleteSiteQrCodeHandler)
adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings)
adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus)
adminRoutes.GET("/distribution/announcements", requirePerm("distribution:announcement_view"), distributionHandler.ListSiteAnnouncements)
+2 -27
View File
@@ -149,7 +149,6 @@ export default function SiteSettings() {
const [awEntry, setAwEntry] = useState<HomeEntryConfig>(defaultAWEntry)
const [coopUseMain, setCoopUseMain] = useState(true)
const [coopEntry, setCoopEntry] = useState<HomeEntryConfig>(defaultCoopEntry)
const [listingQrcode, setListingQrcode] = useState('')
const [savingRatio, setSavingRatio] = useState(false)
// SMS config state
@@ -226,7 +225,6 @@ export default function SiteSettings() {
const coop = parseHomeEntry(found.cooperation_feedback_json, defaultCoopEntry)
setCoopUseMain(coop.useMain)
setCoopEntry(coop.value)
setListingQrcode(found.listing_qrcode_url || '')
try {
const sms = await fetchSiteSMSConfig(id)
@@ -524,7 +522,6 @@ export default function SiteSettings() {
aw_recycle_json: awUseMain ? '' : JSON.stringify(awEntry),
cooperation_feedback_json: coopUseMain ? '' : JSON.stringify(coopEntry),
storefront_text_json: storefrontTextFilled(storefrontText),
listing_qrcode_url: listingQrcode.trim(),
}
const updated = await updateSiteBranding(siteID, payload)
setCurrentSite(updated)
@@ -994,13 +991,13 @@ export default function SiteSettings() {
},
{
key: 'entries',
label: '首页入口与客服二维码',
label: '首页入口',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert
type="info"
showIcon
message="回收和合作入口默认继承主站。关掉继承后,只影响当前分销站首页。客服二维码用于号主发布后进群;留空时先用本站二维码池,没有再使用主站二维码池。"
message="回收和合作入口默认继承主站。关掉继承后,只影响当前分销站首页。发布群进群码由主站在分站二维码池维护。"
/>
<div>
<Typography.Text strong>AW炫彩回收</Typography.Text>
@@ -1020,28 +1017,6 @@ export default function SiteSettings() {
)}
</div>
</div>
<div>
<Typography.Text strong>客服二维码</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 8 }} size="small">
{listingQrcode ? (
<Image src={listingQrcode} width={160} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">未配置,发布群使用主站二维码池。</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, 'support-qrcode', setListingQrcode)}
>
<Button icon={<PictureOutlined />}>上传二维码</Button>
</Upload>
<Button disabled={!listingQrcode} onClick={() => setListingQrcode('')}>
恢复主站
</Button>
</Space>
</Space>
</div>
</Space>
),
},
@@ -170,3 +170,52 @@ export async function batchDeleteQrCodes(ids: number[]) {
)
return data.data
}
function siteQrCodeBase(siteId: number) {
return `/admin/distribution/sites/${siteId}/qrcodes`
}
export async function fetchSiteQrCodes(siteId: number, query: QrCodeListQuery = {}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatQrCode>>>(
siteQrCodeBase(siteId),
{
params: {
status: query.status || undefined,
keyword: query.keyword?.trim() || undefined,
page: query.page || 1,
limit: query.limit || 20,
},
silent: true,
}
)
return data.data
}
export async function fetchSiteQrCodeStats(siteId: number) {
const { data } = await apiClient.get<ApiResponse<QrCodeStats>>(
`${siteQrCodeBase(siteId)}/stats`,
{ silent: true }
)
return data.data
}
export async function batchCreateSiteQrCodes(siteId: number, items: CreateQrCodePayload[]) {
const { data } = await apiClient.post<ApiResponse<ChatQrCode[]>>(
`${siteQrCodeBase(siteId)}/batch`,
{ items }
)
return data.data
}
export async function updateSiteQrCode(siteId: number, id: number, payload: UpdateQrCodePayload) {
const { data } = await apiClient.patch<ApiResponse<null>>(
`${siteQrCodeBase(siteId)}/${id}`,
payload
)
return data.data
}
export async function deleteSiteQrCode(siteId: number, id: number) {
const { data } = await apiClient.delete<ApiResponse<null>>(`${siteQrCodeBase(siteId)}/${id}`)
return data.data
}
@@ -0,0 +1,520 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus, Refresh, UploadFilled } from '@element-plus/icons-vue'
import {
batchCreateSiteQrCodes,
deleteSiteQrCode,
fetchSiteQrCodeStats,
fetchSiteQrCodes,
getOCRJobResult,
submitOCRJob,
updateSiteQrCode,
type ChatQrCode,
type CreateQrCodePayload,
type QrCodeStats,
type QrCodeStatus,
} from '@/features/admin/api/adminQrCodes'
import {
fetchDistributionSites,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { uploadAdminFile } from '@/shared/api/files'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
import { optimizeImageForUpload } from '@/shared/utils/imageUpload'
import { useAdminSessionStore } from '@/stores/adminSession'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue'
const adminSession = useAdminSessionStore()
const canManage = computed(() => adminSession.hasPermission('qrcode:manage'))
const sites = ref<DistributionSite[]>([])
const siteId = ref<number | undefined>()
const loading = ref(false)
const qrcodes = ref<ChatQrCode[]>([])
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(20)
const statusFilter = ref<QrCodeStatus | ''>('')
const stats = ref<QrCodeStats>({
unused_count: 0,
used_count: 0,
disabled_count: 0,
total_count: 0,
})
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 map: Record<string, { label: string; type: string }> = {}
statusOptions.forEach(option => {
if (option.value) map[option.value] = { label: option.label, type: option.type }
})
return map
})
const uploadVisible = ref(false)
const uploadSaving = ref(false)
const uploadPendingCount = ref(0)
const uploadNote = ref('')
type UploadedQrImage = {
id: string
url: string
previewUrl: string
file: File
groupName: string
}
const uploadedImages = ref<UploadedQrImage[]>([])
const maxBatchUploadCount = 20
const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value))
async function loadSites() {
sites.value = await fetchDistributionSites()
const firstSite = sites.value[0]
if (!siteId.value && firstSite) {
siteId.value = firstSite.id
}
}
async function loadPool() {
if (!siteId.value) {
qrcodes.value = []
total.value = 0
return
}
loading.value = true
try {
const [list, nextStats] = await Promise.all([
fetchSiteQrCodes(siteId.value, {
status: statusFilter.value || undefined,
page: currentPage.value,
limit: currentPageSize.value,
}),
fetchSiteQrCodeStats(siteId.value),
])
qrcodes.value = list.items
total.value = list.total
stats.value = nextStats
} finally {
loading.value = false
}
}
function handleSiteChange() {
currentPage.value = 1
loadPool()
}
function handleFilter() {
currentPage.value = 1
loadPool()
}
async function recognizeGroupName(file: File) {
const { job_id } = await submitOCRJob(file)
const delays = [1000, 2000, 3000, 3000, 3000]
for (const delay of delays) {
await new Promise(resolve => setTimeout(resolve, delay))
const record = await getOCRJobResult(job_id)
if (record.status === 'done' && record.result) return record.result.group_name || ''
if (record.status === 'failed') throw new Error(record.error || 'OCR 识别失败')
}
throw new Error('OCR 识别超时')
}
async function handleFileUpload(options: { file: File }) {
if (!siteId.value) {
ElMessage.warning('请先选择分站')
return
}
if (uploadedImages.value.length >= maxBatchUploadCount) {
ElMessage.warning(`单次最多添加 ${maxBatchUploadCount} 张`)
return
}
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('只能上传图片')
return
}
uploadPendingCount.value += 1
const item: UploadedQrImage = {
id: `${Date.now()}_${Math.random().toString(36).slice(2)}`,
url: '',
previewUrl: URL.createObjectURL(options.file),
file: options.file,
groupName: '',
}
uploadedImages.value.push(item)
try {
const normalized = await optimizeImageForUpload(options.file, 'qrcode')
const [uploaded, groupName] = await Promise.all([
uploadAdminFile(normalized, 'qrcode', { optimize: false }),
recognizeGroupName(normalized).catch(error => {
ElMessage.warning(readError(error, '群名识别失败,可手动填写'))
return ''
}),
])
item.url = uploaded.url
item.groupName = groupName
} catch {
removeImage(item.id)
ElMessage.error('图片上传失败')
} finally {
uploadPendingCount.value = Math.max(0, uploadPendingCount.value - 1)
}
}
function removeImage(id: string) {
const index = uploadedImages.value.findIndex(item => item.id === id)
if (index < 0) return
const [removed] = uploadedImages.value.splice(index, 1)
if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl)
}
function clearUploadedImages() {
uploadedImages.value.forEach(item => {
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
})
uploadedImages.value = []
uploadPendingCount.value = 0
}
function openUpload() {
if (!canManage.value || !siteId.value) return
clearUploadedImages()
uploadNote.value = ''
uploadVisible.value = true
}
async function submitUpload() {
if (!siteId.value) return
if (uploadPendingCount.value > 0) {
ElMessage.warning('图片还在上传,请稍后再保存')
return
}
const ready = uploadedImages.value.filter(item => item.url)
if (ready.length === 0) {
ElMessage.warning('请先上传二维码图片')
return
}
uploadSaving.value = true
try {
const items: CreateQrCodePayload[] = ready.map(item => ({
image_url: item.url,
group_name: item.groupName,
note: uploadNote.value,
}))
await batchCreateSiteQrCodes(siteId.value, items)
ElMessage.success(`已放入${selectedSite.value?.site_name || '该分站'}码池 ${items.length} 张`)
uploadVisible.value = false
await loadPool()
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
uploadSaving.value = false
}
}
async function handleDisable(row: ChatQrCode) {
if (!siteId.value || !canManage.value || row.status === 'disabled') return
await ElMessageBox.confirm('停用后这张码不会再发给该分站的新发布群。', '停用确认', {
type: 'warning',
})
await updateSiteQrCode(siteId.value, row.id, { status: 'disabled' })
ElMessage.success('已停用')
await loadPool()
}
async function handleDelete(row: ChatQrCode) {
if (!siteId.value || !canManage.value) return
await ElMessageBox.confirm('确定删除这张二维码?已发出的记录也会从池里移除。', '删除确认', {
type: 'warning',
})
await deleteSiteQrCode(siteId.value, row.id)
ElMessage.success('已删除')
await loadPool()
}
watch(uploadVisible, visible => {
if (!visible) clearUploadedImages()
})
onMounted(async () => {
await loadSites()
await loadPool()
})
onBeforeUnmount(() => {
clearUploadedImages()
})
</script>
<template>
<section class="site-qr-page">
<header class="page-head">
<div>
<h1>分站二维码池</h1>
<p>每个分站只用自己的池。池空了会等主站补码,不会发主站或其他分站的二维码。</p>
</div>
<el-button
v-if="canManage"
type="primary"
:icon="Plus"
:disabled="!siteId"
@click="openUpload"
>
上传到当前分站
</el-button>
</header>
<div class="toolbar">
<el-select
v-model="siteId"
filterable
placeholder="选择分站"
class="site-select"
@change="handleSiteChange"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name}(${site.site_code})`"
:value="site.id"
/>
</el-select>
<el-select
v-model="statusFilter"
placeholder="状态"
clearable
class="status-select"
@change="handleFilter"
>
<el-option
v-for="option in statusOptions"
:key="option.label"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :icon="Refresh" @click="loadPool">刷新</el-button>
</div>
<div class="stats">
<div>
<strong>{{ stats.unused_count }}</strong>
<span>待用</span>
</div>
<div>
<strong>{{ stats.used_count }}</strong>
<span>已发放</span>
</div>
<div>
<strong>{{ stats.disabled_count }}</strong>
<span>已停用</span>
</div>
<div>
<strong>{{ stats.total_count }}</strong>
<span>合计</span>
</div>
</div>
<el-table v-loading="loading" :data="qrcodes" empty-text="这个分站还没有二维码">
<el-table-column label="二维码" width="96">
<template #default="{ row }">
<AuthImage
:source="row.image_url"
fit="contain"
:image-style="{ width: '64px', height: '64px', background: '#f7f9fc' }"
:preview-src-list="[row.image_url]"
/>
</template>
</el-table-column>
<el-table-column label="群名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.group_name || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="
(statusMap[row.status]?.type as 'success' | 'primary' | 'danger' | 'info') || 'info'
"
size="small"
>
{{ statusMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定群" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{
row.bound_conversation_title || (row.conversation_id ? `#${row.conversation_id}` : '-')
}}
</template>
</el-table-column>
<el-table-column label="过期时间" min-width="170">
<template #default="{ row }">{{
row.expires_at ? formatDateTime(row.expires_at) : '永久'
}}</template>
</el-table-column>
<el-table-column label="备注" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column v-if="canManage" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
link
type="warning"
:disabled="row.status === 'disabled'"
@click="handleDisable(row)"
>
停用
</el-button>
<el-button link type="danger" :icon="Delete" @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="loadPool"
/>
<el-dialog v-model="uploadVisible" title="上传到当前分站" width="640px" destroy-on-close>
<p class="upload-tip">
这些码只发给 {{ selectedSite?.site_name || '当前分站' }} 的发布群。默认 7 天后过期。
</p>
<el-upload
drag
multiple
accept="image/*"
:show-file-list="false"
:http-request="(opt: any) => handleFileUpload(opt)"
:disabled="uploadSaving || uploadedImages.length >= maxBatchUploadCount"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>拖入或点击上传企微群二维码</div>
</el-upload>
<div v-if="uploadedImages.length" class="upload-list">
<div v-for="item in uploadedImages" :key="item.id" class="upload-item">
<img :src="item.previewUrl" alt="" />
<el-input v-model="item.groupName" placeholder="群名,识别失败可手填" />
<el-button link type="danger" @click="removeImage(item.id)">移除</el-button>
</div>
</div>
<el-input
v-model="uploadNote"
class="upload-note"
placeholder="备注,可选,会写到本批每一张"
/>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button
type="primary"
:loading="uploadSaving || uploadPendingCount > 0"
@click="submitUpload"
>
放入该分站码池
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.site-qr-page {
display: grid;
gap: 16px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-head h1 {
margin: 0 0 6px;
font-size: 20px;
}
.page-head p,
.upload-tip {
margin: 0;
color: #64748b;
font-size: 13px;
}
.toolbar,
.stats {
display: flex;
gap: 12px;
align-items: center;
}
.site-select {
width: 280px;
}
.status-select {
width: 140px;
}
.stats div {
min-width: 88px;
padding: 10px 14px;
border: 1px solid #e8eef5;
border-radius: 10px;
background: #fff;
}
.stats strong {
display: block;
font-size: 18px;
}
.stats span {
color: #64748b;
font-size: 12px;
}
.upload-icon {
margin-bottom: 8px;
font-size: 28px;
color: #94a3b8;
}
.upload-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.upload-item {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 10px;
align-items: center;
}
.upload-item img {
width: 56px;
height: 56px;
object-fit: contain;
background: #f8fafc;
}
.upload-note {
margin-top: 12px;
}
</style>
@@ -8,8 +8,6 @@ import {
DocumentCopy,
Refresh,
Clock,
Picture,
Upload,
Coin,
Message,
Edit,
@@ -33,8 +31,6 @@ import {
type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult,
} from '@/features/admin/api/adminDistribution'
import { uploadAdminFile } from '@/shared/api/files'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
import {
@@ -110,15 +106,6 @@ const receiveRatioForm = reactive({
receive_cost_delta: 0,
})
const qrDialogVisible = ref(false)
const qrSubmitting = ref(false)
const qrUploading = ref(false)
const qrForm = reactive({
site_id: 0,
site_name: '',
listing_qrcode_url: '',
})
const smsDialogVisible = ref(false)
const smsLoading = ref(false)
const smsSubmitting = ref(false)
@@ -204,51 +191,6 @@ async function handleReceiveRatioSubmit() {
}
}
function handleOpenQrcode(row: DistributionSite) {
qrForm.site_id = row.id
qrForm.site_name = row.site_name
qrForm.listing_qrcode_url = row.listing_qrcode_url || ''
qrDialogVisible.value = true
}
async function uploadSiteQrcode(file: File) {
qrUploading.value = true
try {
const uploaded = await uploadAdminFile(file, 'qrcode')
qrForm.listing_qrcode_url = uploaded.url
ElMessage.success('二维码已上传,保存后生效')
} catch (err) {
ElMessage.error(readError(err, '上传二维码失败'))
} finally {
qrUploading.value = false
}
return false
}
function clearSiteQrcode() {
qrForm.listing_qrcode_url = ''
}
async function handleQrcodeSubmit() {
qrSubmitting.value = true
try {
await updateDistributionSite(qrForm.site_id, {
listing_qrcode_url: qrForm.listing_qrcode_url.trim(),
})
ElMessage.success(
qrForm.listing_qrcode_url.trim()
? `【${qrForm.site_name}】发布群固定二维码已更新`
: `【${qrForm.site_name}】已清空发布群固定二维码`
)
qrDialogVisible.value = false
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '更新发布群二维码失败'))
} finally {
qrSubmitting.value = false
}
}
async function handleOpenSMS(row: DistributionSite) {
smsForm.site_id = row.id
smsForm.site_name = row.site_name
@@ -676,8 +618,8 @@ onMounted(() => {
<div>
<h2 class="page-title">分销站点管理</h2>
<p class="page-subtitle">
开通分销站、配置结算冷却期、收号比例上限与发布群固定二维码。利润按 35% 收号 / 40% 卖出 /
25% 服务费切分。
开通分销站、配置结算冷却期与收号比例上限。发布群进群码在「分站二维码池」按站点维护。利润按
35% 收号 / 40% 卖出 / 25% 服务费切分。
</p>
</div>
<div class="header-actions">
@@ -811,18 +753,6 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布群二维码" width="120" align="center">
<template #default="{ row }">
<AuthImage
v-if="row.listing_qrcode_url"
:source="row.listing_qrcode_url"
fit="cover"
:image-style="{ width: '48px', height: '48px', borderRadius: '6px' }"
:preview-src-list="[row.listing_qrcode_url]"
/>
<el-tag v-else type="info" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
@@ -854,9 +784,6 @@ onMounted(() => {
<el-dropdown-item :icon="Coin" @click="handleOpenReceiveRatio(row)">
收号比例
</el-dropdown-item>
<el-dropdown-item :icon="Picture" @click="handleOpenQrcode(row)">
发布二维码
</el-dropdown-item>
<el-dropdown-item :icon="Message" @click="handleOpenSMS(row)">
短信配置
</el-dropdown-item>
@@ -1089,58 +1016,6 @@ onMounted(() => {
</template>
</el-dialog>
<!-- 发布群固定二维码 -->
<el-dialog v-model="qrDialogVisible" title="发布群固定二维码" width="520px" destroy-on-close>
<el-alert
type="info"
:closable="false"
show-icon
title="号主在该分站上传账号后,会把这张固定二维码发到对应发布群。同一分站可重复使用,不消耗主站二维码池。"
style="margin-bottom: 16px"
/>
<el-form label-width="110px">
<el-form-item label="分销站点">
<el-input :model-value="qrForm.site_name" disabled />
</el-form-item>
<el-form-item label="固定二维码">
<div class="qr-row">
<AuthImage
v-if="qrForm.listing_qrcode_url"
:source="qrForm.listing_qrcode_url"
fit="contain"
:image-style="{
width: '160px',
height: '160px',
borderRadius: '12px',
background: '#f7f9fc',
}"
:preview-src-list="[qrForm.listing_qrcode_url]"
/>
<div v-else class="qr-empty">尚未配置</div>
<div class="qr-actions">
<el-upload
:show-file-list="false"
accept="image/*"
:disabled="qrUploading"
:before-upload="uploadSiteQrcode"
>
<el-button :icon="Upload" :loading="qrUploading">上传二维码</el-button>
</el-upload>
<el-button :disabled="!qrForm.listing_qrcode_url" @click="clearSiteQrcode">
清空
</el-button>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="qrDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="qrSubmitting" @click="handleQrcodeSubmit">
保存设置
</el-button>
</template>
</el-dialog>
<!-- 短信配置对话框 -->
<el-dialog
v-model="smsDialogVisible"
@@ -1457,29 +1332,4 @@ onMounted(() => {
color: #6b7280;
font-size: 13px;
}
.qr-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.qr-empty {
width: 160px;
height: 160px;
border-radius: 12px;
background: #f7f9fc;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.qr-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
+6
View File
@@ -145,6 +145,12 @@ const allNavGroups: NavGroup[] = [
icon: Shop,
permission: 'distribution:site_view',
},
{
label: '分站二维码池',
to: adminPath('distribution/qrcode-pool'),
icon: Picture,
permission: 'qrcode:view',
},
{
label: '分销规则',
to: adminPath('distribution/rules'),
+6
View File
@@ -222,6 +222,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminDistributionSitesView.vue'),
meta: adminMeta,
},
{
path: adminPath('distribution/qrcode-pool'),
name: 'admin-distribution-qrcode-pool',
component: () => import('@/features/admin/views/AdminDistributionQrCodePoolView.vue'),
meta: adminMeta,
},
{
path: adminPath('distribution/rules'),
name: 'admin-distribution-rules',