客户基本信息支持快速编辑,新增微信与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
+26 -1
View File
@@ -232,6 +232,8 @@ const Customers = () => {
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),
@@ -240,7 +242,8 @@ const Customers = () => {
}
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)
try {
@@ -248,6 +251,8 @@ const Customers = () => {
name: values.name.trim(),
phone: values.phone?.trim() || '',
email: values.email?.trim() || '',
wechat: values.wechat?.trim() || '',
qq: values.qq?.trim() || '',
source: values.source?.trim() || '手动录入',
status: values.status || 'offline',
tags: JSON.stringify(values.tags || []),
@@ -572,6 +577,18 @@ const Customers = () => {
{selectedCustomer.email || '—'}
</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"></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">
<GlobalOutlined className="text-neutral-400 text-[13px]" />
<span className="text-[13px] text-neutral-700 whitespace-nowrap">
@@ -692,6 +709,14 @@ const Customers = () => {
<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="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="来源渠道">
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
</Form.Item>
+138 -14
View File
@@ -12,11 +12,95 @@ import { useAuth } from '@/stores/auth'
import {
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
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 Session, type SessionEvent, type VisitorPageView,
} 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 排序 */
function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] {
const map = new Map<number, Message>()
@@ -185,6 +269,7 @@ const Dashboard = () => {
/** 访客输入框未发送草稿(实时) */
const [visitorDraft, setVisitorDraft] = useState('')
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
/** 驱动在线读秒每秒刷新 */
const [clockTick, setClockTick] = useState(0)
const initialLoad = useRef(true)
@@ -714,6 +799,22 @@ const Dashboard = () => {
return () => { cancelled = true }
}, [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 filteredSessions = sessions
.filter(session => {
@@ -1493,29 +1594,52 @@ const Dashboard = () => {
<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">
<span className="font-normal normal-case tracking-normal text-neutral-400"></span>
</div>
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
<span className="truncate text-neutral-800">{selectedCustomer.phone || ''}</span>
</div>
<div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
<span className="truncate text-neutral-800">{selectedCustomer.email || '—'}</span>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<CustomerInfoField
label="手机"
value={selectedCustomer.phone || ''}
placeholder="点击填写手机号"
saving={savingCustomerField === 'phone'}
onSave={v => saveCustomerField(selectedCustomer.id, 'phone', v)}
/>
<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) =>
k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k
// 主字段已展示的手机/邮箱不再重复;微信/QQ 等一律展示
// 主字段已展示的联系方式不重复
const extra = customerContacts.filter(c => {
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 === 'wechat' && selectedCustomer.wechat && c.value === selectedCustomer.wechat) return false
if (c.kind === 'qq' && selectedCustomer.qq && c.value === selectedCustomer.qq) return false
return true
})
if (extra.length === 0) return null
return (
<div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
<div className="flex items-start gap-2 pt-1">
<span className="shrink-0 text-neutral-400 min-w-12 leading-5"></span>
<div className="flex flex-col gap-1 min-w-0">
{extra.map(c => (
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
@@ -1530,7 +1654,7 @@ const Dashboard = () => {
</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="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
</div>
+4 -1
View File
@@ -61,7 +61,10 @@ export interface SessionEvent {
export interface AvailableAgent { id: number; nickname: string; status: string }
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
contacts?: CustomerContact[]
}