完善二维码池 OCR 配置与识别
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("企微改名标记未保存")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user