新增客户标签库:管理员维护、员工选择
租户级标签 CRUD 与颜色预设;客户打标仅能从库中选择,设置页提供管理入口,列表筛选随标签库动态生成。
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user