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

租户级标签 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
+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')