扩展客户资料表单:多手机号、固话、微信QQ与备注

添加/编辑客户支持动态多手机、联系电话、备注等字段,详情侧栏同步展示。
This commit is contained in:
yml2213
2026-07-19 00:49:56 +08:00
parent 1f92c35963
commit 4b7d8b04ea
4 changed files with 408 additions and 78 deletions
+175 -31
View File
@@ -45,7 +45,11 @@ func (h *CustomerHandler) List(c *gin.Context) {
query = query.Where("id IN (?)", assignedCustomers) query = query.Where("id IN (?)", assignedCustomers)
} }
if search != "" { if search != "" {
query = query.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%") like := "%" + search + "%"
query = query.Where(
"name LIKE ? OR phone LIKE ? OR tel LIKE ? OR email LIKE ? OR wechat LIKE ? OR qq LIKE ? OR remark LIKE ?",
like, like, like, like, like, like, like,
)
} }
if status != "" { if status != "" {
query = query.Where("status = ?", status) query = query.Where("status = ?", status)
@@ -89,29 +93,132 @@ func (h *CustomerHandler) Get(c *gin.Context) {
}) })
} }
// SaveCustomerReq 创建/更新客户(支持多手机号)。
// phones:多个手机号;第一项写入主字段 phone,全部写入 contacts。
type SaveCustomerReq struct {
Name string `json:"name"`
Phone string `json:"phone"` // 兼容:单手机
Phones []string `json:"phones"` // 多手机
Tel string `json:"tel"` // 联系电话/固话
Email string `json:"email"`
Wechat string `json:"wechat"`
QQ string `json:"qq"`
Remark string `json:"remark"`
Tags string `json:"tags"`
Source string `json:"source"`
Status string `json:"status"`
}
func normalizePhoneList(primary string, phones []string) []string {
seen := map[string]bool{}
var out []string
add := func(raw string) {
v := strings.TrimSpace(raw)
if v == "" || seen[v] {
return
}
seen[v] = true
out = append(out, v)
}
add(primary)
for _, p := range phones {
add(p)
}
return out
}
// syncCustomerPhones 以 phones 列表为准重写 kind=phone 的 contactssource=agent),并补主字段。
func syncCustomerPhones(tenantID, customerID uint, phones []string) {
// 删除坐席维护的手机号记录后重建;自动识别(draft/message)中不在新列表的保留
model.DB.Where("customer_id = ? AND kind = ? AND source = ?", customerID, "phone", "agent").Delete(&model.CustomerContact{})
for _, p := range phones {
var exists int64
model.DB.Model(&model.CustomerContact{}).
Where("customer_id = ? AND kind = ? AND value = ?", customerID, "phone", p).
Count(&exists)
if exists > 0 {
continue
}
_ = model.DB.Create(&model.CustomerContact{
TenantID: tenantID,
CustomerID: customerID,
Kind: "phone",
Value: p,
Source: "agent",
}).Error
}
}
func (h *CustomerHandler) Create(c *gin.Context) { func (h *CustomerHandler) Create(c *gin.Context) {
var customer model.Customer var req SaveCustomerReq
if err := c.ShouldBindJSON(&customer); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return return
} }
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) < 2 || len([]rune(name)) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户姓名需 2-50 个字符"})
return
}
phones := normalizePhoneList(req.Phone, req.Phones)
primaryPhone := ""
if len(phones) > 0 {
primaryPhone = phones[0]
}
status := strings.TrimSpace(req.Status)
if status == "" {
status = "offline"
}
source := strings.TrimSpace(req.Source)
if source == "" {
source = "手动录入"
}
customer.TenantID = middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
if customer.Tags != "" { tags := req.Tags
normalized, err := normalizeCustomerTagsForTenant(customer.TenantID, customer.Tags, nil) if tags != "" {
normalized, err := normalizeCustomerTagsForTenant(tenantID, tags, nil)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return return
} }
customer.Tags = normalized tags = normalized
} else { } else {
customer.Tags = "[]" tags = "[]"
} }
customer := model.Customer{
TenantID: tenantID,
Name: name,
Phone: primaryPhone,
Tel: strings.TrimSpace(req.Tel),
Email: strings.TrimSpace(req.Email),
Wechat: strings.TrimSpace(req.Wechat),
QQ: strings.TrimSpace(req.QQ),
Remark: strings.TrimSpace(req.Remark),
Tags: tags,
Source: source,
Status: status,
}
if err := model.DB.Create(&customer).Error; err != nil { if err := model.DB.Create(&customer).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return return
} }
syncCustomerPhones(tenantID, customer.ID, phones)
// 主字段微信/邮箱/QQ 也写入 contacts(便于统一展示)
var extras []extractedContact
if customer.Wechat != "" {
extras = append(extras, extractedContact{Kind: "wechat", Value: customer.Wechat})
}
if customer.QQ != "" {
extras = append(extras, extractedContact{Kind: "qq", Value: customer.QQ})
}
if customer.Email != "" {
extras = append(extras, extractedContact{Kind: "email", Value: customer.Email})
}
if len(extras) > 0 {
_, _ = mergeCustomerContacts(tenantID, customer.ID, extras, "agent")
}
middleware.JSON(c, customer) middleware.JSON(c, customer)
} }
@@ -130,35 +237,63 @@ func (h *CustomerHandler) Update(c *gin.Context) {
return return
} }
var updates map[string]interface{} var raw map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil { if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return return
} }
allowed := map[string]bool{ // 多手机号完整同步
"name": true, "phone": true, "email": true, "wechat": true, "qq": true, var phones []string
"tags": true, "source": true, "status": true, hasPhones := false
} if v, ok := raw["phones"]; ok {
for key := range updates { hasPhones = true
if !allowed[key] { if arr, ok := v.([]interface{}); ok {
delete(updates, key) for _, item := range arr {
} if s, ok := item.(string); ok {
} phones = append(phones, s)
// 字符串字段统一 trim }
for _, k := range []string{"name", "phone", "email", "wechat", "qq", "source", "status"} {
if v, ok := updates[k]; ok {
if s, ok := v.(string); ok {
updates[k] = strings.TrimSpace(s)
} }
} }
primaryExtra := ""
if p, ok := raw["phone"].(string); ok {
primaryExtra = p
}
phones = normalizePhoneList(primaryExtra, phones)
delete(raw, "phones")
} }
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) allowed := map[string]bool{
return "name": true, "phone": true, "tel": true, "email": true, "wechat": true, "qq": true,
"remark": true, "tags": true, "source": true, "status": true,
} }
if raw, ok := updates["tags"]; ok { updates := map[string]interface{}{}
normalized, err := normalizeCustomerTagsForTenant(tenantID, raw, parseCustomerTagsJSON(customer.Tags)) for key, val := range raw {
if !allowed[key] {
continue
}
if s, ok := val.(string); ok {
updates[key] = strings.TrimSpace(s)
} else {
updates[key] = val
}
}
if hasPhones {
primary := ""
if len(phones) > 0 {
primary = phones[0]
}
updates["phone"] = primary
}
if rawName, ok := updates["name"].(string); ok {
if rawName == "" || len([]rune(rawName)) < 2 || len([]rune(rawName)) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户姓名需 2-50 个字符"})
return
}
}
if rawTags, ok := updates["tags"]; ok {
normalized, err := normalizeCustomerTagsForTenant(tenantID, rawTags, parseCustomerTagsJSON(customer.Tags))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return return
@@ -166,10 +301,19 @@ func (h *CustomerHandler) Update(c *gin.Context) {
updates["tags"] = normalized updates["tags"] = normalized
} }
if err := model.DB.Model(&customer).Updates(updates).Error; err != nil { if len(updates) == 0 && !hasPhones {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return return
} }
if len(updates) > 0 {
if err := model.DB.Model(&customer).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
}
if hasPhones {
syncCustomerPhones(tenantID, customer.ID, phones)
}
model.DB.First(&customer, customer.ID) model.DB.First(&customer, customer.ID)
middleware.JSON(c, customer) middleware.JSON(c, customer)
} }
+4 -2
View File
@@ -50,10 +50,12 @@ type Customer struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
Name string `gorm:"size:50;not null" json:"name"` Name string `gorm:"size:50;not null" json:"name"`
Phone string `gorm:"size:20" json:"phone"` Phone string `gorm:"size:20" json:"phone"` // 主手机号(兼容列表搜索)
Tel string `gorm:"size:30" json:"tel"` // 联系电话/固话
Email string `gorm:"size:100" json:"email"` Email string `gorm:"size:100" json:"email"`
Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空 Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空
QQ string `gorm:"size:20;column:qq" json:"qq"` QQ string `gorm:"size:20;column:qq" json:"qq"`
Remark string `gorm:"type:text" json:"remark"` // 客户备注
Tags string `gorm:"type:text" json:"tags"` Tags string `gorm:"type:text" json:"tags"`
Source string `gorm:"size:30" json:"source"` Source string `gorm:"size:30" json:"source"`
Status string `gorm:"size:20;default:online" json:"status"` Status string `gorm:"size:20;default:online" json:"status"`
+208 -41
View File
@@ -1,15 +1,16 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Input, Button, Empty, message, Modal, Form, Select, Popconfirm, Spin, Pagination, Input, Button, Empty, message, Modal, Form, Select, Popconfirm, Spin, Pagination, Space,
} from 'antd' } from 'antd'
import { import {
SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined, SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined,
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined, DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
MinusCircleOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer, createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
type Customer, type CustomerTag, type Session, type Customer, type CustomerContact, type CustomerTag, type Session,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
@@ -144,6 +145,7 @@ const Customers = () => {
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const [tagFilter, setTagFilter] = useState('all') const [tagFilter, setTagFilter] = useState('all')
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null) const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
const [detailContacts, setDetailContacts] = useState<CustomerContact[]>([])
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [historySessions, setHistorySessions] = useState<Session[]>([]) const [historySessions, setHistorySessions] = useState<Session[]>([])
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
@@ -199,15 +201,30 @@ const Customers = () => {
) )
}, [customers, tagFilter]) }, [customers, tagFilter])
const collectPhones = (customer: Customer, contacts?: CustomerContact[]) => {
const list: string[] = []
const push = (v?: string) => {
const s = (v || '').trim()
if (s && !list.includes(s)) list.push(s)
}
push(customer.phone)
;(contacts || []).forEach(c => {
if (c.kind === 'phone') push(c.value)
})
return list.length > 0 ? list : ['']
}
const openDetail = async (record: Customer) => { const openDetail = async (record: Customer) => {
setSelectedCustomer(record) setSelectedCustomer(record)
setPanelOpen(true) setPanelOpen(true)
setHistorySessions([]) setHistorySessions([])
setDetailContacts([])
setDetailLoading(true) setDetailLoading(true)
try { try {
const res = await getCustomer(record.id) const res = await getCustomer(record.id)
setSelectedCustomer(res.data.customer) setSelectedCustomer(res.data.customer)
setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : []) setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : [])
setDetailContacts(Array.isArray(res.data.contacts) ? res.data.contacts : [])
} catch { } catch {
// keep list snapshot // keep list snapshot
} finally { } finally {
@@ -222,37 +239,83 @@ const Customers = () => {
const openCreate = () => { const openCreate = () => {
setEditingId(null) setEditingId(null)
form.resetFields() form.resetFields()
form.setFieldsValue({ status: 'offline', source: '手动录入', tags: [] })
setEditOpen(true)
}
const openEdit = (customer: Customer) => {
setEditingId(customer.id)
form.setFieldsValue({ form.setFieldsValue({
name: customer.name, status: 'offline',
phone: customer.phone, source: '手动录入',
email: customer.email, tags: [],
wechat: customer.wechat || '', phones: [''],
qq: customer.qq || '', tel: '',
source: customer.source, email: '',
status: customer.status, wechat: '',
tags: parseTags(customer.tags), qq: '',
remark: '',
}) })
setEditOpen(true) setEditOpen(true)
} }
const openEdit = async (customer: Customer) => {
setEditingId(customer.id)
setEditOpen(true)
form.setFieldsValue({
name: customer.name,
phones: customer.phone ? [customer.phone] : [''],
tel: customer.tel || '',
email: customer.email,
wechat: customer.wechat || '',
qq: customer.qq || '',
remark: customer.remark || '',
source: customer.source,
status: customer.status,
tags: parseTags(customer.tags),
})
// 拉取完整联系方式,补全多手机号
try {
const res = await getCustomer(customer.id)
const full = res.data.customer
const contacts = Array.isArray(res.data.contacts) ? res.data.contacts : []
form.setFieldsValue({
name: full.name,
phones: collectPhones(full, contacts),
tel: full.tel || '',
email: full.email,
wechat: full.wechat || '',
qq: full.qq || '',
remark: full.remark || '',
source: full.source,
status: full.status,
tags: parseTags(full.tags),
})
} catch {
// 使用列表快照
}
}
const handleSave = async (values: { const handleSave = async (values: {
name: string; phone?: string; email?: string; wechat?: string; qq?: string name: string
source?: string; status?: string; tags?: string[] phones?: string[]
tel?: string
email?: string
wechat?: string
qq?: string
remark?: string
source?: string
status?: string
tags?: string[]
}) => { }) => {
setSaving(true) setSaving(true)
try { try {
const phones = (values.phones || [])
.map(p => (p || '').trim())
.filter(Boolean)
const payload = { const payload = {
name: values.name.trim(), name: values.name.trim(),
phone: values.phone?.trim() || '', phones,
phone: phones[0] || '',
tel: values.tel?.trim() || '',
email: values.email?.trim() || '', email: values.email?.trim() || '',
wechat: values.wechat?.trim() || '', wechat: values.wechat?.trim() || '',
qq: values.qq?.trim() || '', qq: values.qq?.trim() || '',
remark: values.remark?.trim() || '',
source: values.source?.trim() || '手动录入', source: values.source?.trim() || '手动录入',
status: values.status || 'offline', status: values.status || 'offline',
tags: JSON.stringify(values.tags || []), tags: JSON.stringify(values.tags || []),
@@ -565,12 +628,34 @@ const Customers = () => {
<div className="px-5 py-3 border-b border-neutral-100"> <div className="px-5 py-3 border-b border-neutral-100">
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2"></div> <div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2"></div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2.5"> {selectedCustomer.tel && (
<PhoneOutlined className="text-neutral-400 text-[13px]" /> <div className="flex items-center gap-2.5 min-w-0">
<span className="text-[13px] text-neutral-700"> <span className="text-neutral-400 text-[12px] shrink-0 w-[13px] text-center font-medium"></span>
{selectedCustomer.phone || '—'} <span className="text-[13px] text-neutral-700 truncate">{selectedCustomer.tel}</span>
</span> </div>
</div> )}
{(() => {
const phones = collectPhones(selectedCustomer, detailContacts).filter(Boolean)
if (phones.length === 0) {
return (
<div className="flex items-center gap-2.5">
<PhoneOutlined className="text-neutral-400 text-[13px]" />
<span className="text-[13px] text-neutral-400"></span>
</div>
)
}
return phones.map((p, i) => (
<div key={`${p}-${i}`} className="flex items-center gap-2.5">
<PhoneOutlined className="text-neutral-400 text-[13px]" />
<span className="text-[13px] text-neutral-700">
{p}
{phones.length > 1 && (
<span className="ml-1 text-[11px] text-neutral-400">{i + 1}</span>
)}
</span>
</div>
))
})()}
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<MailOutlined className="text-neutral-400 text-[13px] shrink-0" /> <MailOutlined className="text-neutral-400 text-[13px] shrink-0" />
<span className="text-[13px] text-neutral-700 truncate"> <span className="text-[13px] text-neutral-700 truncate">
@@ -598,6 +683,16 @@ const Customers = () => {
</div> </div>
</div> </div>
{/* 备注 */}
{(selectedCustomer.remark || '').trim() && (
<div className="px-5 py-3 border-b border-neutral-100">
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2"></div>
<div className="text-[13px] text-neutral-700 whitespace-pre-wrap break-words leading-relaxed">
{selectedCustomer.remark}
</div>
</div>
)}
{/* 活跃数据 — 三列 */} {/* 活跃数据 — 三列 */}
<div className="px-5 py-3 border-b border-neutral-100"> <div className="px-5 py-3 border-b border-neutral-100">
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2"></div> <div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2"></div>
@@ -693,22 +788,74 @@ const Customers = () => {
confirmLoading={saving} confirmLoading={saving}
destroyOnClose destroyOnClose
okText="保存" okText="保存"
width={560}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
> >
<Form form={form} layout="vertical" className="mt-3" onFinish={handleSave}> <Form form={form} layout="vertical" className="mt-2" onFinish={handleSave}>
<Form.Item name="name" label="客户名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 50, message: '2-50 个字符' }]}> <Form.Item
name="name"
label="客户姓名"
rules={[{ required: true, message: '请输入客户姓名' }, { min: 2, max: 50, message: '2-50 个字符' }]}
>
<Input maxLength={50} placeholder="姓名或昵称" /> <Input maxLength={50} placeholder="姓名或昵称" />
</Form.Item> </Form.Item>
<div className="grid grid-cols-2 gap-3">
<Form.Item name="phone" label="手机号" rules={[{ pattern: /^$|^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}> <Form.Item name="tel" label="联系电话">
<Input maxLength={20} placeholder="可选" /> <Input maxLength={30} placeholder="固话/座机,可选" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
</Form.Item>
</div>
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
<Input maxLength={100} placeholder="可选" />
</Form.Item> </Form.Item>
<Form.List name="phones">
{(fields, { add, remove }) => (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-neutral-700"></span>
<Button
type="link"
size="small"
icon={<PlusOutlined />}
onClick={() => add('')}
disabled={fields.length >= 5}
>
</Button>
</div>
<Space direction="vertical" className="w-full" size={8}>
{fields.map((field, index) => (
<div key={field.key} className="flex items-start gap-2">
<Form.Item
{...field}
className="flex-1 !mb-0"
rules={[
{
pattern: /^$|^1[3-9]\d{9}$/,
message: '手机号格式不正确',
},
]}
>
<Input
maxLength={20}
placeholder={index === 0 ? '主手机号(可选)' : `手机号 ${index + 1}`}
prefix={<PhoneOutlined className="text-neutral-400" />}
/>
</Form.Item>
{fields.length > 1 && (
<button
type="button"
className="h-8 w-8 mt-0.5 rounded-md text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer"
onClick={() => remove(field.name)}
title="删除"
>
<MinusCircleOutlined />
</button>
)}
</div>
))}
</Space>
<div className="text-xs text-neutral-400 mt-1"></div>
</div>
)}
</Form.List>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Form.Item name="wechat" label="微信号"> <Form.Item name="wechat" label="微信号">
<Input maxLength={50} placeholder="可选" /> <Input maxLength={50} placeholder="可选" />
@@ -717,16 +864,27 @@ const Customers = () => {
<Input maxLength={20} placeholder="可选" /> <Input maxLength={20} placeholder="可选" />
</Form.Item> </Form.Item>
</div> </div>
<Form.Item name="source" label="来源渠道">
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" /> <Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
<Input maxLength={100} placeholder="可选" />
</Form.Item> </Form.Item>
<div className="grid grid-cols-2 gap-3">
<Form.Item name="source" label="客户来源">
<Input maxLength={30} placeholder="如:官网、微信、APP、手动录入" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
</Form.Item>
</div>
<Form.Item <Form.Item
name="tags" name="tags"
label="标签" label="客户标签"
extra={ extra={
tagSelectOptions.length === 0 tagSelectOptions.length === 0
? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建' ? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建'
: '仅可从标签库中选择(由管理员维护)' : '从标签库中选择'
} }
> >
<Select <Select
@@ -739,6 +897,15 @@ const Customers = () => {
optionFilterProp="label" optionFilterProp="label"
/> />
</Form.Item> </Form.Item>
<Form.Item name="remark" label="客户备注">
<Input.TextArea
maxLength={500}
showCount
rows={3}
placeholder="内部备注,客户不可见"
/>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>
+21 -4
View File
@@ -77,14 +77,31 @@ export interface AvailableAgent {
} }
export interface Customer { export interface Customer {
id: number; tenant_id: number; name: string; phone: string; email: string id: number
tenant_id: number
name: string
phone: string
/** 联系电话/固话 */
tel?: string
email: string
wechat?: string wechat?: string
qq?: string qq?: string
/** 客户备注 */
remark?: string
tags: string tags: string
source: string; status: string; conversation_count: number; last_contact_at: string source: string
status: string
conversation_count: number
last_contact_at: string
contacts?: CustomerContact[] contacts?: CustomerContact[]
} }
export type SaveCustomerPayload = Partial<Customer> & {
name?: string
/** 多个手机号;首项为主手机 */
phones?: string[]
}
export interface KnowledgeCategory { export interface KnowledgeCategory {
id: number; tenant_id: number; parent_id: number | null; name: string id: number; tenant_id: number; parent_id: number | null; name: string
/** 本分类直属条目数 */ /** 本分类直属条目数 */
@@ -347,8 +364,8 @@ export const exportStatisticsCSV = () =>
export const getCustomer = (id: number) => export const getCustomer = (id: number) =>
get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`) get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`)
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data) export const createCustomer = (data: SaveCustomerPayload) => post<Customer>('/customers', data)
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data) export const updateCustomer = (id: number, data: SaveCustomerPayload) => put<Customer>(`/customers/${id}`, data)
export const deleteCustomer = (id: number) => del(`/customers/${id}`) export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// 黑名单 // 黑名单