新增客户标签库:管理员维护、员工选择
租户级标签 CRUD 与颜色预设;客户打标仅能从库中选择,设置页提供管理入口,列表筛选随标签库动态生成。
This commit is contained in:
@@ -154,6 +154,16 @@ func seed() {
|
||||
}
|
||||
model.DB.Create(&entries)
|
||||
|
||||
// 客户标签库(管理员维护,坐席选择)
|
||||
customerTags := []model.CustomerTag{
|
||||
{TenantID: tid, Name: "VIP客户", Color: "amber", SortOrder: 0},
|
||||
{TenantID: tid, Name: "新客户", Color: "green", SortOrder: 1},
|
||||
{TenantID: tid, Name: "活跃", Color: "blue", SortOrder: 2},
|
||||
{TenantID: tid, Name: "沉默", Color: "orange", SortOrder: 3},
|
||||
{TenantID: tid, Name: "企业客户", Color: "cyan", SortOrder: 4},
|
||||
}
|
||||
model.DB.Create(&customerTags)
|
||||
|
||||
// 团队快捷回复(独立模块;知识库「快捷回复模板」分类可逐步弃用)
|
||||
quickReplies := []model.QuickReply{
|
||||
{TenantID: tid, Scope: "team", Title: "打招呼", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Shortcut: "nh", GroupName: "通用", Status: "published", UsageCount: 120},
|
||||
|
||||
@@ -91,6 +91,16 @@ func (h *CustomerHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
customer.TenantID = middleware.GetTenantID(c)
|
||||
if customer.Tags != "" {
|
||||
normalized, err := normalizeCustomerTagsForTenant(customer.TenantID, customer.Tags, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
customer.Tags = normalized
|
||||
} else {
|
||||
customer.Tags = "[]"
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&customer).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
@@ -130,6 +140,14 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
}
|
||||
if raw, ok := updates["tags"]; ok {
|
||||
normalized, err := normalizeCustomerTagsForTenant(tenantID, raw, parseCustomerTagsJSON(customer.Tags))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
updates["tags"] = normalized
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&customer).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-cloud/server/internal/middleware"
|
||||
"kefu-cloud/server/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCustomerTagName = 30
|
||||
maxCustomerTagCount = 50
|
||||
)
|
||||
|
||||
// 预设颜色键(前端可映射样式);也允许 #RRGGBB
|
||||
var allowedTagColorKeys = map[string]bool{
|
||||
"": true, "amber": true, "green": true, "blue": true,
|
||||
"cyan": true, "violet": true, "rose": true, "slate": true, "orange": true,
|
||||
}
|
||||
|
||||
type CustomerTagHandler struct{}
|
||||
|
||||
func NewCustomerTagHandler() *CustomerTagHandler { return &CustomerTagHandler{} }
|
||||
|
||||
func requireCustomerTagManager(c *gin.Context) bool {
|
||||
// 管理员维护标签库;主管也可维护,便于运营
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理客户标签"})
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeTagColor(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "slate", nil
|
||||
}
|
||||
if strings.HasPrefix(s, "#") {
|
||||
if len(s) != 7 {
|
||||
return "", fmt.Errorf("颜色格式无效,请使用预设色或 #RRGGBB")
|
||||
}
|
||||
for _, ch := range s[1:] {
|
||||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
|
||||
return "", fmt.Errorf("颜色格式无效")
|
||||
}
|
||||
}
|
||||
return strings.ToLower(s), nil
|
||||
}
|
||||
s = strings.ToLower(s)
|
||||
if !allowedTagColorKeys[s] {
|
||||
return "", fmt.Errorf("不支持的颜色预设")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// List GET /api/customer-tags — 全员可读(坐席选标签)
|
||||
func (h *CustomerTagHandler) List(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var list []model.CustomerTag
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).
|
||||
Order("sort_order asc, id asc").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载标签失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, list)
|
||||
}
|
||||
|
||||
type customerTagReq struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// Create POST /api/customer-tags
|
||||
func (h *CustomerTagHandler) Create(c *gin.Context) {
|
||||
if !requireCustomerTagManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
|
||||
var req customerTagReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称不能为空"})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxCustomerTagName {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("标签名称不能超过 %d 字", maxCustomerTagName)})
|
||||
return
|
||||
}
|
||||
color, err := normalizeTagColor(req.Color)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
model.DB.Model(&model.CustomerTag{}).Where("tenant_id = ?", tenantID).Count(&count)
|
||||
if count >= maxCustomerTagCount {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("标签最多 %d 个", maxCustomerTagCount)})
|
||||
return
|
||||
}
|
||||
|
||||
var exists int64
|
||||
model.DB.Model(&model.CustomerTag{}).Where("tenant_id = ? AND name = ?", tenantID, name).Count(&exists)
|
||||
if exists > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
sortOrder := int(count)
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = *req.SortOrder
|
||||
}
|
||||
item := model.CustomerTag{
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
Color: color,
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
if err := model.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, item)
|
||||
}
|
||||
|
||||
// Update PUT /api/customer-tags/:id
|
||||
func (h *CustomerTagHandler) Update(c *gin.Context) {
|
||||
if !requireCustomerTagManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var item model.CustomerTag
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id64, tenantID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "标签不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req customerTagReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称不能为空"})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxCustomerTagName {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("标签名称不能超过 %d 字", maxCustomerTagName)})
|
||||
return
|
||||
}
|
||||
color, err := normalizeTagColor(req.Color)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var conflict int64
|
||||
model.DB.Model(&model.CustomerTag{}).
|
||||
Where("tenant_id = ? AND name = ? AND id <> ?", tenantID, name, item.ID).
|
||||
Count(&conflict)
|
||||
if conflict > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
oldName := item.Name
|
||||
updates := map[string]interface{}{
|
||||
"name": name,
|
||||
"color": color,
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updates["sort_order"] = *req.SortOrder
|
||||
}
|
||||
if err := model.DB.Model(&item).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重命名时同步客户 tags JSON 中的旧名称
|
||||
if oldName != name {
|
||||
syncCustomerTagRename(tenantID, oldName, name)
|
||||
}
|
||||
|
||||
_ = model.DB.First(&item, item.ID)
|
||||
middleware.JSON(c, item)
|
||||
}
|
||||
|
||||
// Delete DELETE /api/customer-tags/:id
|
||||
func (h *CustomerTagHandler) Delete(c *gin.Context) {
|
||||
if !requireCustomerTagManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var item model.CustomerTag
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id64, tenantID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "标签不存在"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
|
||||
return
|
||||
}
|
||||
// 从客户 tags 中移除该标签名(不删客户)
|
||||
syncCustomerTagRemove(tenantID, item.Name)
|
||||
middleware.JSON(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func parseCustomerTagsJSON(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err == nil {
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, t := range arr {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// 兼容逗号分隔
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func syncCustomerTagRename(tenantID uint, oldName, newName string) {
|
||||
var customers []model.Customer
|
||||
model.DB.Where("tenant_id = ? AND tags LIKE ?", tenantID, "%"+oldName+"%").Find(&customers)
|
||||
for _, cu := range customers {
|
||||
tags := parseCustomerTagsJSON(cu.Tags)
|
||||
changed := false
|
||||
for i, t := range tags {
|
||||
if t == oldName {
|
||||
tags[i] = newName
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
// 去重
|
||||
tags = uniqueStrings(tags)
|
||||
b, _ := json.Marshal(tags)
|
||||
model.DB.Model(&model.Customer{}).Where("id = ?", cu.ID).Update("tags", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func syncCustomerTagRemove(tenantID uint, name string) {
|
||||
var customers []model.Customer
|
||||
model.DB.Where("tenant_id = ? AND tags LIKE ?", tenantID, "%"+name+"%").Find(&customers)
|
||||
for _, cu := range customers {
|
||||
tags := parseCustomerTagsJSON(cu.Tags)
|
||||
next := make([]string, 0, len(tags))
|
||||
changed := false
|
||||
for _, t := range tags {
|
||||
if t == name {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
next = append(next, t)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(next)
|
||||
model.DB.Model(&model.Customer{}).Where("id = ?", cu.ID).Update("tags", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeCustomerTagsForTenant 校验并规范化客户 tags。
|
||||
// 新建:必须来自标签库;更新时允许保留客户已有的历史标签(库中已删/改名前的孤儿)。
|
||||
func normalizeCustomerTagsForTenant(tenantID uint, raw interface{}, existingOnCustomer []string) (string, error) {
|
||||
var names []string
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
names = parseCustomerTagsJSON(v)
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, s := range v {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
case nil:
|
||||
names = nil
|
||||
default:
|
||||
return "", fmt.Errorf("标签格式无效")
|
||||
}
|
||||
names = uniqueStrings(names)
|
||||
if len(names) > 10 {
|
||||
return "", fmt.Errorf("每位客户最多 10 个标签")
|
||||
}
|
||||
if len(names) == 0 {
|
||||
b, _ := json.Marshal([]string{})
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var catalog []model.CustomerTag
|
||||
model.DB.Where("tenant_id = ?", tenantID).Find(&catalog)
|
||||
allowed := map[string]bool{}
|
||||
for _, t := range catalog {
|
||||
allowed[t.Name] = true
|
||||
}
|
||||
legacy := map[string]bool{}
|
||||
for _, t := range existingOnCustomer {
|
||||
legacy[t] = true
|
||||
}
|
||||
if len(allowed) == 0 && len(legacy) == 0 {
|
||||
return "", fmt.Errorf("请先在系统设置中创建客户标签")
|
||||
}
|
||||
for _, n := range names {
|
||||
if allowed[n] || legacy[n] {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("标签「%s」不在可选列表中,请从标签库选择", n)
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTagColor(t *testing.T) {
|
||||
c, err := normalizeTagColor("")
|
||||
if err != nil || c != "slate" {
|
||||
t.Fatalf("empty -> slate, got %q %v", c, err)
|
||||
}
|
||||
c, err = normalizeTagColor("Blue")
|
||||
if err != nil || c != "blue" {
|
||||
t.Fatalf("blue preset, got %q %v", c, err)
|
||||
}
|
||||
c, err = normalizeTagColor("#A1b2C3")
|
||||
if err != nil || c != "#a1b2c3" {
|
||||
t.Fatalf("hex, got %q %v", c, err)
|
||||
}
|
||||
if _, err := normalizeTagColor("nope"); err == nil {
|
||||
t.Fatal("invalid preset should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCustomerTagsJSON(t *testing.T) {
|
||||
got := parseCustomerTagsJSON(`["VIP客户","新客户"]`)
|
||||
if len(got) != 2 || got[0] != "VIP客户" {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
got = parseCustomerTagsJSON("活跃, 沉默")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
auth := NewAuthHandler()
|
||||
session := NewSessionHandler()
|
||||
customer := NewCustomerHandler()
|
||||
customerTag := NewCustomerTagHandler()
|
||||
knowledge := NewKnowledgeHandler()
|
||||
quickReply := NewQuickReplyHandler()
|
||||
stats := NewStatisticsHandler()
|
||||
@@ -77,6 +78,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
customers.PUT("/:id", customer.Update)
|
||||
customers.DELETE("/:id", customer.Delete)
|
||||
|
||||
// 客户标签库(管理员维护,全员可读可选)
|
||||
ctags := authRequired.Group("/customer-tags")
|
||||
ctags.GET("", customerTag.List)
|
||||
ctags.POST("", customerTag.Create)
|
||||
ctags.PUT("/:id", customerTag.Update)
|
||||
ctags.DELETE("/:id", customerTag.Delete)
|
||||
|
||||
// 知识库
|
||||
kb := authRequired.Group("/knowledge")
|
||||
kb.GET("/categories", knowledge.ListCategories)
|
||||
|
||||
@@ -635,6 +635,16 @@ func TestCustomerAndKnowledgeCRUD(t *testing.T) {
|
||||
tenant := createTenant(t, "业务CRUD租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "biz-admin", "admin")
|
||||
|
||||
// 先建标签库,客户打标只能从库中选
|
||||
for _, name := range []string{"新客户", "VIP客户", "活跃"} {
|
||||
tagRec := httptest.NewRecorder()
|
||||
body := fmt.Sprintf(`{"name":%q,"color":"blue"}`, name)
|
||||
router.ServeHTTP(tagRec, bearerRequest(t, http.MethodPost, "/api/customer-tags", []byte(body), admin))
|
||||
if tagRec.Code != http.StatusOK {
|
||||
t.Fatalf("创建标签 %s 失败: %s", name, tagRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
createCustomerRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createCustomerRec, bearerRequest(t, http.MethodPost, "/api/customers", []byte(`{"name":"测试客户甲","phone":"13900001111","tags":"[\"新客户\"]","status":"offline","source":"手动"}`), admin))
|
||||
if createCustomerRec.Code != http.StatusOK {
|
||||
|
||||
@@ -33,6 +33,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&User{},
|
||||
&Channel{},
|
||||
&Customer{},
|
||||
&CustomerTag{},
|
||||
&Session{},
|
||||
&Message{},
|
||||
&SessionEvent{},
|
||||
|
||||
@@ -63,6 +63,17 @@ type Customer struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CustomerTag 租户级客户标签库,由管理员维护,坐席给客户打标时选择。
|
||||
type CustomerTag struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"uniqueIndex:idx_tenant_tag_name;not null" json:"tenant_id"`
|
||||
Name string `gorm:"size:30;uniqueIndex:idx_tenant_tag_name;not null" json:"name"`
|
||||
Color string `gorm:"size:20" json:"color"` // 预设色键或 #hex
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomers, updateCustomer,
|
||||
type Customer, type Session,
|
||||
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
|
||||
type Customer, type CustomerTag, type Session,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
@@ -19,23 +19,22 @@ const statusMap: Record<string, { color: string; text: string; dot: string }> =
|
||||
busy: { color: '#d97706', text: '忙碌', dot: '#d97706' },
|
||||
}
|
||||
|
||||
const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
|
||||
const filterChips = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'VIP客户', label: 'VIP' },
|
||||
{ key: '新客户', label: '新客户' },
|
||||
{ key: '活跃', label: '活跃' },
|
||||
{ key: '沉默', label: '沉默' },
|
||||
]
|
||||
|
||||
const tagStyle: Record<string, { bg: string; color: string }> = {
|
||||
const tagColorMap: Record<string, { bg: string; color: string }> = {
|
||||
amber: { bg: '#fef3c7', color: '#92400e' },
|
||||
green: { bg: '#f0fdf4', color: '#16a34a' },
|
||||
blue: { bg: '#dbeafe', color: '#2563eb' },
|
||||
cyan: { bg: '#ecfeff', color: '#0891b2' },
|
||||
violet: { bg: '#f3e8ff', color: '#7c3aed' },
|
||||
rose: { bg: '#fff1f2', color: '#e11d48' },
|
||||
orange: { bg: '#fffbeb', color: '#d97706' },
|
||||
slate: { bg: '#f1f5f9', color: '#475569' },
|
||||
// 兼容历史写死名称
|
||||
'VIP客户': { bg: '#fef3c7', color: '#92400e' },
|
||||
'VIP': { bg: '#fef3c7', color: '#92400e' },
|
||||
'新客户': { bg: '#f0fdf4', color: '#16a34a' },
|
||||
'活跃': { bg: '#dbeafe', color: '#2563eb' },
|
||||
'沉默': { bg: '#fffbeb', color: '#d97706' },
|
||||
'企业客户': { bg: '#ecfeff', color: '#0891b2' },
|
||||
'高价值': { bg: '#f1f5f9', color: '#475569' },
|
||||
}
|
||||
|
||||
/** 浅底深字头像色,对齐效果图 */
|
||||
@@ -107,8 +106,10 @@ function sessionTopic(s: Session) {
|
||||
return map[s.status] || `会话 #${s.id}`
|
||||
}
|
||||
|
||||
const TagPill = ({ tag }: { tag: string }) => {
|
||||
const s = tagStyle[tag] || { bg: '#f1f5f9', color: '#475569' }
|
||||
const TagPill = ({ tag, catalog }: { tag: string; catalog?: CustomerTag[] }) => {
|
||||
const meta = catalog?.find(t => t.name === tag)
|
||||
const byColor = meta?.color ? tagColorMap[meta.color] : undefined
|
||||
const s = byColor || tagColorMap[tag] || { bg: '#f1f5f9', color: '#475569' }
|
||||
const label = tag === 'VIP客户' ? 'VIP' : tag
|
||||
return (
|
||||
<span
|
||||
@@ -150,11 +151,34 @@ const Customers = () => {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [tagCatalog, setTagCatalog] = useState<CustomerTag[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomers()
|
||||
}, [page, pageSize, search])
|
||||
|
||||
useEffect(() => {
|
||||
getCustomerTags()
|
||||
.then(res => setTagCatalog(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(() => setTagCatalog([]))
|
||||
}, [])
|
||||
|
||||
const filterChips = useMemo(() => {
|
||||
const chips = [{ key: 'all', label: '全部' }]
|
||||
tagCatalog.forEach(t => {
|
||||
chips.push({
|
||||
key: t.name,
|
||||
label: t.name === 'VIP客户' ? 'VIP' : t.name,
|
||||
})
|
||||
})
|
||||
return chips
|
||||
}, [tagCatalog])
|
||||
|
||||
const tagSelectOptions = useMemo(
|
||||
() => tagCatalog.map(t => ({ value: t.name, label: t.name })),
|
||||
[tagCatalog],
|
||||
)
|
||||
|
||||
const loadCustomers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -422,7 +446,7 @@ const Customers = () => {
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{parseTags(record.tags).length === 0
|
||||
? <span className="text-neutral-300 text-xs">—</span>
|
||||
: parseTags(record.tags).map(t => <TagPill key={t} tag={t} />)}
|
||||
: parseTags(record.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-[13px] text-neutral-500 whitespace-nowrap">
|
||||
@@ -528,7 +552,7 @@ const Customers = () => {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{parseTags(selectedCustomer.tags).length === 0 ? (
|
||||
<span className="text-xs text-neutral-400">暂无标签</span>
|
||||
) : parseTags(selectedCustomer.tags).map(t => <TagPill key={t} tag={t} />)}
|
||||
) : parseTags(selectedCustomer.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -671,8 +695,24 @@ const Customers = () => {
|
||||
<Form.Item name="source" label="来源渠道">
|
||||
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" maxCount={10} options={tagOptions.map(t => ({ value: t, label: t }))} placeholder="选择或输入标签" />
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="标签"
|
||||
extra={
|
||||
tagSelectOptions.length === 0
|
||||
? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建'
|
||||
: '仅可从标签库中选择(由管理员维护)'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
maxCount={10}
|
||||
allowClear
|
||||
options={tagSelectOptions}
|
||||
placeholder={tagSelectOptions.length === 0 ? '请先配置标签库' : '选择标签'}
|
||||
disabled={tagSelectOptions.length === 0}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -5,17 +5,36 @@ import {
|
||||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
||||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||||
EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined,
|
||||
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined,
|
||||
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined, TagsOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import {
|
||||
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
|
||||
updateChannel, updateStaff, updateTenantSettings, uploadImage,
|
||||
type Channel, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||
createChannel, createCustomerTag, createStaff, deleteCustomerTag, deleteStaff,
|
||||
getChannels, getCustomerTags, getStaff, getTenantSettings,
|
||||
updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage,
|
||||
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [
|
||||
{ value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' },
|
||||
{ value: 'green', label: '绿色', bg: '#f0fdf4', color: '#16a34a' },
|
||||
{ value: 'blue', label: '蓝色', bg: '#dbeafe', color: '#2563eb' },
|
||||
{ value: 'cyan', label: '青色', bg: '#ecfeff', color: '#0891b2' },
|
||||
{ value: 'violet', label: '紫色', bg: '#f3e8ff', color: '#7c3aed' },
|
||||
{ value: 'rose', label: '玫红', bg: '#fff1f2', color: '#e11d48' },
|
||||
{ value: 'orange', label: '橙色', bg: '#fffbeb', color: '#d97706' },
|
||||
{ value: 'slate', label: '灰蓝', bg: '#f1f5f9', color: '#475569' },
|
||||
]
|
||||
|
||||
function tagColorStyle(color: string): { bg: string; color: string } {
|
||||
const preset = tagColorPresets.find(p => p.value === color)
|
||||
if (preset) return { bg: preset.bg, color: preset.color }
|
||||
if (color?.startsWith('#')) return { bg: `${color}22`, color }
|
||||
return { bg: '#f1f5f9', color: '#475569' }
|
||||
}
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
||||
@@ -114,6 +133,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
|
||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||||
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
||||
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: <TagsOutlined /> },
|
||||
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||
@@ -149,9 +169,28 @@ const Settings = () => {
|
||||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||||
])
|
||||
const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState<number | null>(null)
|
||||
const [customerTags, setCustomerTags] = useState<CustomerTag[]>([])
|
||||
const [loadingTags, setLoadingTags] = useState(false)
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [editingTag, setEditingTag] = useState<CustomerTag | null>(null)
|
||||
const [savingTag, setSavingTag] = useState(false)
|
||||
const [tagForm] = Form.useForm()
|
||||
|
||||
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||||
|
||||
const loadCustomerTags = async () => {
|
||||
setLoadingTags(true)
|
||||
try {
|
||||
const res = await getCustomerTags()
|
||||
setCustomerTags(Array.isArray(res.data) ? res.data : [])
|
||||
} catch (e) {
|
||||
setCustomerTags([])
|
||||
message.error(e instanceof Error ? e.message : '加载标签失败')
|
||||
} finally {
|
||||
setLoadingTags(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadStaff = async () => {
|
||||
setLoadingStaff(true)
|
||||
try {
|
||||
@@ -227,6 +266,7 @@ const Settings = () => {
|
||||
if (activeTab === 'channels') loadChannels()
|
||||
if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings()
|
||||
if (activeTab === 'staff' && canViewStaff) loadStaff()
|
||||
if (activeTab === 'tags') void loadCustomerTags()
|
||||
}, [activeTab])
|
||||
|
||||
const openCreateStaff = () => {
|
||||
@@ -1177,10 +1217,146 @@ const Settings = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tags' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500 m-0">
|
||||
在此创建标签后,客户管理与工作台中员工只能从列表选择,不能随意输入新标签。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingTag(null)
|
||||
tagForm.resetFields()
|
||||
tagForm.setFieldsValue({ color: 'blue' })
|
||||
setTagModalOpen(true)
|
||||
}}
|
||||
>
|
||||
新建标签
|
||||
</Button>
|
||||
</div>
|
||||
{loadingTags ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : customerTags.length === 0 ? (
|
||||
<Empty description="暂无标签,请先创建" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{customerTags.map(tag => {
|
||||
const style = tagColorStyle(tag.color)
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-neutral-100"
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium"
|
||||
style={{ backgroundColor: style.bg, color: style.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingTag(tag)
|
||||
tagForm.setFieldsValue({ name: tag.name, color: tag.color || 'slate' })
|
||||
setTagModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="删除该标签?"
|
||||
description="将从标签库移除,并同步去掉客户上的此标签"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteCustomerTag(tag.id)
|
||||
message.success('已删除')
|
||||
await loadCustomerTags()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
title={editingTag ? '编辑客户标签' : '新建客户标签'}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => setTagModalOpen(false)}
|
||||
onOk={() => tagForm.submit()}
|
||||
confirmLoading={savingTag}
|
||||
destroyOnHidden
|
||||
okText="保存"
|
||||
width={400}
|
||||
>
|
||||
<Form
|
||||
form={tagForm}
|
||||
layout="vertical"
|
||||
className="mt-2"
|
||||
onFinish={async values => {
|
||||
setSavingTag(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: String(values.name || '').trim(),
|
||||
color: values.color || 'slate',
|
||||
}
|
||||
if (editingTag) {
|
||||
await updateCustomerTag(editingTag.id, payload)
|
||||
message.success('标签已更新')
|
||||
} else {
|
||||
await createCustomerTag(payload)
|
||||
message.success('标签已创建')
|
||||
}
|
||||
setTagModalOpen(false)
|
||||
await loadCustomerTags()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSavingTag(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="标签名称"
|
||||
rules={[{ required: true, message: '请输入名称' }, { max: 30, message: '最多 30 字' }]}
|
||||
>
|
||||
<Input maxLength={30} placeholder="如:VIP客户、高意向" />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="颜色" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={tagColorPresets.map(p => ({
|
||||
value: p.value,
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: p.color }} />
|
||||
{p.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingStaff ? '编辑坐席' : '添加坐席'}
|
||||
open={staffModalOpen}
|
||||
|
||||
@@ -292,6 +292,23 @@ export const createCustomer = (data: Partial<Customer>) => post<Customer>('/cust
|
||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
||||
|
||||
// 客户标签库(管理员维护,全员可选)
|
||||
export interface CustomerTag {
|
||||
id: number
|
||||
tenant_id: number
|
||||
name: string
|
||||
color: string
|
||||
sort_order: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
export const getCustomerTags = () => get<CustomerTag[]>('/customer-tags')
|
||||
export const createCustomerTag = (data: { name: string; color?: string; sort_order?: number }) =>
|
||||
post<CustomerTag>('/customer-tags', data)
|
||||
export const updateCustomerTag = (id: number, data: { name: string; color?: string; sort_order?: number }) =>
|
||||
put<CustomerTag>(`/customer-tags/${id}`, data)
|
||||
export const deleteCustomerTag = (id: number) => del(`/customer-tags/${id}`)
|
||||
|
||||
// Knowledge
|
||||
export const getKnowledgeCategories = () =>
|
||||
get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories')
|
||||
|
||||
Reference in New Issue
Block a user