diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index 3ae20f3..7332845 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -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) } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 3872cc7..2878bc9 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -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"` diff --git a/web/src/pages/agent/Customers.tsx b/web/src/pages/agent/Customers.tsx index 9ddea76..5e0c47a 100644 --- a/web/src/pages/agent/Customers.tsx +++ b/web/src/pages/agent/Customers.tsx @@ -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(null) + const [detailContacts, setDetailContacts] = useState([]) const [editingId, setEditingId] = useState(null) const [historySessions, setHistorySessions] = useState([]) 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 = () => {
联系方式
-
- - - {selectedCustomer.phone || '—'} - -
+ {selectedCustomer.tel && ( +
+ + {selectedCustomer.tel} +
+ )} + {(() => { + const phones = collectPhones(selectedCustomer, detailContacts).filter(Boolean) + if (phones.length === 0) { + return ( +
+ + +
+ ) + } + return phones.map((p, i) => ( +
+ + + {p} + {phones.length > 1 && ( + 手机{i + 1} + )} + +
+ )) + })()}
@@ -598,6 +683,16 @@ const Customers = () => {
+ {/* 备注 */} + {(selectedCustomer.remark || '').trim() && ( +
+
客户备注
+
+ {selectedCustomer.remark} +
+
+ )} + {/* 活跃数据 — 三列 */}
活跃数据
@@ -693,22 +788,74 @@ const Customers = () => { confirmLoading={saving} destroyOnClose okText="保存" + width={560} + styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }} > -
- + + -
- - - - - + + + + + + {(fields, { add, remove }) => ( +
+
+ 联系手机 + +
+ + {fields.map((field, index) => ( +
+ + } + /> + + {fields.length > 1 && ( + + )} +
+ ))} +
+
可添加多个手机号,第一个作为主号码
+
+ )} +
+
@@ -717,16 +864,27 @@ const Customers = () => {
- - + + + + +
+ + + + + { optionFilterProp="label" /> + + + +
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 5232819..100633e 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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 & { + 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) => post('/customers', data) -export const updateCustomer = (id: number, data: Partial) => put(`/customers/${id}`, data) +export const createCustomer = (data: SaveCustomerPayload) => post('/customers', data) +export const updateCustomer = (id: number, data: SaveCustomerPayload) => put(`/customers/${id}`, data) export const deleteCustomer = (id: number) => del(`/customers/${id}`) // 黑名单