扩展客户资料表单:多手机号、固话、微信QQ与备注

添加/编辑客户支持动态多手机、联系电话、备注等字段,详情侧栏同步展示。
This commit is contained in:
yml2213
2026-07-19 00:49:56 +08:00
parent 1f92c35963
commit 4b7d8b04ea
4 changed files with 408 additions and 78 deletions
+208 -41
View File
@@ -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
View File
@@ -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}`)
// 黑名单