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