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

租户级标签 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)
// 客户标签库(管理员维护,坐席选择)
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},
+18
View File
@@ -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": "更新失败"})
+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()
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 {
+1
View File
@@ -33,6 +33,7 @@ func Migrate(db *gorm.DB) error {
&User{},
&Channel{},
&Customer{},
&CustomerTag{},
&Session{},
&Message{},
&SessionEvent{},
+11
View File
@@ -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"`
+59 -19
View File
@@ -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>
+180 -4
View File
@@ -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}
+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 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')