完善二维码池 OCR 配置与识别

This commit is contained in:
yml
2026-06-18 15:22:46 +08:00
parent e8bdf574f4
commit 35f2d93fdf
16 changed files with 1025 additions and 48 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ type flowServices struct {
} }
func newFlowServices(db *gorm.DB) flowServices { func newFlowServices(db *gorm.DB) flowServices {
listingRepo := listing.NewRepository(db) listingRepo := listing.NewRepository(db, nil)
walletRepo := wallet.NewRepository(db) walletRepo := wallet.NewRepository(db)
configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{}) configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{})
var paymentRepo *payment.Repository var paymentRepo *payment.Repository
+2
View File
@@ -60,12 +60,14 @@ func (ChatMessage) TableName() string {
type ChatQrCode struct { type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"` ImageURL string `gorm:"size:512;not null" json:"image_url"`
GroupName string `gorm:"size:128;not null;default:''" json:"group_name"`
Status string `gorm:"size:16;not null;default:'unused'" json:"status"` Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
ConversationID *uint64 `gorm:"index" json:"conversation_id"` ConversationID *uint64 `gorm:"index" json:"conversation_id"`
UsedAt *time.Time `json:"used_at"` UsedAt *time.Time `json:"used_at"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
CreatedBy uint64 `gorm:"not null" json:"created_by"` CreatedBy uint64 `gorm:"not null" json:"created_by"`
Note string `gorm:"size:255;not null;default:''" json:"note"` Note string `gorm:"size:255;not null;default:''" json:"note"`
WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -1,6 +1,7 @@
package chat package chat
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -78,6 +79,41 @@ func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": stats}) c.JSON(http.StatusOK, gin.H{"data": stats})
} }
// RecognizeQrCodeGroupNameHandler 调用 PaddleOCR API 识别二维码图片中的企业微信群名
func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传二维码图片"})
return
}
defer file.Close()
result, err := h.service.repo.RecognizeQrCodeGroupName(
c.Request.Context(),
header.Filename,
header.Header.Get("Content-Type"),
file,
)
if err != nil {
if errors.Is(err, ErrQrCodeOCRNotConfigured) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统配置中填写 PaddleOCR API Token"})
return
}
if errors.Is(err, ErrQrCodeOCRInvalidFile) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的二维码图片"})
return
}
if errors.Is(err, ErrQrCodeOCRUnavailable) {
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// UpdateQrCodeHandler 更新二维码 // UpdateQrCodeHandler 更新二维码
func (h *Handler) UpdateQrCodeHandler(c *gin.Context) { func (h *Handler) UpdateQrCodeHandler(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+74 -3
View File
@@ -15,6 +15,12 @@ const (
QrCodeStatusUnused = "unused" QrCodeStatusUnused = "unused"
QrCodeStatusUsed = "used" QrCodeStatusUsed = "used"
QrCodeStatusDisabled = "disabled" QrCodeStatusDisabled = "disabled"
qrCodeOCRTokenConfigKey = "integration.paddle_ocr_token"
qrCodeOCRJobURLConfigKey = "integration.paddle_ocr_job_url"
qrCodeOCRModelConfigKey = "integration.paddle_ocr_model"
defaultQrCodeOCRJobURL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
defaultQrCodeOCRModel = "PaddleOCR-VL-1.6"
) )
var ( var (
@@ -26,6 +32,7 @@ var (
// CreateQrCodeRequest 创建二维码请求 // CreateQrCodeRequest 创建二维码请求
type CreateQrCodeRequest struct { type CreateQrCodeRequest struct {
ImageURL string `json:"image_url" binding:"required"` ImageURL string `json:"image_url" binding:"required"`
GroupName string `json:"group_name"`
Note string `json:"note"` Note string `json:"note"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
} }
@@ -38,8 +45,10 @@ type BatchCreateQrCodeRequest struct {
// UpdateQrCodeRequest 更新二维码请求 // UpdateQrCodeRequest 更新二维码请求
type UpdateQrCodeRequest struct { type UpdateQrCodeRequest struct {
ImageURL *string `json:"image_url"` ImageURL *string `json:"image_url"`
GroupName *string `json:"group_name"`
Note *string `json:"note"` Note *string `json:"note"`
Status *string `json:"status"` Status *string `json:"status"`
WecomRenamed *bool `json:"wecom_renamed"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
ClearExpiresAt bool `json:"clear_expires_at"` ClearExpiresAt bool `json:"clear_expires_at"`
} }
@@ -59,10 +68,24 @@ type QrCodeStats struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
} }
// QrCodeListItem 二维码列表项
type QrCodeListItem struct {
model.ChatQrCode
BoundConversationTitle string `json:"bound_conversation_title"`
}
// QrCodeOCRConfig 二维码 OCR 调用配置
type QrCodeOCRConfig struct {
Token string `json:"token"`
JobURL string `json:"job_url"`
Model string `json:"model"`
}
// CreateQrCode 创建二维码 // CreateQrCode 创建二维码
func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) { func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) {
qrcode := model.ChatQrCode{ qrcode := model.ChatQrCode{
ImageURL: req.ImageURL, ImageURL: req.ImageURL,
GroupName: req.GroupName,
Status: QrCodeStatusUnused, Status: QrCodeStatusUnused,
CreatedBy: adminID, CreatedBy: adminID,
Note: req.Note, Note: req.Note,
@@ -90,6 +113,7 @@ func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req
for _, item := range req.Items { for _, item := range req.Items {
qr := model.ChatQrCode{ qr := model.ChatQrCode{
ImageURL: item.ImageURL, ImageURL: item.ImageURL,
GroupName: item.GroupName,
Status: QrCodeStatusUnused, Status: QrCodeStatusUnused,
CreatedBy: adminID, CreatedBy: adminID,
Note: item.Note, Note: item.Note,
@@ -109,7 +133,7 @@ func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req
} }
// ListQrCodes 列表查询二维码 // ListQrCodes 列表查询二维码
func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]model.ChatQrCode, int64, error) { func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]QrCodeListItem, int64, error) {
if req.Page < 1 { if req.Page < 1 {
req.Page = 1 req.Page = 1
} }
@@ -131,9 +155,22 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]
} }
// 查询列表 // 查询列表
var qrcodes []model.ChatQrCode var qrcodes []QrCodeListItem
offset := (req.Page - 1) * req.Limit offset := (req.Page - 1) * req.Limit
if err := query.Order("id DESC").Offset(offset).Limit(req.Limit).Find(&qrcodes).Error; err != nil { if err := r.db.WithContext(ctx).
Table("chat_qrcode_pool AS q").
Select("q.*, COALESCE(c.title, '') AS bound_conversation_title").
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id").
Scopes(func(db *gorm.DB) *gorm.DB {
if req.Status != "" {
return db.Where("q.status = ?", req.Status)
}
return db
}).
Order("q.id DESC").
Offset(offset).
Limit(req.Limit).
Scan(&qrcodes).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -171,6 +208,34 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
return stats, nil return stats, nil
} }
// GetQrCodeOCRConfig 获取前端直连 PaddleOCR 所需配置
func (r *Repository) GetQrCodeOCRConfig(ctx context.Context) (*QrCodeOCRConfig, error) {
config := &QrCodeOCRConfig{
JobURL: defaultQrCodeOCRJobURL,
Model: defaultQrCodeOCRModel,
}
var rows []model.SystemConfig
keys := []string{qrCodeOCRTokenConfigKey, qrCodeOCRJobURLConfigKey, qrCodeOCRModelConfigKey}
if err := r.db.WithContext(ctx).Where("`key` IN ?", keys).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
switch row.Key {
case qrCodeOCRTokenConfigKey:
config.Token = row.Value
case qrCodeOCRJobURLConfigKey:
if row.Value != "" {
config.JobURL = row.Value
}
case qrCodeOCRModelConfigKey:
if row.Value != "" {
config.Model = row.Value
}
}
}
return config, nil
}
// UpdateQrCode 更新二维码 // UpdateQrCode 更新二维码
func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error { func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error {
var qrcode model.ChatQrCode var qrcode model.ChatQrCode
@@ -186,9 +251,15 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo
if req.ImageURL != nil { if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL updates["image_url"] = *req.ImageURL
} }
if req.GroupName != nil {
updates["group_name"] = *req.GroupName
}
if req.Note != nil { if req.Note != nil {
updates["note"] = *req.Note updates["note"] = *req.Note
} }
if req.WecomRenamed != nil {
updates["wecom_renamed"] = *req.WecomRenamed
}
if req.Status != nil { if req.Status != nil {
// 校验状态值 // 校验状态值
if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled { if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled {
+327
View File
@@ -0,0 +1,327 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
)
const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024
qrCodeOCRPollInterval = 2 * time.Second
qrCodeOCRMaxPollTimes = 45
)
var (
ErrQrCodeOCRNotConfigured = errors.New("二维码 OCR Token 未配置")
ErrQrCodeOCRInvalidFile = errors.New("无效的二维码图片")
ErrQrCodeOCRUnavailable = errors.New("二维码 OCR 服务不可用")
)
type QrCodeOCRResult struct {
GroupName string `json:"group_name"`
Candidates []string `json:"candidates"`
RawText string `json:"raw_text"`
}
type paddleOCRJobResponse struct {
Data struct {
JobID string `json:"jobId"`
} `json:"data"`
Message string `json:"message"`
Error string `json:"error"`
}
type paddleOCRJobStatusResponse struct {
Data struct {
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
ResultURL struct {
JSONURL string `json:"jsonUrl"`
} `json:"resultUrl"`
} `json:"data"`
Message string `json:"message"`
Error string `json:"error"`
}
// RecognizeQrCodeGroupName 调用 PaddleOCR 异步 API 识别二维码图片中的群名。
func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, contentType string, reader io.Reader) (*QrCodeOCRResult, error) {
config, err := r.GetQrCodeOCRConfig(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(config.Token) == "" {
return nil, ErrQrCodeOCRNotConfigured
}
data, err := io.ReadAll(io.LimitReader(reader, maxQrCodeOCRFileSize+1))
if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize {
return nil, ErrQrCodeOCRInvalidFile
}
if contentType == "" {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
return nil, ErrQrCodeOCRInvalidFile
}
jobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil {
return nil, err
}
jsonURL, err := waitPaddleOCRJob(ctx, config, jobID)
if err != nil {
return nil, err
}
rawText, err := fetchPaddleOCRText(ctx, jsonURL)
if err != nil {
return nil, err
}
candidates := parseQrCodeGroupNameCandidates(rawText)
groupName := ""
if len(candidates) > 0 {
groupName = candidates[0]
}
return &QrCodeOCRResult{
GroupName: groupName,
Candidates: candidates,
RawText: rawText,
}, nil
}
func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("model", config.Model)
optionalPayload, _ := json.Marshal(map[string]bool{
"useDocOrientationClassify": false,
"useDocUnwarping": false,
"useChartRecognition": false,
})
_ = writer.WriteField("optionalPayload", string(optionalPayload))
partHeader := make(textproto.MIMEHeader)
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(filename)))
partHeader.Set("Content-Type", contentType)
fileWriter, err := writer.CreatePart(partHeader)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
if _, err := fileWriter.Write(data); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req.Header.Set("Authorization", "bearer "+config.Token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
}
var payload paddleOCRJobResponse
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
if payload.Data.JobID == "" {
return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable)
}
return payload.Data.JobID, nil
}
func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
for i := 0; i < qrCodeOCRMaxPollTimes; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
}
var payload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
switch payload.Data.State {
case "done":
if payload.Data.ResultURL.JSONURL == "" {
return "", fmt.Errorf("%w: 未返回识别结果地址", ErrQrCodeOCRUnavailable)
}
return payload.Data.ResultURL.JSONURL, nil
case "failed":
if payload.Data.ErrorMsg != "" {
return "", fmt.Errorf("%w: %s", ErrQrCodeOCRUnavailable, payload.Data.ErrorMsg)
}
return "", ErrQrCodeOCRUnavailable
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(qrCodeOCRPollInterval):
}
}
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
}
func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
return collectPaddleOCRJSONLText(string(body)), nil
}
func collectPaddleOCRJSONLText(raw string) string {
var texts []string
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var value any
if err := json.Unmarshal([]byte(line), &value); err != nil {
texts = append(texts, line)
continue
}
collectPaddleOCRText(value, &texts)
}
return strings.Join(texts, "\n")
}
func collectPaddleOCRText(value any, texts *[]string) {
switch v := value.(type) {
case string:
if strings.TrimSpace(v) != "" {
*texts = append(*texts, v)
}
case []any:
for _, item := range v {
collectPaddleOCRText(item, texts)
}
case map[string]any:
if markdown, ok := v["markdown"].(map[string]any); ok {
if text, ok := markdown["text"].(string); ok && strings.TrimSpace(text) != "" {
*texts = append(*texts, text)
}
}
for key, item := range v {
if key == "images" || key == "outputImages" {
continue
}
collectPaddleOCRText(item, texts)
}
}
}
func parseQrCodeGroupNameCandidates(rawText string) []string {
excluded := []*regexp.Regexp{
regexp.MustCompile(`使用.*微信`),
regexp.MustCompile(`企业微信`),
regexp.MustCompile(`扫码`),
regexp.MustCompile(`加入`),
regexp.MustCompile(`二维码`),
regexp.MustCompile(`有效`),
regexp.MustCompile(`天内`),
regexp.MustCompile(`月\d+日`),
regexp.MustCompile(``),
regexp.MustCompile(`^\d+$`),
}
scores := map[string]int{}
for _, line := range strings.Split(rawText, "\n") {
line = normalizeQrCodeOCRLine(line)
if line == "" || utf8.RuneCountInString(line) < 3 || utf8.RuneCountInString(line) > 64 {
continue
}
if !regexp.MustCompile(`[\p{Han}]`).MatchString(line) {
continue
}
skip := false
for _, pattern := range excluded {
if pattern.MatchString(line) {
skip = true
break
}
}
if skip {
continue
}
scores[line] = scoreQrCodeGroupNameCandidate(line)
}
candidates := make([]string, 0, len(scores))
for candidate := range scores {
candidates = append(candidates, candidate)
}
sort.SliceStable(candidates, func(i, j int) bool {
return scores[candidates[i]] > scores[candidates[j]]
})
return candidates
}
func normalizeQrCodeOCRLine(line string) string {
replacer := strings.NewReplacer(" ", "", "\t", "", "|", "", "", "", "#", "", "*", "", "`", "", ">", "")
line = replacer.Replace(line)
allowed := regexp.MustCompile(`[^\p{Han}a-zA-Z0-9_()()《》\-—·]+`)
return allowed.ReplaceAllString(line, "")
}
func scoreQrCodeGroupNameCandidate(line string) int {
length := utf8.RuneCountInString(line)
score := length
if score > 20 {
score = 20
}
if strings.Contains(line, "群") {
score += 100
}
if strings.Contains(line, "-") || strings.Contains(line, "—") {
score += 30
}
if length >= 6 && length <= 24 {
score += 20
}
return score
}
func escapeQuotes(value string) string {
return strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace(value)
}
+116 -1
View File
@@ -2,6 +2,7 @@ package chat
import ( import (
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -20,12 +21,93 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatalf("无法创建测试数据库: %v", err) t.Fatalf("无法创建测试数据库: %v", err)
} }
if err := db.AutoMigrate(&model.ChatQrCode{}); err != nil { if err := db.AutoMigrate(&model.ChatQrCode{}, &model.ChatConversation{}, &model.SystemConfig{}); err != nil {
t.Fatalf("数据库迁移失败: %v", err) t.Fatalf("数据库迁移失败: %v", err)
} }
return db return db
} }
func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil)
rows := []model.SystemConfig{
{Key: qrCodeOCRTokenConfigKey, Value: "test-token"},
{Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"},
{Key: qrCodeOCRModelConfigKey, Value: "PaddleOCR-Test"},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("创建系统配置失败: %v", err)
}
config, err := repo.GetQrCodeOCRConfig(t.Context())
if err != nil {
t.Fatalf("读取 OCR 配置失败: %v", err)
}
if config.Token != "test-token" {
t.Fatalf("token = %q", config.Token)
}
if config.JobURL != "https://example.test/ocr/jobs" {
t.Fatalf("job_url = %q", config.JobURL)
}
if config.Model != "PaddleOCR-Test" {
t.Fatalf("model = %q", config.Model)
}
}
func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil)
conversation := model.ChatConversation{
Title: "账号群 L202606180001",
Type: ConversationTypeListingGroup,
Status: "active",
}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建群聊失败: %v", err)
}
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/group.png",
GroupName: "王大锤-鼠鼠跑刀一群",
Status: QrCodeStatusUsed,
ConversationID: &conversation.ID,
CreatedBy: 1,
}
if err := db.Create(&qrcode).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 {
t.Fatalf("total = %d, len = %d, want 1", total, len(items))
}
if items[0].GroupName != "王大锤-鼠鼠跑刀一群" {
t.Fatalf("群名 = %q", items[0].GroupName)
}
if items[0].BoundConversationTitle != "账号群 L202606180001" {
t.Fatalf("绑定群名 = %q", items[0].BoundConversationTitle)
}
}
func TestParseQrCodeGroupNameCandidates(t *testing.T) {
rawText := strings.Join([]string{
"使用微信或企业微信扫码加入",
"王大锤-鼠鼠跑刀一群",
"这二维码7天内有效",
}, "\n")
candidates := parseQrCodeGroupNameCandidates(rawText)
if len(candidates) == 0 {
t.Fatal("未解析出群名候选")
}
if candidates[0] != "王大锤-鼠鼠跑刀一群" {
t.Fatalf("首个候选 = %q", candidates[0])
}
}
func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil)
@@ -103,3 +185,36 @@ func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) {
t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusDisabled) t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusDisabled)
} }
} }
func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil)
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/unused.png",
Status: QrCodeStatusUnused,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
groupName := "王大锤-鼠鼠跑刀一群"
renamed := true
if err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{
GroupName: &groupName,
WecomRenamed: &renamed,
}); err != nil {
t.Fatalf("更新二维码失败: %v", err)
}
var saved model.ChatQrCode
if err := db.First(&saved, qrcode.ID).Error; err != nil {
t.Fatalf("查询二维码失败: %v", err)
}
if saved.GroupName != groupName {
t.Fatalf("群名 = %q, want %q", saved.GroupName, groupName)
}
if !saved.WecomRenamed {
t.Fatal("企微改名标记未保存")
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ type ConfigDTO struct {
} }
type UpdateRequest struct { type UpdateRequest struct {
Value string `json:"value" binding:"required"` Value string `json:"value"`
Description string `json:"description"` Description string `json:"description"`
} }
@@ -39,6 +39,9 @@ var defaultConfigs = []defaultConfig{
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
{Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"}, {Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"},
{Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"}, {Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"},
{Key: "integration.paddle_ocr_token", Value: "", Description: "PaddleOCR API Token,用于二维码群名自动识别"},
{Key: "integration.paddle_ocr_job_url", Value: "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", Description: "PaddleOCR 异步任务接口地址"},
{Key: "integration.paddle_ocr_model", Value: "PaddleOCR-VL-1.6", Description: "PaddleOCR 识别模型"},
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
@@ -53,6 +56,9 @@ var adminVisibleConfigKeys = []string{
"handoff.owner_return_confirm_timeout_minutes", "handoff.owner_return_confirm_timeout_minutes",
"handoff.owner_submit_timeout_minutes", "handoff.owner_submit_timeout_minutes",
"handoff.renter_confirm_timeout_minutes", "handoff.renter_confirm_timeout_minutes",
"integration.paddle_ocr_job_url",
"integration.paddle_ocr_model",
"integration.paddle_ocr_token",
"listing.publish_agreements", "listing.publish_agreements",
"listing.publish_options", "listing.publish_options",
"listing.review_required", "listing.review_required",
@@ -8,7 +8,7 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
if key == "" || req.Value == "" { if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig
} }
return s.repo.Update(ctx, actorID, key, req, meta) return s.repo.Update(ctx, actorID, key, req, meta)
+1
View File
@@ -567,6 +567,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/chats/qrcodes/batch", requirePerm("chat:manage"), chatHandler.BatchCreateQrCodeHandler) adminRoutes.POST("/chats/qrcodes/batch", requirePerm("chat:manage"), chatHandler.BatchCreateQrCodeHandler)
adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler) adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler)
adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler) adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler)
adminRoutes.POST("/chats/qrcodes/ocr-group-name", requirePerm("chat:manage"), chatHandler.RecognizeQrCodeGroupNameHandler)
adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler) adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler)
adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler) adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler)
@@ -0,0 +1,31 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE chat_qrcode_pool
ADD COLUMN group_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '企业微信群名' AFTER image_url,
ADD COLUMN wecom_renamed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '企业微信侧已改名标记' AFTER note;
INSERT INTO system_configs (`key`, `value`, description) VALUES
('integration.paddle_ocr_token', '', 'PaddleOCR API Token,用于二维码群名自动识别'),
('integration.paddle_ocr_job_url', 'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs', 'PaddleOCR 异步任务接口地址'),
('integration.paddle_ocr_model', 'PaddleOCR-VL-1.6', 'PaddleOCR 识别模型')
ON DUPLICATE KEY UPDATE
description = VALUES(description);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE chat_qrcode_pool
DROP COLUMN wecom_renamed,
DROP COLUMN group_name;
DELETE FROM system_configs
WHERE `key` IN (
'integration.paddle_ocr_token',
'integration.paddle_ocr_job_url',
'integration.paddle_ocr_model'
);
-- +goose StatementEnd
@@ -6,12 +6,15 @@ export type QrCodeStatus = 'unused' | 'used' | 'disabled'
export interface ChatQrCode { export interface ChatQrCode {
id: number id: number
image_url: string image_url: string
group_name: string
status: QrCodeStatus status: QrCodeStatus
conversation_id: number | null conversation_id: number | null
bound_conversation_title: string
used_at: string | null used_at: string | null
expires_at: string | null expires_at: string | null
created_by: number created_by: number
note: string note: string
wecom_renamed: boolean
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -23,6 +26,12 @@ export interface QrCodeStats {
total_count: number total_count: number
} }
export interface QrCodeOcrResult {
group_name: string
candidates: string[]
raw_text: string
}
export interface QrCodeListQuery { export interface QrCodeListQuery {
status?: QrCodeStatus status?: QrCodeStatus
page?: number page?: number
@@ -31,14 +40,17 @@ export interface QrCodeListQuery {
export interface CreateQrCodePayload { export interface CreateQrCodePayload {
image_url: string image_url: string
group_name?: string
note?: string note?: string
expires_at?: string | null expires_at?: string | null
} }
export interface UpdateQrCodePayload { export interface UpdateQrCodePayload {
image_url?: string image_url?: string
group_name?: string
note?: string note?: string
status?: QrCodeStatus status?: QrCodeStatus
wecom_renamed?: boolean
expires_at?: string | null expires_at?: string | null
clear_expires_at?: boolean clear_expires_at?: boolean
} }
@@ -73,6 +85,20 @@ export async function batchCreateQrCodes(items: CreateQrCodePayload[]) {
return data.data return data.data
} }
export async function recognizeQrCodeGroupName(file: File) {
const form = new FormData()
form.append('file', file)
const { data } = await apiClient.post<ApiResponse<QrCodeOcrResult>>(
'/admin/chats/qrcodes/ocr-group-name',
form,
{
headers: { 'Content-Type': 'multipart/form-data' },
silent: true,
}
)
return data.data
}
export async function updateQrCode(id: number, payload: UpdateQrCodePayload) { export async function updateQrCode(id: number, payload: UpdateQrCodePayload) {
const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>( const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>(
`/admin/chats/qrcodes/${id}`, `/admin/chats/qrcodes/${id}`,
@@ -39,6 +39,8 @@ const isStructuredConfig = computed(() => {
return trimmed.startsWith('{') || trimmed.startsWith('[') return trimmed.startsWith('{') || trimmed.startsWith('[')
}) })
const isSecretConfig = computed(() => props.config.key.includes('token'))
const selectOptions = computed<SystemConfigOption[] | null>(() => { const selectOptions = computed<SystemConfigOption[] | null>(() => {
const options = getSystemConfigSelectOptions(props.config.key) const options = getSystemConfigSelectOptions(props.config.key)
if (!options) return null if (!options) return null
@@ -92,7 +94,12 @@ async function handleSave() {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-else label="配置值" class="full-control"> <el-form-item v-else label="配置值" class="full-control">
<el-input v-model="value" placeholder="配置值" /> <el-input
v-model="value"
:type="isSecretConfig ? 'password' : 'text'"
:show-password="isSecretConfig"
placeholder="配置值"
/>
</el-form-item> </el-form-item>
<el-form-item label="配置说明" class="desc-item"> <el-form-item label="配置说明" class="desc-item">
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue' import { Check, CopyDocument, Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'
import { import {
batchCreateQrCodes, batchCreateQrCodes,
deleteQrCode, deleteQrCode,
fetchQrCodeStats, fetchQrCodeStats,
fetchQrCodes, fetchQrCodes,
recognizeQrCodeGroupName,
updateQrCode, updateQrCode,
type ChatQrCode, type ChatQrCode,
type CreateQrCodePayload, type CreateQrCodePayload,
@@ -18,6 +19,7 @@ import { uploadAdminFile } from '@/shared/api/files'
import { formatDateTime } from '@/shared/utils/time' import { formatDateTime } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue' import AdminTablePagination from '../components/AdminTablePagination.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue' import AuthImage from '@/shared/components/business/AuthImage.vue'
import { readError } from '@/shared/utils/error'
const loading = ref(false) const loading = ref(false)
const qrcodes = ref<ChatQrCode[]>([]) const qrcodes = ref<ChatQrCode[]>([])
@@ -32,7 +34,15 @@ const statusFilter = ref<QrCodeStatus | ''>('')
const uploadVisible = ref(false) const uploadVisible = ref(false)
const uploadPendingCount = ref(0) const uploadPendingCount = ref(0)
const uploadSaving = ref(false) const uploadSaving = ref(false)
const uploadedImages = ref<{ url: string; file: File }[]>([]) type UploadedQrImage = {
url: string
previewUrl: string
file: File
groupName: string
ocrStatus: 'recognizing' | 'done' | 'failed'
}
const uploadedImages = ref<UploadedQrImage[]>([])
const uploadForm = reactive({ const uploadForm = reactive({
note: '', note: '',
expires_at: '' as string, expires_at: '' as string,
@@ -44,8 +54,10 @@ const editUploading = ref(false)
const editForm = reactive({ const editForm = reactive({
id: 0, id: 0,
image_url: '', image_url: '',
group_name: '',
note: '', note: '',
status: 'unused' as QrCodeStatus, status: 'unused' as QrCodeStatus,
wecom_renamed: false,
expires_at: '' as string, expires_at: '' as string,
was_issued: false, was_issued: false,
}) })
@@ -75,6 +87,7 @@ const maxBatchUploadCount = 20
const uploading = computed(() => uploadPendingCount.value > 0) const uploading = computed(() => uploadPendingCount.value > 0)
const uploadBusy = computed(() => uploading.value || uploadSaving.value) const uploadBusy = computed(() => uploading.value || uploadSaving.value)
const uploadedImageCountText = computed(() => `${uploadedImages.value.length}/${maxBatchUploadCount}`) const uploadedImageCountText = computed(() => `${uploadedImages.value.length}/${maxBatchUploadCount}`)
const renameLoadingIds = ref(new Set<number>())
async function loadList() { async function loadList() {
loading.value = true loading.value = true
@@ -108,6 +121,39 @@ function handlePageChange() {
loadList() loadList()
} }
function parseGroupNameFromOcrText(text: string) {
const lines = text
.split(/\r?\n/)
.map(line => line.replace(/\s+/g, '').replace(/[|]/g, ''))
.map(line => line.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_()()《》\-—·]+/g, ''))
.filter(Boolean)
const excludedPatterns = [
/使用.*微信/,
/企业微信/,
/扫码/,
/加入/,
/二维码/,
/有效/,
/天内/,
/前/,
/^\d+$/,
]
const candidates = lines.filter(line => {
if (line.length < 3 || line.length > 64) return false
if (!/[\u4e00-\u9fa5]/.test(line)) return false
return !excludedPatterns.some(pattern => pattern.test(line))
})
return candidates.find(line => line.includes('群')) || candidates.find(line => /[-]/.test(line)) || candidates[0] || ''
}
async function recognizeGroupName(file: File) {
const result = await recognizeQrCodeGroupName(file)
return result.group_name || parseGroupNameFromOcrText(result.raw_text || '')
}
// 上传:逐个上传图片拿 URL // 上传:逐个上传图片拿 URL
async function handleFileUpload(options: { file: File }) { async function handleFileUpload(options: { file: File }) {
if (uploadedImages.value.length + uploadPendingCount.value >= maxBatchUploadCount) { if (uploadedImages.value.length + uploadPendingCount.value >= maxBatchUploadCount) {
@@ -121,17 +167,54 @@ async function handleFileUpload(options: { file: File }) {
uploadPendingCount.value += 1 uploadPendingCount.value += 1
try { try {
const uploaded = await uploadAdminFile(options.file, 'qrcode') const item: UploadedQrImage = {
uploadedImages.value.push({ url: uploaded.url, file: options.file }) url: '',
previewUrl: URL.createObjectURL(options.file),
file: options.file,
groupName: '',
ocrStatus: 'recognizing',
}
uploadedImages.value.push(item)
const [uploaded, groupName] = await Promise.all([
uploadAdminFile(options.file, 'qrcode'),
recognizeGroupName(options.file).catch(error => {
console.warn('二维码群名 OCR 失败', error)
ElMessage.warning(readError(error, '二维码群名 OCR 失败'))
return ''
}),
])
item.url = uploaded.url
item.groupName = groupName
item.ocrStatus = groupName ? 'done' : 'failed'
if (!groupName) {
console.warn('二维码群名 OCR 未识别到有效群名', options.file.name)
}
} catch { } catch {
const index = uploadedImages.value.findIndex(item => item.file === options.file && !item.url)
if (index >= 0) removeImage(index)
ElMessage.error('图片上传失败') ElMessage.error('图片上传失败')
} finally { } finally {
uploadPendingCount.value = Math.max(0, uploadPendingCount.value - 1) uploadPendingCount.value = Math.max(0, uploadPendingCount.value - 1)
} }
} }
function imagePreviewSources() {
return uploadedImages.value.map(item => item.url).filter(Boolean)
}
function removeImage(index: number) { function removeImage(index: number) {
uploadedImages.value.splice(index, 1) 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 = []
} }
function handleUploadExceed() { function handleUploadExceed() {
@@ -139,7 +222,7 @@ function handleUploadExceed() {
} }
function openUpload() { function openUpload() {
uploadedImages.value = [] clearUploadedImages()
uploadForm.note = '' uploadForm.note = ''
uploadForm.expires_at = '' uploadForm.expires_at = ''
uploadVisible.value = true uploadVisible.value = true
@@ -161,6 +244,7 @@ async function submitUpload() {
: null : null
const items: CreateQrCodePayload[] = uploadedImages.value.map(img => ({ const items: CreateQrCodePayload[] = uploadedImages.value.map(img => ({
image_url: img.url, image_url: img.url,
group_name: img.groupName,
note: uploadForm.note, note: uploadForm.note,
expires_at: expiresAt, expires_at: expiresAt,
})) }))
@@ -173,11 +257,17 @@ async function submitUpload() {
} }
} }
onBeforeUnmount(() => {
clearUploadedImages()
})
function openEdit(row: ChatQrCode) { function openEdit(row: ChatQrCode) {
editForm.id = row.id editForm.id = row.id
editForm.image_url = row.image_url editForm.image_url = row.image_url
editForm.group_name = row.group_name || ''
editForm.note = row.note editForm.note = row.note
editForm.status = row.status editForm.status = row.status
editForm.wecom_renamed = row.wecom_renamed
editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : '' editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : ''
editForm.was_issued = row.status === 'used' || Boolean(row.conversation_id || row.used_at) editForm.was_issued = row.status === 'used' || Boolean(row.conversation_id || row.used_at)
editVisible.value = true editVisible.value = true
@@ -203,8 +293,10 @@ async function handleEditImageUpload(options: { file: File }) {
async function submitEdit() { async function submitEdit() {
await updateQrCode(editForm.id, { await updateQrCode(editForm.id, {
image_url: editForm.image_url, image_url: editForm.image_url,
group_name: editForm.group_name,
note: editForm.note, note: editForm.note,
status: editForm.status, status: editForm.status,
wecom_renamed: editForm.wecom_renamed,
expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : undefined, expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : undefined,
clear_expires_at: !editForm.expires_at, clear_expires_at: !editForm.expires_at,
}) })
@@ -213,6 +305,51 @@ async function submitEdit() {
await reloadAll() await reloadAll()
} }
async function copyText(text: string, successMessage: string) {
if (!text) {
ElMessage.warning('暂无可复制内容')
return
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success(successMessage)
} catch {
ElMessage.error('复制失败')
}
}
function boundConversationLabel(row: ChatQrCode) {
if (row.bound_conversation_title) return row.bound_conversation_title
if (row.conversation_id) return `#${row.conversation_id}`
return ''
}
function setRenameLoading(id: number, loading: boolean) {
const next = new Set(renameLoadingIds.value)
if (loading) {
next.add(id)
} else {
next.delete(id)
}
renameLoadingIds.value = next
}
async function handleRenameFlagChange(row: ChatQrCode, value: string | number | boolean) {
const nextValue = Boolean(value)
const previous = !nextValue
row.wecom_renamed = nextValue
setRenameLoading(row.id, true)
try {
await updateQrCode(row.id, { wecom_renamed: nextValue })
ElMessage.success(nextValue ? '已标记企微已改名' : '已取消企微改名标记')
} catch {
row.wecom_renamed = previous
ElMessage.error('标记失败')
} finally {
setRenameLoading(row.id, false)
}
}
async function handleDisable(row: ChatQrCode) { async function handleDisable(row: ChatQrCode) {
await ElMessageBox.confirm('确定停用该二维码?停用后不再发放给新发布群。', '停用确认', { await ElMessageBox.confirm('确定停用该二维码?停用后不再发放给新发布群。', '停用确认', {
type: 'warning', type: 'warning',
@@ -305,14 +442,48 @@ onMounted(reloadAll)
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="群名" class-name="name-col" show-overflow-tooltip>
<template #default="{ row }">
<button
class="copy-cell-button"
type="button"
:disabled="!row.group_name"
@click="copyText(row.group_name, '群名已复制')"
>
<span>{{ row.group_name || '-' }}</span>
<el-icon v-if="row.group_name"><CopyDocument /></el-icon>
</button>
</template>
</el-table-column>
<el-table-column label="备注" class-name="note-col" show-overflow-tooltip> <el-table-column label="备注" class-name="note-col" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="note-cell">{{ row.note || '-' }}</span> <span class="note-cell">{{ row.note || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="绑定群" class-name="group-col" align="center"> <el-table-column label="绑定群" class-name="group-col" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="group-cell">{{ row.conversation_id ? `#${row.conversation_id}` : '-' }}</span> <button
class="copy-cell-button"
type="button"
:disabled="!boundConversationLabel(row)"
@click="copyText(boundConversationLabel(row), '绑定群聊名称已复制')"
>
<span>{{ boundConversationLabel(row) || '-' }}</span>
<el-icon v-if="boundConversationLabel(row)"><CopyDocument /></el-icon>
</button>
</template>
</el-table-column>
<el-table-column label="企微改名" class-name="rename-col" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.wecom_renamed"
:loading="renameLoadingIds.has(row.id)"
inline-prompt
:active-icon="Check"
active-text="已改"
inactive-text="未改"
@change="(value: string | number | boolean) => handleRenameFlagChange(row, value)"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="过期时间" class-name="expire-col" align="center"> <el-table-column label="过期时间" class-name="expire-col" align="center">
@@ -360,7 +531,7 @@ onMounted(reloadAll)
</el-card> </el-card>
<!-- 上传弹窗 --> <!-- 上传弹窗 -->
<el-dialog v-model="uploadVisible" title="批量添加企业微信群二维码" width="680px"> <el-dialog v-model="uploadVisible" title="批量添加企业微信群二维码" width="680px" @close="clearUploadedImages">
<el-form class="qrcode-upload-form" label-width="108px"> <el-form class="qrcode-upload-form" label-width="108px">
<el-form-item label="二维码图片" required> <el-form-item label="二维码图片" required>
<div class="batch-upload-area"> <div class="batch-upload-area">
@@ -386,23 +557,50 @@ onMounted(reloadAll)
</el-upload> </el-upload>
<div v-if="uploadedImages.length" class="uploaded-list"> <div v-if="uploadedImages.length" class="uploaded-list">
<div v-for="(img, idx) in uploadedImages" :key="idx" class="uploaded-item"> <div v-for="(img, idx) in uploadedImages" :key="idx" class="uploaded-item">
<div class="uploaded-thumb"> <div class="uploaded-main">
<AuthImage <div class="uploaded-thumb">
:source="img.url" <AuthImage
fit="cover" v-if="img.url"
:image-style="{ width: '52px', height: '52px', borderRadius: '6px' }" :source="img.url"
:preview-src-list="uploadedImages.map(item => item.url)" fit="cover"
:image-style="{ width: '52px', height: '52px', borderRadius: '6px' }"
:preview-src-list="imagePreviewSources()"
/>
<img
v-else
class="uploaded-local-preview"
:src="img.previewUrl"
alt=""
/>
<button
class="uploaded-remove"
type="button"
:disabled="uploadBusy"
@click="removeImage(idx)"
>
×
</button>
</div>
<div class="uploaded-meta">
<div class="uploaded-name" :title="img.file.name">{{ img.file.name }}</div>
<el-tag v-if="img.ocrStatus === 'recognizing'" size="small" type="info"
>识别中</el-tag
>
<el-tag v-if="img.ocrStatus === 'done'" size="small" type="success">OCR</el-tag>
<el-tag v-if="img.ocrStatus === 'failed'" size="small" type="warning"
>待校对</el-tag
>
</div>
</div>
<div class="uploaded-group-editor">
<el-input
v-model="img.groupName"
size="small"
placeholder="群名"
maxlength="64"
clearable
/> />
<button
class="uploaded-remove"
type="button"
:disabled="uploadBusy"
@click="removeImage(idx)"
>
×
</button>
</div> </div>
<div class="uploaded-name" :title="img.file.name">{{ img.file.name }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -467,6 +665,12 @@ onMounted(reloadAll)
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="群名">
<el-input v-model="editForm.group_name" placeholder="企业微信群名" maxlength="64" clearable />
</el-form-item>
<el-form-item label="企微改名">
<el-switch v-model="editForm.wecom_renamed" inline-prompt active-text="已改" inactive-text="未改" />
</el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="editForm.note" placeholder="7月企微群A" maxlength="50" /> <el-input v-model="editForm.note" placeholder="7月企微群A" maxlength="50" />
</el-form-item> </el-form-item>
@@ -588,31 +792,39 @@ onMounted(reloadAll)
.qrcode-table :deep(.el-table__header-wrapper), .qrcode-table :deep(.el-table__header-wrapper),
.qrcode-table :deep(.el-table__body-wrapper) { .qrcode-table :deep(.el-table__body-wrapper) {
overflow-x: hidden; overflow-x: auto;
} }
.qrcode-table :deep(.qr-col) { .qrcode-table :deep(.qr-col) {
width: 12%; width: 8%;
} }
.qrcode-table :deep(.status-col) { .qrcode-table :deep(.status-col) {
width: 10%; width: 8%;
}
.qrcode-table :deep(.name-col) {
width: 18%;
} }
.qrcode-table :deep(.note-col) { .qrcode-table :deep(.note-col) {
width: 40%; width: 18%;
} }
.qrcode-table :deep(.group-col) { .qrcode-table :deep(.group-col) {
width: 10%; width: 18%;
}
.qrcode-table :deep(.rename-col) {
width: 8%;
} }
.qrcode-table :deep(.expire-col) { .qrcode-table :deep(.expire-col) {
width: 16%; width: 12%;
} }
.qrcode-table :deep(.action-col) { .qrcode-table :deep(.action-col) {
width: 12%; width: 10%;
} }
.qrcode-thumb { .qrcode-thumb {
@@ -631,12 +843,46 @@ onMounted(reloadAll)
font-weight: 500; font-weight: 500;
} }
.group-cell,
.expire-cell { .expire-cell {
color: #5f6b7a; color: #5f6b7a;
font-size: 13px; font-size: 13px;
} }
.copy-cell-button {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
color: #303846;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 20px;
text-align: left;
}
.copy-cell-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copy-cell-button .el-icon {
flex: 0 0 auto;
color: var(--el-color-primary);
font-size: 14px;
}
.copy-cell-button:disabled {
color: #a8b0bd;
cursor: default;
}
.table-actions { .table-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -703,10 +949,10 @@ onMounted(reloadAll)
.uploaded-list { .uploaded-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px; gap: 12px;
margin-top: 10px; margin-top: 10px;
max-height: 224px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
padding: 10px; padding: 10px;
border: 1px solid #eef1f5; border: 1px solid #eef1f5;
@@ -716,6 +962,17 @@ onMounted(reloadAll)
.uploaded-item { .uploaded-item {
min-width: 0; min-width: 0;
padding: 10px;
border: 1px solid #e7ebf2;
border-radius: 8px;
background: #ffffff;
}
.uploaded-main {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
} }
.uploaded-thumb { .uploaded-thumb {
@@ -725,12 +982,20 @@ onMounted(reloadAll)
justify-content: center; justify-content: center;
width: 60px; width: 60px;
height: 60px; height: 60px;
margin: 0 auto; flex: 0 0 auto;
border: 1px solid #e7ebf2; border: 1px solid #e7ebf2;
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
} }
.uploaded-local-preview {
display: block;
width: 52px;
height: 52px;
border-radius: 6px;
object-fit: cover;
}
.uploaded-remove { .uploaded-remove {
position: absolute; position: absolute;
top: -7px; top: -7px;
@@ -756,16 +1021,31 @@ onMounted(reloadAll)
} }
.uploaded-name { .uploaded-name {
margin-top: 5px;
overflow: hidden; overflow: hidden;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 16px;
text-align: center;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.uploaded-meta {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.uploaded-meta .el-tag {
height: 20px;
}
.uploaded-group-editor {
margin-top: 8px;
}
.expired-tag { .expired-tag {
color: var(--el-color-danger); color: var(--el-color-danger);
} }
@@ -105,6 +105,23 @@ const listingGroupWelcomeConfig = computed(
const renterRetentionConfig = computed( const renterRetentionConfig = computed(
() => configs.value.find(item => item.key === 'chat.renter_retention_days_after_order_end') || null () => configs.value.find(item => item.key === 'chat.renter_retention_days_after_order_end') || null
) )
const paddleOcrTokenConfig = computed(() =>
systemConfigByKey(
'integration.paddle_ocr_token',
'',
'PaddleOCR API Token,用于二维码群名自动识别'
)
)
const paddleOcrJobUrlConfig = computed(() =>
systemConfigByKey(
'integration.paddle_ocr_job_url',
'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs',
'PaddleOCR 异步任务接口地址'
)
)
const paddleOcrModelConfig = computed(() =>
systemConfigByKey('integration.paddle_ocr_model', 'PaddleOCR-VL-1.6', 'PaddleOCR 识别模型')
)
const regularConfigs = computed(() => const regularConfigs = computed(() =>
configs.value.filter( configs.value.filter(
@@ -118,7 +135,10 @@ const regularConfigs = computed(() =>
item.key !== 'profile.post_rental_notice' && item.key !== 'profile.post_rental_notice' &&
item.key !== 'chat.auto_welcome_message' && item.key !== 'chat.auto_welcome_message' &&
item.key !== 'chat.listing_group_welcome' && item.key !== 'chat.listing_group_welcome' &&
item.key !== 'chat.renter_retention_days_after_order_end' item.key !== 'chat.renter_retention_days_after_order_end' &&
item.key !== 'integration.paddle_ocr_token' &&
item.key !== 'integration.paddle_ocr_job_url' &&
item.key !== 'integration.paddle_ocr_model'
) )
) )
@@ -188,6 +208,19 @@ async function loadConfigs() {
} }
} }
function systemConfigByKey(key: string, fallbackValue: string, description: string): SystemConfig {
const existing = configs.value.find(item => item.key === key)
if (existing) return existing
return {
id: 0,
key,
value: fallbackValue,
description,
created_at: '',
updated_at: '',
}
}
function openEdit(row: SystemConfig) { function openEdit(row: SystemConfig) {
currentEditingConfig.value = row currentEditingConfig.value = row
if (row.key === 'listing.publish_options') { if (row.key === 'listing.publish_options') {
@@ -309,6 +342,9 @@ function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) {
} }
function formatConfigValue(row: SystemConfig) { function formatConfigValue(row: SystemConfig) {
if (row.key.includes('token')) {
return row.value.trim() ? '已配置' : '未配置'
}
const trimmed = row.value.trim() const trimmed = row.value.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) { if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return 'JSON 配置' return 'JSON 配置'
@@ -599,6 +635,41 @@ function formatConfigValue(row: SystemConfig) {
</div> </div>
</section> </section>
<section class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">PaddleOCR</p>
<h2>二维码群名识别配置</h2>
<span>配置企业微信群二维码上传时使用的 OCR 识别接口保存后无需重新构建前端</span>
</div>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ paddleOcrTokenConfig.value.trim() ? '已配置' : '未配置' }}</strong>
<span>API Token</span>
<el-button size="small" @click="openEdit(paddleOcrTokenConfig)">编辑</el-button>
</div>
<div class="publish-stat">
<strong>接口</strong>
<span>{{ paddleOcrJobUrlConfig.value || '未配置' }}</span>
<el-button size="small" @click="openEdit(paddleOcrJobUrlConfig)">编辑</el-button>
</div>
<div class="publish-stat">
<strong>{{ paddleOcrModelConfig.value || '未配置' }}</strong>
<span>识别模型</span>
<el-button size="small" @click="openEdit(paddleOcrModelConfig)">编辑</el-button>
</div>
<div class="publish-stat">
<strong>直连</strong>
<span>二维码池上传时调用</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(paddleOcrTokenConfig, '未初始化') }}</span>
</div>
</div>
</section>
<el-table v-loading="loading" class="table-panel" :data="regularConfigs"> <el-table v-loading="loading" class="table-panel" :data="regularConfigs">
<el-table-column prop="key" label="配置项" min-width="260" /> <el-table-column prop="key" label="配置项" min-width="260" />
<el-table-column label="当前值" min-width="180" show-overflow-tooltip> <el-table-column label="当前值" min-width="180" show-overflow-tooltip>
@@ -747,6 +818,7 @@ function formatConfigValue(row: SystemConfig) {
.config-value { .config-value {
color: #4b5563; color: #4b5563;
font-size: 13px; font-size: 13px;
overflow-wrap: anywhere;
} }
/* 按钮文字清晰度优化 */ /* 按钮文字清晰度优化 */
@@ -58,6 +58,9 @@ export const systemConfigSelectOptions: Record<string, SystemConfigOption[]> = {
{ label: '5 天', value: '5' }, { label: '5 天', value: '5' },
{ label: '7 天', value: '7' }, { label: '7 天', value: '7' },
], ],
'integration.paddle_ocr_model': [
{ label: 'PaddleOCR-VL-1.6', value: 'PaddleOCR-VL-1.6' },
],
} }
export function getSystemConfigSelectOptions(key: string) { export function getSystemConfigSelectOptions(key: string) {