扩展客户资料表单:多手机号、固话、微信QQ与备注
添加/编辑客户支持动态多手机、联系电话、备注等字段,详情侧栏同步展示。
This commit is contained in:
@@ -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 的 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) {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
@@ -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}`)
|
||||||
|
|
||||||
// 黑名单
|
// 黑名单
|
||||||
|
|||||||
Reference in New Issue
Block a user