扩展客户资料表单:多手机号、固话、微信QQ与备注
添加/编辑客户支持动态多手机、联系电话、备注等字段,详情侧栏同步展示。
This commit is contained in:
@@ -45,7 +45,11 @@ func (h *CustomerHandler) List(c *gin.Context) {
|
||||
query = query.Where("id IN (?)", assignedCustomers)
|
||||
}
|
||||
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 != "" {
|
||||
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 的 contacts(source=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) {
|
||||
var customer model.Customer
|
||||
if err := c.ShouldBindJSON(&customer); err != nil {
|
||||
var req SaveCustomerReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
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)
|
||||
if customer.Tags != "" {
|
||||
normalized, err := normalizeCustomerTagsForTenant(customer.TenantID, customer.Tags, nil)
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
tags := req.Tags
|
||||
if tags != "" {
|
||||
normalized, err := normalizeCustomerTagsForTenant(tenantID, tags, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
customer.Tags = normalized
|
||||
tags = normalized
|
||||
} 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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
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)
|
||||
}
|
||||
@@ -130,35 +237,63 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
var raw map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
allowed := map[string]bool{
|
||||
"name": true, "phone": true, "email": true, "wechat": true, "qq": true,
|
||||
"tags": true, "source": true, "status": true,
|
||||
}
|
||||
for key := range updates {
|
||||
if !allowed[key] {
|
||||
delete(updates, key)
|
||||
}
|
||||
}
|
||||
// 字符串字段统一 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)
|
||||
// 多手机号完整同步
|
||||
var phones []string
|
||||
hasPhones := false
|
||||
if v, ok := raw["phones"]; ok {
|
||||
hasPhones = true
|
||||
if arr, ok := v.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
phones = append(phones, 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": "没有可更新字段"})
|
||||
return
|
||||
|
||||
allowed := map[string]bool{
|
||||
"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 {
|
||||
normalized, err := normalizeCustomerTagsForTenant(tenantID, raw, parseCustomerTagsJSON(customer.Tags))
|
||||
updates := map[string]interface{}{}
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
@@ -166,10 +301,19 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
updates["tags"] = normalized
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&customer).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
if len(updates) == 0 && !hasPhones {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
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)
|
||||
middleware.JSON(c, customer)
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ type Customer struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
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"`
|
||||
Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空
|
||||
Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空
|
||||
QQ string `gorm:"size:20;column:qq" json:"qq"`
|
||||
Remark string `gorm:"type:text" json:"remark"` // 客户备注
|
||||
Tags string `gorm:"type:text" json:"tags"`
|
||||
Source string `gorm:"size:30" json:"source"`
|
||||
Status string `gorm:"size:20;default:online" json:"status"`
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Input, Button, Empty, message, Modal, Form, Select, Popconfirm, Spin, Pagination,
|
||||
Input, Button, Empty, message, Modal, Form, Select, Popconfirm, Spin, Pagination, Space,
|
||||
} from 'antd'
|
||||
import {
|
||||
SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined,
|
||||
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
|
||||
type Customer, type CustomerTag, type Session,
|
||||
type Customer, type CustomerContact, type CustomerTag, type Session,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
@@ -144,6 +145,7 @@ const Customers = () => {
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [tagFilter, setTagFilter] = useState('all')
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||
const [detailContacts, setDetailContacts] = useState<CustomerContact[]>([])
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [historySessions, setHistorySessions] = useState<Session[]>([])
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
@@ -199,15 +201,30 @@ const Customers = () => {
|
||||
)
|
||||
}, [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) => {
|
||||
setSelectedCustomer(record)
|
||||
setPanelOpen(true)
|
||||
setHistorySessions([])
|
||||
setDetailContacts([])
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const res = await getCustomer(record.id)
|
||||
setSelectedCustomer(res.data.customer)
|
||||
setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : [])
|
||||
setDetailContacts(Array.isArray(res.data.contacts) ? res.data.contacts : [])
|
||||
} catch {
|
||||
// keep list snapshot
|
||||
} finally {
|
||||
@@ -222,37 +239,83 @@ const Customers = () => {
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ status: 'offline', source: '手动录入', tags: [] })
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (customer: Customer) => {
|
||||
setEditingId(customer.id)
|
||||
form.setFieldsValue({
|
||||
name: customer.name,
|
||||
phone: customer.phone,
|
||||
email: customer.email,
|
||||
wechat: customer.wechat || '',
|
||||
qq: customer.qq || '',
|
||||
source: customer.source,
|
||||
status: customer.status,
|
||||
tags: parseTags(customer.tags),
|
||||
status: 'offline',
|
||||
source: '手动录入',
|
||||
tags: [],
|
||||
phones: [''],
|
||||
tel: '',
|
||||
email: '',
|
||||
wechat: '',
|
||||
qq: '',
|
||||
remark: '',
|
||||
})
|
||||
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: {
|
||||
name: string; phone?: string; email?: string; wechat?: string; qq?: string
|
||||
source?: string; status?: string; tags?: string[]
|
||||
name: string
|
||||
phones?: string[]
|
||||
tel?: string
|
||||
email?: string
|
||||
wechat?: string
|
||||
qq?: string
|
||||
remark?: string
|
||||
source?: string
|
||||
status?: string
|
||||
tags?: string[]
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const phones = (values.phones || [])
|
||||
.map(p => (p || '').trim())
|
||||
.filter(Boolean)
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
phone: values.phone?.trim() || '',
|
||||
phones,
|
||||
phone: phones[0] || '',
|
||||
tel: values.tel?.trim() || '',
|
||||
email: values.email?.trim() || '',
|
||||
wechat: values.wechat?.trim() || '',
|
||||
qq: values.qq?.trim() || '',
|
||||
remark: values.remark?.trim() || '',
|
||||
source: values.source?.trim() || '手动录入',
|
||||
status: values.status || 'offline',
|
||||
tags: JSON.stringify(values.tags || []),
|
||||
@@ -565,12 +628,34 @@ const Customers = () => {
|
||||
<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="space-y-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PhoneOutlined className="text-neutral-400 text-[13px]" />
|
||||
<span className="text-[13px] text-neutral-700">
|
||||
{selectedCustomer.phone || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{selectedCustomer.tel && (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="text-neutral-400 text-[12px] shrink-0 w-[13px] text-center font-medium">电</span>
|
||||
<span className="text-[13px] text-neutral-700 truncate">{selectedCustomer.tel}</span>
|
||||
</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">
|
||||
<MailOutlined className="text-neutral-400 text-[13px] shrink-0" />
|
||||
<span className="text-[13px] text-neutral-700 truncate">
|
||||
@@ -598,6 +683,16 @@ const Customers = () => {
|
||||
</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="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">活跃数据</div>
|
||||
@@ -693,22 +788,74 @@ const Customers = () => {
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
width={560}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-3" onFinish={handleSave}>
|
||||
<Form.Item name="name" label="客户名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 50, message: '2-50 个字符' }]}>
|
||||
<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 个字符' }]}
|
||||
>
|
||||
<Input maxLength={50} placeholder="姓名或昵称" />
|
||||
</Form.Item>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="phone" label="手机号" rules={[{ pattern: /^$|^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}>
|
||||
<Input maxLength={20} 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 name="tel" label="联系电话">
|
||||
<Input maxLength={30} placeholder="固话/座机,可选" />
|
||||
</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">
|
||||
<Form.Item name="wechat" label="微信号">
|
||||
<Input maxLength={50} placeholder="可选" />
|
||||
@@ -717,16 +864,27 @@ const Customers = () => {
|
||||
<Input maxLength={20} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</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>
|
||||
|
||||
<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
|
||||
name="tags"
|
||||
label="标签"
|
||||
label="客户标签"
|
||||
extra={
|
||||
tagSelectOptions.length === 0
|
||||
? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建'
|
||||
: '仅可从标签库中选择(由管理员维护)'
|
||||
: '从标签库中选择'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
@@ -739,6 +897,15 @@ const Customers = () => {
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="客户备注">
|
||||
<Input.TextArea
|
||||
maxLength={500}
|
||||
showCount
|
||||
rows={3}
|
||||
placeholder="内部备注,客户不可见"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
+21
-4
@@ -77,14 +77,31 @@ export interface AvailableAgent {
|
||||
}
|
||||
|
||||
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
|
||||
qq?: string
|
||||
/** 客户备注 */
|
||||
remark?: 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[]
|
||||
}
|
||||
|
||||
export type SaveCustomerPayload = Partial<Customer> & {
|
||||
name?: string
|
||||
/** 多个手机号;首项为主手机 */
|
||||
phones?: string[]
|
||||
}
|
||||
|
||||
export interface KnowledgeCategory {
|
||||
id: number; tenant_id: number; parent_id: number | null; name: string
|
||||
/** 本分类直属条目数 */
|
||||
@@ -347,8 +364,8 @@ export const exportStatisticsCSV = () =>
|
||||
|
||||
export const getCustomer = (id: number) =>
|
||||
get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`)
|
||||
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||
export const createCustomer = (data: SaveCustomerPayload) => post<Customer>('/customers', data)
|
||||
export const updateCustomer = (id: number, data: SaveCustomerPayload) => put<Customer>(`/customers/${id}`, data)
|
||||
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
||||
|
||||
// 黑名单
|
||||
|
||||
Reference in New Issue
Block a user