客户基本信息支持快速编辑,新增微信与QQ字段

工作台侧栏可点击编辑手机/邮箱/微信/QQ;客户资料同步展示,自动识别仅在主字段为空时回填。
This commit is contained in:
yml2213
2026-07-19 00:23:21 +08:00
parent 6070cf47dc
commit cbc76f3258
6 changed files with 191 additions and 17 deletions
@@ -137,6 +137,14 @@ func mergeCustomerContacts(tenantID, customerID uint, contacts []extractedContac
updates["email"] = c.Value updates["email"] = c.Value
customer.Email = c.Value customer.Email = c.Value
} }
if c.Kind == "wechat" && strings.TrimSpace(customer.Wechat) == "" && updates["wechat"] == nil {
updates["wechat"] = c.Value
customer.Wechat = c.Value
}
if c.Kind == "qq" && strings.TrimSpace(customer.QQ) == "" && updates["qq"] == nil {
updates["qq"] = c.Value
customer.QQ = c.Value
}
} }
if len(updates) > 0 { if len(updates) > 0 {
model.DB.Model(&model.Customer{}).Where("id = ?", customerID).Updates(updates) model.DB.Model(&model.Customer{}).Where("id = ?", customerID).Updates(updates)
+13 -1
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/middleware"
@@ -135,12 +136,23 @@ func (h *CustomerHandler) Update(c *gin.Context) {
return return
} }
allowed := map[string]bool{"name": true, "phone": true, "email": true, "tags": true, "source": true, "status": true} allowed := map[string]bool{
"name": true, "phone": true, "email": true, "wechat": true, "qq": true,
"tags": true, "source": true, "status": true,
}
for key := range updates { for key := range updates {
if !allowed[key] { if !allowed[key] {
delete(updates, 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)
}
}
}
if len(updates) == 0 { if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return return
+2
View File
@@ -52,6 +52,8 @@ type Customer struct {
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"`
Email string `gorm:"size:100" json:"email"` Email string `gorm:"size:100" json:"email"`
Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空
QQ string `gorm:"size:20;column:qq" json:"qq"`
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"`
+26 -1
View File
@@ -232,6 +232,8 @@ const Customers = () => {
name: customer.name, name: customer.name,
phone: customer.phone, phone: customer.phone,
email: customer.email, email: customer.email,
wechat: customer.wechat || '',
qq: customer.qq || '',
source: customer.source, source: customer.source,
status: customer.status, status: customer.status,
tags: parseTags(customer.tags), tags: parseTags(customer.tags),
@@ -240,7 +242,8 @@ const Customers = () => {
} }
const handleSave = async (values: { const handleSave = async (values: {
name: string; phone?: string; email?: string; source?: string; status?: string; tags?: string[] name: string; phone?: string; email?: string; wechat?: string; qq?: string
source?: string; status?: string; tags?: string[]
}) => { }) => {
setSaving(true) setSaving(true)
try { try {
@@ -248,6 +251,8 @@ const Customers = () => {
name: values.name.trim(), name: values.name.trim(),
phone: values.phone?.trim() || '', phone: values.phone?.trim() || '',
email: values.email?.trim() || '', email: values.email?.trim() || '',
wechat: values.wechat?.trim() || '',
qq: values.qq?.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 || []),
@@ -572,6 +577,18 @@ const Customers = () => {
{selectedCustomer.email || '—'} {selectedCustomer.email || '—'}
</span> </span>
</div> </div>
<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.wechat || '—'}
</span>
</div>
<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">Q</span>
<span className="text-[13px] text-neutral-700 truncate">
{selectedCustomer.qq || '—'}
</span>
</div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<GlobalOutlined className="text-neutral-400 text-[13px]" /> <GlobalOutlined className="text-neutral-400 text-[13px]" />
<span className="text-[13px] text-neutral-700 whitespace-nowrap"> <span className="text-[13px] text-neutral-700 whitespace-nowrap">
@@ -692,6 +709,14 @@ const Customers = () => {
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}> <Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
<Input maxLength={100} placeholder="可选" /> <Input maxLength={100} placeholder="可选" />
</Form.Item> </Form.Item>
<div className="grid grid-cols-2 gap-3">
<Form.Item name="wechat" label="微信号">
<Input maxLength={50} placeholder="可选" />
</Form.Item>
<Form.Item name="qq" label="QQ 号" rules={[{ pattern: /^$|^[1-9]\d{4,11}$/, message: 'QQ 号格式不正确' }]}>
<Input maxLength={20} placeholder="可选" />
</Form.Item>
</div>
<Form.Item name="source" label="来源渠道"> <Form.Item name="source" label="来源渠道">
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" /> <Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
</Form.Item> </Form.Item>
+138 -14
View File
@@ -12,11 +12,95 @@ import { useAuth } from '@/stores/auth'
import { import {
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries, addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply, suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply, type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
type Session, type SessionEvent, type VisitorPageView, type Session, type SessionEvent, type VisitorPageView,
} from '@/services/api' } from '@/services/api'
type CustomerEditableField = 'phone' | 'email' | 'wechat' | 'qq'
/** 客户侧栏基本信息:点击输入,失焦/回车保存 */
function CustomerInfoField({
label,
value,
placeholder,
disabled,
saving,
onSave,
}: {
label: string
value: string
placeholder?: string
disabled?: boolean
saving?: boolean
onSave: (next: string) => Promise<void> | void
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editing) setDraft(value)
}, [value, editing])
useEffect(() => {
if (editing) {
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}
}, [editing])
const commit = async () => {
const next = draft.trim()
setEditing(false)
if (next === (value || '').trim()) return
await onSave(next)
}
return (
<div className="flex items-center gap-2 min-h-[28px]">
<span className="shrink-0 text-neutral-400 min-w-12 leading-7">{label}</span>
{editing ? (
<input
ref={inputRef}
className="min-w-0 flex-1 h-7 px-2 rounded border border-[#2563eb] bg-white text-[13px] text-neutral-800 outline-none"
value={draft}
disabled={saving || disabled}
placeholder={placeholder || '点击填写'}
onChange={e => setDraft(e.target.value)}
onBlur={() => { void commit() }}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
void commit()
}
if (e.key === 'Escape') {
setDraft(value)
setEditing(false)
}
}}
/>
) : (
<button
type="button"
disabled={disabled || saving}
title="点击编辑"
onClick={() => !disabled && setEditing(true)}
className={`min-w-0 flex-1 text-left h-7 px-1 -mx-1 rounded truncate text-[13px] leading-7 ${
value
? 'text-neutral-800 hover:bg-neutral-100'
: 'text-neutral-400 hover:bg-neutral-100'
} disabled:cursor-default disabled:hover:bg-transparent`}
>
{value || placeholder || '点击填写'}
</button>
)}
</div>
)
}
/** 按 id 合并消息,再按 seq / id 排序 */ /** 按 id 合并消息,再按 seq / id 排序 */
function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] { function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] {
const map = new Map<number, Message>() const map = new Map<number, Message>()
@@ -185,6 +269,7 @@ const Dashboard = () => {
/** 访客输入框未发送草稿(实时) */ /** 访客输入框未发送草稿(实时) */
const [visitorDraft, setVisitorDraft] = useState('') const [visitorDraft, setVisitorDraft] = useState('')
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([]) const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
/** 驱动在线读秒每秒刷新 */ /** 驱动在线读秒每秒刷新 */
const [clockTick, setClockTick] = useState(0) const [clockTick, setClockTick] = useState(0)
const initialLoad = useRef(true) const initialLoad = useRef(true)
@@ -714,6 +799,22 @@ const Dashboard = () => {
return () => { cancelled = true } return () => { cancelled = true }
}, [selectedCustomer?.id, selectedId]) }, [selectedCustomer?.id, selectedId])
const saveCustomerField = useCallback(async (customerId: number, field: CustomerEditableField, value: string) => {
setSavingCustomerField(field)
try {
const res = await updateCustomer(customerId, { [field]: value })
const updated = res.data
setCustomers(prev => ({
...prev,
[customerId]: { ...prev[customerId], ...updated },
}))
} catch (e) {
antMsg.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSavingCustomerField(null)
}
}, [])
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all' const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
const filteredSessions = sessions const filteredSessions = sessions
.filter(session => { .filter(session => {
@@ -1493,29 +1594,52 @@ const Dashboard = () => {
<div className="mb-5"> <div className="mb-5">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider"> <div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
<span className="font-normal normal-case tracking-normal text-neutral-400"></span>
</div> </div>
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-1.5 text-sm">
<div className="flex items-start gap-2"> <CustomerInfoField
<span className="shrink-0 text-neutral-400 min-w-12"></span> label="手机"
<span className="truncate text-neutral-800">{selectedCustomer.phone || ''}</span> value={selectedCustomer.phone || ''}
</div> placeholder="点击填写手机号"
<div className="flex items-start gap-2"> saving={savingCustomerField === 'phone'}
<span className="shrink-0 text-neutral-400 min-w-12"></span> onSave={v => saveCustomerField(selectedCustomer.id, 'phone', v)}
<span className="truncate text-neutral-800">{selectedCustomer.email || '—'}</span> />
</div> <CustomerInfoField
label="邮箱"
value={selectedCustomer.email || ''}
placeholder="点击填写邮箱"
saving={savingCustomerField === 'email'}
onSave={v => saveCustomerField(selectedCustomer.id, 'email', v)}
/>
<CustomerInfoField
label="微信"
value={selectedCustomer.wechat || ''}
placeholder="点击填写微信号"
saving={savingCustomerField === 'wechat'}
onSave={v => saveCustomerField(selectedCustomer.id, 'wechat', v)}
/>
<CustomerInfoField
label="QQ"
value={selectedCustomer.qq || ''}
placeholder="点击填写 QQ 号"
saving={savingCustomerField === 'qq'}
onSave={v => saveCustomerField(selectedCustomer.id, 'qq', v)}
/>
{(() => { {(() => {
const kindLabel = (k: string) => const kindLabel = (k: string) =>
k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k
// 主字段已展示的手机/邮箱不再重复;微信/QQ 等一律展示 // 主字段已展示的联系方式不重复
const extra = customerContacts.filter(c => { const extra = customerContacts.filter(c => {
if (c.kind === 'phone' && selectedCustomer.phone && c.value === selectedCustomer.phone) return false if (c.kind === 'phone' && selectedCustomer.phone && c.value === selectedCustomer.phone) return false
if (c.kind === 'email' && selectedCustomer.email && c.value === selectedCustomer.email) return false if (c.kind === 'email' && selectedCustomer.email && c.value === selectedCustomer.email) return false
if (c.kind === 'wechat' && selectedCustomer.wechat && c.value === selectedCustomer.wechat) return false
if (c.kind === 'qq' && selectedCustomer.qq && c.value === selectedCustomer.qq) return false
return true return true
}) })
if (extra.length === 0) return null if (extra.length === 0) return null
return ( return (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2 pt-1">
<span className="shrink-0 text-neutral-400 min-w-12"></span> <span className="shrink-0 text-neutral-400 min-w-12 leading-5"></span>
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
{extra.map(c => ( {extra.map(c => (
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all"> <span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
@@ -1530,7 +1654,7 @@ const Dashboard = () => {
</div> </div>
) )
})()} })()}
<div className="flex items-start gap-2"> <div className="flex items-start gap-2 pt-1">
<span className="shrink-0 text-neutral-400 min-w-12"></span> <span className="shrink-0 text-neutral-400 min-w-12"></span>
<span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span> <span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
</div> </div>
+4 -1
View File
@@ -61,7 +61,10 @@ export interface SessionEvent {
export interface AvailableAgent { id: number; nickname: string; status: string } export interface AvailableAgent { id: number; nickname: string; status: string }
export interface Customer { export interface Customer {
id: number; tenant_id: number; name: string; phone: string; email: string; tags: string id: number; tenant_id: number; name: string; phone: string; email: string
wechat?: string
qq?: 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[]
} }