扩展客户资料表单:多手机号、固话、微信QQ与备注
添加/编辑客户支持动态多手机、联系电话、备注等字段,详情侧栏同步展示。
This commit is contained in:
@@ -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<Customer | null>(null)
|
||||
const [detailContacts, setDetailContacts] = useState<CustomerContact[]>([])
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [historySessions, setHistorySessions] = useState<Session[]>([])
|
||||
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 = () => {
|
||||
<div className="px-5 py-3 border-b border-neutral-100">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">联系方式</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PhoneOutlined className="text-neutral-400 text-[13px]" />
|
||||
<span className="text-[13px] text-neutral-700">
|
||||
{selectedCustomer.phone || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{selectedCustomer.tel && (
|
||||
<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.tel}</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const phones = collectPhones(selectedCustomer, detailContacts).filter(Boolean)
|
||||
if (phones.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PhoneOutlined className="text-neutral-400 text-[13px]" />
|
||||
<span className="text-[13px] text-neutral-400">—</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return phones.map((p, i) => (
|
||||
<div key={`${p}-${i}`} className="flex items-center gap-2.5">
|
||||
<PhoneOutlined className="text-neutral-400 text-[13px]" />
|
||||
<span className="text-[13px] text-neutral-700">
|
||||
{p}
|
||||
{phones.length > 1 && (
|
||||
<span className="ml-1 text-[11px] text-neutral-400">手机{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<MailOutlined className="text-neutral-400 text-[13px] shrink-0" />
|
||||
<span className="text-[13px] text-neutral-700 truncate">
|
||||
@@ -598,6 +683,16 @@ const Customers = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
{(selectedCustomer.remark || '').trim() && (
|
||||
<div className="px-5 py-3 border-b border-neutral-100">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">客户备注</div>
|
||||
<div className="text-[13px] text-neutral-700 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{selectedCustomer.remark}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 活跃数据 — 三列 */}
|
||||
<div className="px-5 py-3 border-b border-neutral-100">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">活跃数据</div>
|
||||
@@ -693,22 +788,74 @@ const Customers = () => {
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
width={560}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-3" onFinish={handleSave}>
|
||||
<Form.Item name="name" label="客户名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 50, message: '2-50 个字符' }]}>
|
||||
<Form form={form} layout="vertical" className="mt-2" onFinish={handleSave}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="客户姓名"
|
||||
rules={[{ required: true, message: '请输入客户姓名' }, { min: 2, max: 50, message: '2-50 个字符' }]}
|
||||
>
|
||||
<Input maxLength={50} placeholder="姓名或昵称" />
|
||||
</Form.Item>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="phone" label="手机号" rules={[{ pattern: /^$|^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}>
|
||||
<Input maxLength={20} placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
|
||||
<Input maxLength={100} placeholder="可选" />
|
||||
|
||||
<Form.Item name="tel" label="联系电话">
|
||||
<Input maxLength={30} placeholder="固话/座机,可选" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.List name="phones">
|
||||
{(fields, { add, remove }) => (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-neutral-700">联系手机</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add('')}
|
||||
disabled={fields.length >= 5}
|
||||
>
|
||||
添加手机号
|
||||
</Button>
|
||||
</div>
|
||||
<Space direction="vertical" className="w-full" size={8}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key} className="flex items-start gap-2">
|
||||
<Form.Item
|
||||
{...field}
|
||||
className="flex-1 !mb-0"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^$|^1[3-9]\d{9}$/,
|
||||
message: '手机号格式不正确',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
maxLength={20}
|
||||
placeholder={index === 0 ? '主手机号(可选)' : `手机号 ${index + 1}`}
|
||||
prefix={<PhoneOutlined className="text-neutral-400" />}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 mt-0.5 rounded-md text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => remove(field.name)}
|
||||
title="删除"
|
||||
>
|
||||
<MinusCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
<div className="text-xs text-neutral-400 mt-1">可添加多个手机号,第一个作为主号码</div>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="wechat" label="微信号">
|
||||
<Input maxLength={50} placeholder="可选" />
|
||||
@@ -717,16 +864,27 @@ const Customers = () => {
|
||||
<Input maxLength={20} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="source" label="来源渠道">
|
||||
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
|
||||
|
||||
<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="source" label="客户来源">
|
||||
<Input maxLength={30} placeholder="如:官网、微信、APP、手动录入" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="标签"
|
||||
label="客户标签"
|
||||
extra={
|
||||
tagSelectOptions.length === 0
|
||||
? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建'
|
||||
: '仅可从标签库中选择(由管理员维护)'
|
||||
: '从标签库中选择'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
@@ -739,6 +897,15 @@ const Customers = () => {
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="客户备注">
|
||||
<Input.TextArea
|
||||
maxLength={500}
|
||||
showCount
|
||||
rows={3}
|
||||
placeholder="内部备注,客户不可见"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
+21
-4
@@ -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<Customer> & {
|
||||
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<Customer>) => post<Customer>('/customers', data)
|
||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||
export const createCustomer = (data: SaveCustomerPayload) => post<Customer>('/customers', data)
|
||||
export const updateCustomer = (id: number, data: SaveCustomerPayload) => put<Customer>(`/customers/${id}`, data)
|
||||
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
||||
|
||||
// 黑名单
|
||||
|
||||
Reference in New Issue
Block a user