新增客户标签库:管理员维护、员工选择
租户级标签 CRUD 与颜色预设;客户打标仅能从库中选择,设置页提供管理入口,列表筛选随标签库动态生成。
This commit is contained in:
@@ -8,8 +8,8 @@ import {
|
||||
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomers, updateCustomer,
|
||||
type Customer, type Session,
|
||||
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
|
||||
type Customer, type CustomerTag, type Session,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
@@ -19,23 +19,22 @@ const statusMap: Record<string, { color: string; text: string; dot: string }> =
|
||||
busy: { color: '#d97706', text: '忙碌', dot: '#d97706' },
|
||||
}
|
||||
|
||||
const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
|
||||
const filterChips = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'VIP客户', label: 'VIP' },
|
||||
{ key: '新客户', label: '新客户' },
|
||||
{ key: '活跃', label: '活跃' },
|
||||
{ key: '沉默', label: '沉默' },
|
||||
]
|
||||
|
||||
const tagStyle: Record<string, { bg: string; color: string }> = {
|
||||
const tagColorMap: Record<string, { bg: string; color: string }> = {
|
||||
amber: { bg: '#fef3c7', color: '#92400e' },
|
||||
green: { bg: '#f0fdf4', color: '#16a34a' },
|
||||
blue: { bg: '#dbeafe', color: '#2563eb' },
|
||||
cyan: { bg: '#ecfeff', color: '#0891b2' },
|
||||
violet: { bg: '#f3e8ff', color: '#7c3aed' },
|
||||
rose: { bg: '#fff1f2', color: '#e11d48' },
|
||||
orange: { bg: '#fffbeb', color: '#d97706' },
|
||||
slate: { bg: '#f1f5f9', color: '#475569' },
|
||||
// 兼容历史写死名称
|
||||
'VIP客户': { bg: '#fef3c7', color: '#92400e' },
|
||||
'VIP': { bg: '#fef3c7', color: '#92400e' },
|
||||
'新客户': { bg: '#f0fdf4', color: '#16a34a' },
|
||||
'活跃': { bg: '#dbeafe', color: '#2563eb' },
|
||||
'沉默': { bg: '#fffbeb', color: '#d97706' },
|
||||
'企业客户': { bg: '#ecfeff', color: '#0891b2' },
|
||||
'高价值': { bg: '#f1f5f9', color: '#475569' },
|
||||
}
|
||||
|
||||
/** 浅底深字头像色,对齐效果图 */
|
||||
@@ -107,8 +106,10 @@ function sessionTopic(s: Session) {
|
||||
return map[s.status] || `会话 #${s.id}`
|
||||
}
|
||||
|
||||
const TagPill = ({ tag }: { tag: string }) => {
|
||||
const s = tagStyle[tag] || { bg: '#f1f5f9', color: '#475569' }
|
||||
const TagPill = ({ tag, catalog }: { tag: string; catalog?: CustomerTag[] }) => {
|
||||
const meta = catalog?.find(t => t.name === tag)
|
||||
const byColor = meta?.color ? tagColorMap[meta.color] : undefined
|
||||
const s = byColor || tagColorMap[tag] || { bg: '#f1f5f9', color: '#475569' }
|
||||
const label = tag === 'VIP客户' ? 'VIP' : tag
|
||||
return (
|
||||
<span
|
||||
@@ -150,11 +151,34 @@ const Customers = () => {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [tagCatalog, setTagCatalog] = useState<CustomerTag[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomers()
|
||||
}, [page, pageSize, search])
|
||||
|
||||
useEffect(() => {
|
||||
getCustomerTags()
|
||||
.then(res => setTagCatalog(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(() => setTagCatalog([]))
|
||||
}, [])
|
||||
|
||||
const filterChips = useMemo(() => {
|
||||
const chips = [{ key: 'all', label: '全部' }]
|
||||
tagCatalog.forEach(t => {
|
||||
chips.push({
|
||||
key: t.name,
|
||||
label: t.name === 'VIP客户' ? 'VIP' : t.name,
|
||||
})
|
||||
})
|
||||
return chips
|
||||
}, [tagCatalog])
|
||||
|
||||
const tagSelectOptions = useMemo(
|
||||
() => tagCatalog.map(t => ({ value: t.name, label: t.name })),
|
||||
[tagCatalog],
|
||||
)
|
||||
|
||||
const loadCustomers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -422,7 +446,7 @@ const Customers = () => {
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{parseTags(record.tags).length === 0
|
||||
? <span className="text-neutral-300 text-xs">—</span>
|
||||
: parseTags(record.tags).map(t => <TagPill key={t} tag={t} />)}
|
||||
: parseTags(record.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-[13px] text-neutral-500 whitespace-nowrap">
|
||||
@@ -528,7 +552,7 @@ const Customers = () => {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{parseTags(selectedCustomer.tags).length === 0 ? (
|
||||
<span className="text-xs text-neutral-400">暂无标签</span>
|
||||
) : parseTags(selectedCustomer.tags).map(t => <TagPill key={t} tag={t} />)}
|
||||
) : parseTags(selectedCustomer.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -671,8 +695,24 @@ const Customers = () => {
|
||||
<Form.Item name="source" label="来源渠道">
|
||||
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" maxCount={10} options={tagOptions.map(t => ({ value: t, label: t }))} placeholder="选择或输入标签" />
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="标签"
|
||||
extra={
|
||||
tagSelectOptions.length === 0
|
||||
? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建'
|
||||
: '仅可从标签库中选择(由管理员维护)'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
maxCount={10}
|
||||
allowClear
|
||||
options={tagSelectOptions}
|
||||
placeholder={tagSelectOptions.length === 0 ? '请先配置标签库' : '选择标签'}
|
||||
disabled={tagSelectOptions.length === 0}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -5,17 +5,36 @@ import {
|
||||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
||||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||||
EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined,
|
||||
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined,
|
||||
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined, TagsOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import {
|
||||
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
|
||||
updateChannel, updateStaff, updateTenantSettings, uploadImage,
|
||||
type Channel, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||
createChannel, createCustomerTag, createStaff, deleteCustomerTag, deleteStaff,
|
||||
getChannels, getCustomerTags, getStaff, getTenantSettings,
|
||||
updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage,
|
||||
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [
|
||||
{ value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' },
|
||||
{ value: 'green', label: '绿色', bg: '#f0fdf4', color: '#16a34a' },
|
||||
{ value: 'blue', label: '蓝色', bg: '#dbeafe', color: '#2563eb' },
|
||||
{ value: 'cyan', label: '青色', bg: '#ecfeff', color: '#0891b2' },
|
||||
{ value: 'violet', label: '紫色', bg: '#f3e8ff', color: '#7c3aed' },
|
||||
{ value: 'rose', label: '玫红', bg: '#fff1f2', color: '#e11d48' },
|
||||
{ value: 'orange', label: '橙色', bg: '#fffbeb', color: '#d97706' },
|
||||
{ value: 'slate', label: '灰蓝', bg: '#f1f5f9', color: '#475569' },
|
||||
]
|
||||
|
||||
function tagColorStyle(color: string): { bg: string; color: string } {
|
||||
const preset = tagColorPresets.find(p => p.value === color)
|
||||
if (preset) return { bg: preset.bg, color: preset.color }
|
||||
if (color?.startsWith('#')) return { bg: `${color}22`, color }
|
||||
return { bg: '#f1f5f9', color: '#475569' }
|
||||
}
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
||||
@@ -114,6 +133,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
|
||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||||
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
||||
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: <TagsOutlined /> },
|
||||
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||
@@ -149,9 +169,28 @@ const Settings = () => {
|
||||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||||
])
|
||||
const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState<number | null>(null)
|
||||
const [customerTags, setCustomerTags] = useState<CustomerTag[]>([])
|
||||
const [loadingTags, setLoadingTags] = useState(false)
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [editingTag, setEditingTag] = useState<CustomerTag | null>(null)
|
||||
const [savingTag, setSavingTag] = useState(false)
|
||||
const [tagForm] = Form.useForm()
|
||||
|
||||
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||||
|
||||
const loadCustomerTags = async () => {
|
||||
setLoadingTags(true)
|
||||
try {
|
||||
const res = await getCustomerTags()
|
||||
setCustomerTags(Array.isArray(res.data) ? res.data : [])
|
||||
} catch (e) {
|
||||
setCustomerTags([])
|
||||
message.error(e instanceof Error ? e.message : '加载标签失败')
|
||||
} finally {
|
||||
setLoadingTags(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadStaff = async () => {
|
||||
setLoadingStaff(true)
|
||||
try {
|
||||
@@ -227,6 +266,7 @@ const Settings = () => {
|
||||
if (activeTab === 'channels') loadChannels()
|
||||
if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings()
|
||||
if (activeTab === 'staff' && canViewStaff) loadStaff()
|
||||
if (activeTab === 'tags') void loadCustomerTags()
|
||||
}, [activeTab])
|
||||
|
||||
const openCreateStaff = () => {
|
||||
@@ -1177,10 +1217,146 @@ const Settings = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tags' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500 m-0">
|
||||
在此创建标签后,客户管理与工作台中员工只能从列表选择,不能随意输入新标签。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingTag(null)
|
||||
tagForm.resetFields()
|
||||
tagForm.setFieldsValue({ color: 'blue' })
|
||||
setTagModalOpen(true)
|
||||
}}
|
||||
>
|
||||
新建标签
|
||||
</Button>
|
||||
</div>
|
||||
{loadingTags ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : customerTags.length === 0 ? (
|
||||
<Empty description="暂无标签,请先创建" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{customerTags.map(tag => {
|
||||
const style = tagColorStyle(tag.color)
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-neutral-100"
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium"
|
||||
style={{ backgroundColor: style.bg, color: style.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingTag(tag)
|
||||
tagForm.setFieldsValue({ name: tag.name, color: tag.color || 'slate' })
|
||||
setTagModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="删除该标签?"
|
||||
description="将从标签库移除,并同步去掉客户上的此标签"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteCustomerTag(tag.id)
|
||||
message.success('已删除')
|
||||
await loadCustomerTags()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
title={editingTag ? '编辑客户标签' : '新建客户标签'}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => setTagModalOpen(false)}
|
||||
onOk={() => tagForm.submit()}
|
||||
confirmLoading={savingTag}
|
||||
destroyOnHidden
|
||||
okText="保存"
|
||||
width={400}
|
||||
>
|
||||
<Form
|
||||
form={tagForm}
|
||||
layout="vertical"
|
||||
className="mt-2"
|
||||
onFinish={async values => {
|
||||
setSavingTag(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: String(values.name || '').trim(),
|
||||
color: values.color || 'slate',
|
||||
}
|
||||
if (editingTag) {
|
||||
await updateCustomerTag(editingTag.id, payload)
|
||||
message.success('标签已更新')
|
||||
} else {
|
||||
await createCustomerTag(payload)
|
||||
message.success('标签已创建')
|
||||
}
|
||||
setTagModalOpen(false)
|
||||
await loadCustomerTags()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSavingTag(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="标签名称"
|
||||
rules={[{ required: true, message: '请输入名称' }, { max: 30, message: '最多 30 字' }]}
|
||||
>
|
||||
<Input maxLength={30} placeholder="如:VIP客户、高意向" />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="颜色" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={tagColorPresets.map(p => ({
|
||||
value: p.value,
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: p.color }} />
|
||||
{p.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingStaff ? '编辑坐席' : '添加坐席'}
|
||||
open={staffModalOpen}
|
||||
|
||||
@@ -292,6 +292,23 @@ export const createCustomer = (data: Partial<Customer>) => post<Customer>('/cust
|
||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
||||
|
||||
// 客户标签库(管理员维护,全员可选)
|
||||
export interface CustomerTag {
|
||||
id: number
|
||||
tenant_id: number
|
||||
name: string
|
||||
color: string
|
||||
sort_order: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
export const getCustomerTags = () => get<CustomerTag[]>('/customer-tags')
|
||||
export const createCustomerTag = (data: { name: string; color?: string; sort_order?: number }) =>
|
||||
post<CustomerTag>('/customer-tags', data)
|
||||
export const updateCustomerTag = (id: number, data: { name: string; color?: string; sort_order?: number }) =>
|
||||
put<CustomerTag>(`/customer-tags/${id}`, data)
|
||||
export const deleteCustomerTag = (id: number) => del(`/customer-tags/${id}`)
|
||||
|
||||
// Knowledge
|
||||
export const getKnowledgeCategories = () =>
|
||||
get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories')
|
||||
|
||||
Reference in New Issue
Block a user