feat(distribution): 分站发布群只使用自己的二维码池
主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user