新增客户标签库:管理员维护、员工选择

租户级标签 CRUD 与颜色预设;客户打标仅能从库中选择,设置页提供管理入口,列表筛选随标签库动态生成。
This commit is contained in:
yml2213
2026-07-17 22:28:16 +08:00
parent c5f28dca1c
commit 7a08f5f729
11 changed files with 723 additions and 23 deletions
+10
View File
@@ -154,6 +154,16 @@ func seed() {
} }
model.DB.Create(&entries) 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{ quickReplies := []model.QuickReply{
{TenantID: tid, Scope: "team", Title: "打招呼", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Shortcut: "nh", GroupName: "通用", Status: "published", UsageCount: 120}, {TenantID: tid, Scope: "team", Title: "打招呼", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Shortcut: "nh", GroupName: "通用", Status: "published", UsageCount: 120},
+18
View File
@@ -91,6 +91,16 @@ func (h *CustomerHandler) Create(c *gin.Context) {
} }
customer.TenantID = middleware.GetTenantID(c) 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 { if err := model.DB.Create(&customer).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) 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": "没有可更新字段"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return 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 { if err := model.DB.Model(&customer).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
+377
View File
@@ -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)
}
}
+8
View File
@@ -11,6 +11,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
auth := NewAuthHandler() auth := NewAuthHandler()
session := NewSessionHandler() session := NewSessionHandler()
customer := NewCustomerHandler() customer := NewCustomerHandler()
customerTag := NewCustomerTagHandler()
knowledge := NewKnowledgeHandler() knowledge := NewKnowledgeHandler()
quickReply := NewQuickReplyHandler() quickReply := NewQuickReplyHandler()
stats := NewStatisticsHandler() stats := NewStatisticsHandler()
@@ -77,6 +78,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
customers.PUT("/:id", customer.Update) customers.PUT("/:id", customer.Update)
customers.DELETE("/:id", customer.Delete) 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 := authRequired.Group("/knowledge")
kb.GET("/categories", knowledge.ListCategories) kb.GET("/categories", knowledge.ListCategories)
@@ -635,6 +635,16 @@ func TestCustomerAndKnowledgeCRUD(t *testing.T) {
tenant := createTenant(t, "业务CRUD租户", "normal") tenant := createTenant(t, "业务CRUD租户", "normal")
admin := createUser(t, tenant.ID, "biz-admin", "admin") 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() createCustomerRec := httptest.NewRecorder()
router.ServeHTTP(createCustomerRec, bearerRequest(t, http.MethodPost, "/api/customers", []byte(`{"name":"测试客户甲","phone":"13900001111","tags":"[\"新客户\"]","status":"offline","source":"手动"}`), admin)) router.ServeHTTP(createCustomerRec, bearerRequest(t, http.MethodPost, "/api/customers", []byte(`{"name":"测试客户甲","phone":"13900001111","tags":"[\"新客户\"]","status":"offline","source":"手动"}`), admin))
if createCustomerRec.Code != http.StatusOK { if createCustomerRec.Code != http.StatusOK {
+1
View File
@@ -33,6 +33,7 @@ func Migrate(db *gorm.DB) error {
&User{}, &User{},
&Channel{}, &Channel{},
&Customer{}, &Customer{},
&CustomerTag{},
&Session{}, &Session{},
&Message{}, &Message{},
&SessionEvent{}, &SessionEvent{},
+11
View File
@@ -63,6 +63,17 @@ type Customer struct {
UpdatedAt time.Time `json:"updated_at"` 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 { type Session struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
+59 -19
View File
@@ -8,8 +8,8 @@ import {
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined, DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomers, updateCustomer, createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
type Customer, type Session, type Customer, type CustomerTag, type Session,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' 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' }, busy: { color: '#d97706', text: '忙碌', dot: '#d97706' },
} }
const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户'] const tagColorMap: Record<string, { bg: string; color: string }> = {
const filterChips = [ amber: { bg: '#fef3c7', color: '#92400e' },
{ key: 'all', label: '全部' }, green: { bg: '#f0fdf4', color: '#16a34a' },
{ key: 'VIP客户', label: 'VIP' }, blue: { bg: '#dbeafe', color: '#2563eb' },
{ key: '新客户', label: '新客户' }, cyan: { bg: '#ecfeff', color: '#0891b2' },
{ key: '活跃', label: '活跃' }, violet: { bg: '#f3e8ff', color: '#7c3aed' },
{ key: '沉默', label: '沉默' }, rose: { bg: '#fff1f2', color: '#e11d48' },
] orange: { bg: '#fffbeb', color: '#d97706' },
slate: { bg: '#f1f5f9', color: '#475569' },
const tagStyle: Record<string, { bg: string; color: string }> = { // 兼容历史写死名称
'VIP客户': { bg: '#fef3c7', color: '#92400e' }, 'VIP客户': { bg: '#fef3c7', color: '#92400e' },
'VIP': { bg: '#fef3c7', color: '#92400e' }, 'VIP': { bg: '#fef3c7', color: '#92400e' },
'新客户': { bg: '#f0fdf4', color: '#16a34a' }, '新客户': { bg: '#f0fdf4', color: '#16a34a' },
'活跃': { bg: '#dbeafe', color: '#2563eb' }, '活跃': { bg: '#dbeafe', color: '#2563eb' },
'沉默': { bg: '#fffbeb', color: '#d97706' }, '沉默': { bg: '#fffbeb', color: '#d97706' },
'企业客户': { bg: '#ecfeff', color: '#0891b2' }, '企业客户': { bg: '#ecfeff', color: '#0891b2' },
'高价值': { bg: '#f1f5f9', color: '#475569' },
} }
/** 浅底深字头像色,对齐效果图 */ /** 浅底深字头像色,对齐效果图 */
@@ -107,8 +106,10 @@ function sessionTopic(s: Session) {
return map[s.status] || `会话 #${s.id}` return map[s.status] || `会话 #${s.id}`
} }
const TagPill = ({ tag }: { tag: string }) => { const TagPill = ({ tag, catalog }: { tag: string; catalog?: CustomerTag[] }) => {
const s = tagStyle[tag] || { bg: '#f1f5f9', color: '#475569' } 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 const label = tag === 'VIP客户' ? 'VIP' : tag
return ( return (
<span <span
@@ -150,11 +151,34 @@ const Customers = () => {
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const [tagCatalog, setTagCatalog] = useState<CustomerTag[]>([])
useEffect(() => { useEffect(() => {
loadCustomers() loadCustomers()
}, [page, pageSize, search]) }, [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 () => { const loadCustomers = async () => {
setLoading(true) setLoading(true)
try { try {
@@ -422,7 +446,7 @@ const Customers = () => {
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{parseTags(record.tags).length === 0 {parseTags(record.tags).length === 0
? <span className="text-neutral-300 text-xs"></span> ? <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> </div>
</td> </td>
<td className="py-3 px-3 text-[13px] text-neutral-500 whitespace-nowrap"> <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"> <div className="flex flex-wrap gap-1.5">
{parseTags(selectedCustomer.tags).length === 0 ? ( {parseTags(selectedCustomer.tags).length === 0 ? (
<span className="text-xs text-neutral-400"></span> <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>
</div> </div>
@@ -671,8 +695,24 @@ const Customers = () => {
<Form.Item name="source" label="来源渠道"> <Form.Item name="source" label="来源渠道">
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" /> <Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
</Form.Item> </Form.Item>
<Form.Item name="tags" label="标签"> <Form.Item
<Select mode="tags" maxCount={10} options={tagOptions.map(t => ({ value: t, label: t }))} placeholder="选择或输入标签" /> 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.Item>
</Form> </Form>
</Modal> </Modal>
+180 -4
View File
@@ -5,17 +5,36 @@ import {
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined, PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined, MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined,
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined, ArrowUpOutlined, ArrowDownOutlined, PictureOutlined, TagsOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import dayjs, { type Dayjs } from 'dayjs' import dayjs, { type Dayjs } from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat' import customParseFormat from 'dayjs/plugin/customParseFormat'
import { import {
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings, createChannel, createCustomerTag, createStaff, deleteCustomerTag, deleteStaff,
updateChannel, updateStaff, updateTenantSettings, uploadImage, getChannels, getCustomerTags, getStaff, getTenantSettings,
type Channel, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment, updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage,
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' 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) dayjs.extend(customParseFormat)
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = { 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: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> }, { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> }, { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: <TagsOutlined /> },
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> }, { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> }, { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> }, { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
@@ -149,9 +169,28 @@ const Settings = () => {
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }, { type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
]) ])
const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState<number | null>(null) 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 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 () => { const loadStaff = async () => {
setLoadingStaff(true) setLoadingStaff(true)
try { try {
@@ -227,6 +266,7 @@ const Settings = () => {
if (activeTab === 'channels') loadChannels() if (activeTab === 'channels') loadChannels()
if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings() if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings()
if (activeTab === 'staff' && canViewStaff) loadStaff() if (activeTab === 'staff' && canViewStaff) loadStaff()
if (activeTab === 'tags') void loadCustomerTags()
}, [activeTab]) }, [activeTab])
const openCreateStaff = () => { const openCreateStaff = () => {
@@ -1177,10 +1217,146 @@ const Settings = () => {
)} )}
</div> </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>
</div> </div>
</section> </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 <Modal
title={editingStaff ? '编辑坐席' : '添加坐席'} title={editingStaff ? '编辑坐席' : '添加坐席'}
open={staffModalOpen} open={staffModalOpen}
+17
View File
@@ -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 updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
export const deleteCustomer = (id: number) => del(`/customers/${id}`) 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 // Knowledge
export const getKnowledgeCategories = () => export const getKnowledgeCategories = () =>
get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories') get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories')