diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index 2bae0e4..22eeb41 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -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}, diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index 8049500..d7fc9e3 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -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": "更新失败"}) diff --git a/server/internal/handler/customer_tag.go b/server/internal/handler/customer_tag.go new file mode 100644 index 0000000..4fde3df --- /dev/null +++ b/server/internal/handler/customer_tag.go @@ -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 +} diff --git a/server/internal/handler/customer_tag_test.go b/server/internal/handler/customer_tag_test.go new file mode 100644 index 0000000..25df3f7 --- /dev/null +++ b/server/internal/handler/customer_tag_test.go @@ -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) + } +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index aceea6e..7b26994 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index c225deb..830e3bf 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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 { diff --git a/server/internal/model/db.go b/server/internal/model/db.go index 33904eb..9586619 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -33,6 +33,7 @@ func Migrate(db *gorm.DB) error { &User{}, &Channel{}, &Customer{}, + &CustomerTag{}, &Session{}, &Message{}, &SessionEvent{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 3378895..0ecadb5 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -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"` diff --git a/web/src/pages/agent/Customers.tsx b/web/src/pages/agent/Customers.tsx index 325412b..9858ef3 100644 --- a/web/src/pages/agent/Customers.tsx +++ b/web/src/pages/agent/Customers.tsx @@ -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 = 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 = { +const tagColorMap: Record = { + 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 ( { const [editOpen, setEditOpen] = useState(false) const [saving, setSaving] = useState(false) const [form] = Form.useForm() + const [tagCatalog, setTagCatalog] = useState([]) 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 = () => {
{parseTags(record.tags).length === 0 ? - : parseTags(record.tags).map(t => )} + : parseTags(record.tags).map(t => )}
@@ -528,7 +552,7 @@ const Customers = () => {
{parseTags(selectedCustomer.tags).length === 0 ? ( 暂无标签 - ) : parseTags(selectedCustomer.tags).map(t => )} + ) : parseTags(selectedCustomer.tags).map(t => )}
@@ -671,8 +695,24 @@ const Customers = () => { - - diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index ce357a2..44e50ed 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -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 = { @@ -114,6 +133,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: }, + { key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: }, { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: }, { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, @@ -149,9 +169,28 @@ const Settings = () => { { type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }, ]) const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState(null) + const [customerTags, setCustomerTags] = useState([]) + const [loadingTags, setLoadingTags] = useState(false) + const [tagModalOpen, setTagModalOpen] = useState(false) + const [editingTag, setEditingTag] = useState(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 = () => { )} )} + + {activeTab === 'tags' && ( +
+
+
+

+ 在此创建标签后,客户管理与工作台中员工只能从列表选择,不能随意输入新标签。 +

+
+ +
+ {loadingTags ? ( +
+ ) : customerTags.length === 0 ? ( + + ) : ( +
+ {customerTags.map(tag => { + const style = tagColorStyle(tag.color) + return ( +
+ + {tag.name} + +
+
+
+ ) + })} +
+ )} +
+ )} + setTagModalOpen(false)} + onOk={() => tagForm.submit()} + confirmLoading={savingTag} + destroyOnHidden + okText="保存" + width={400} + > +
{ + 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) + } + }} + > + + + + +