客户基本信息支持快速编辑,新增微信与QQ字段
工作台侧栏可点击编辑手机/邮箱/微信/QQ;客户资料同步展示,自动识别仅在主字段为空时回填。
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user