feat(crm): 建档一步到位 + 客户详情加公司简介

痛点:原来建客户只能填公司名等 7 个字段,联系人必须先保存、进详情页、
再加一遍,一件事拆成三步;详情页也没地方记公司背景。

建档表单
· 补上后端本来就收但表单没暴露的字段:公司英文名、地址、来源
· 新增「主要联系人」一组(姓名/职位/邮箱/电话/WhatsApp),自动标为主联系人
· 联系人与客户在**同一个事务**里创建 —— 分两个请求的话,客户写成功、
  联系人写失败会留下一条没人能联系的半成品客户
· 姓名留空则整块不创建,「只想先记个公司名」的用法完全不受影响

公司简介(customers 新增 5 列)
· founded_year / employee_count / annual_revenue / profile,外加复用 address
· employee_count 与 annual_revenue 用 TEXT 而非数字:实际拿到的都是
  「50-200 人」「€5-10M」这种带单位的区间,用途是给人看、不参与计算
· founded_year 卡 1800~今年:见过把「2024 年营收」当成立年份填进来的,
  也见过手滑打成 20225,不合理的值宁可拒掉
· 一个字段都没填时显示引导语而不是一排「未填写」—— 说明白这些信息拿来
  干什么,人才有动力填
· 跟「编辑客户信息」分成两个弹窗:基本信息是成交必须的,公司简介是
  了解多少写多少,混在一起会让人以为后者也得填

为 AI 背调预留:research_at / research_sources 两列已建,背调结果将落到
上述字段,并要求每条结论带来源 URL。

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Drsakura
2026-10-28 16:40:29 +08:00
co-authored by Claude Opus 5
parent 7281c41434
commit 241fb229c6
9 changed files with 363 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-management-frontend",
"version": "1.2.0",
"version": "1.3.0",
"description": "外贸供应链管理系统 - 前端",
"type": "module",
"scripts": {
+137 -1
View File
@@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import {
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon, BuildingIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
@@ -298,6 +298,94 @@ function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
}
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
/**
* 公司简介编辑。
*
* 跟「编辑客户信息」分成两个弹窗,因为这两组字段的性质不同:
* 基本信息是成交必须的(公司名、国家、联系方式),公司简介是了解到多少写多少
* 的背景资料。混在一个表单里会让人以为后面这些也得填。
*/
function CompanyProfileModal({ customer, onClose, onSaved }) {
const [form, setForm] = useState({
founded_year: customer.founded_year || '',
employee_count: customer.employee_count || '',
annual_revenue: customer.annual_revenue || '',
address: customer.address || '',
profile: customer.profile || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const submit = async event => {
event.preventDefault()
setSaving(true)
setError('')
try {
/* 成立年份空字符串要转成 null,否则后端会拿 '' 去做数字校验然后报错。
其余字段后端会把空串当 null 处理,直接传即可。 */
await axios.patch(`/api/customers/${customer.id}`, {
...form,
founded_year: String(form.founded_year).trim() === '' ? null : Number(form.founded_year)
})
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>公司简介</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
<p className="form-hint">全部选填。谈判和定价时,这些背景常常比联系方式更有用。</p>
<div className="form-row">
<div className="form-group">
<label>成立年份</label>
<input type="number" min="1800" max={new Date().getFullYear()} placeholder="1998"
value={form.founded_year} onChange={e => set('founded_year', e.target.value)} />
</div>
<div className="form-group">
<label>员工规模</label>
<input type="text" placeholder="50-200 人"
value={form.employee_count} onChange={e => set('employee_count', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>年营收</label>
<input type="text" placeholder="€5-10M"
value={form.annual_revenue} onChange={e => set('annual_revenue', e.target.value)} />
</div>
<div className="form-group">
<label>办公地址</label>
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>公司简介</label>
<textarea rows="5" placeholder="主营业务、渠道、规模、合作过的品牌……"
value={form.profile} onChange={e => set('profile', e.target.value)} />
</div>
{error && <p className="form-error">{error}</p>}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? '保存中…' : '保存'}</button>
</div>
</form>
</div>
</div>
)
}
function CustomerProfileModal({ customer, onClose, onSaved }) {
const [form, setForm] = useState({
company: customer.company || '',
@@ -416,6 +504,7 @@ function CustomerDetail() {
const [contactError, setContactError] = useState('')
const [savingContact, setSavingContact] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const [companyOpen, setCompanyOpen] = useState(false)
/* 正在编辑的跟进记录;null = 没打开 */
const [followUpEditing, setFollowUpEditing] = useState(null)
@@ -483,6 +572,13 @@ function CustomerDetail() {
const contacts = data?.contacts || []
const inquiries = data?.inquiries || []
/* 公司简介卡片:一个字段都没填就整块显示引导语,而不是摆一排「未填写」。
address 也算进来 —— 它虽然存在基本信息里,但在这张卡上是「办公地址」。 */
const hasCompanyProfile = !!(customer && (
customer.founded_year || customer.employee_count ||
customer.annual_revenue || customer.profile || customer.address
))
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
const nextFollowUp = useMemo(() => {
const dated = followups.filter(record => record.next_action_date)
@@ -757,6 +853,38 @@ function CustomerDetail() {
{customer.notes && <p className="notes-box">{customer.notes}</p>}
</section>
{/* 公司简介:了解到多少写多少,全部选填。
也是 AI 背调结果的落点 —— 查到的成立年份/规模/营收会填进这里。 */}
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><BuildingIcon size={17} /> 公司简介</h2>
<p className="panel-sub">了解到多少写多少,都不是必填</p>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCompanyOpen(true)}>
<PencilIcon size={14} /> 编辑
</button>
)}
</div>
{hasCompanyProfile ? (
<>
<div className="kv-grid">
<div className="kv"><span className="kv-label">成立于</span><span className={`kv-value${customer.founded_year ? '' : ' is-empty'}`}>{customer.founded_year || '未填写'}</span></div>
<div className="kv"><span className="kv-label">员工规模</span><span className={`kv-value${customer.employee_count ? '' : ' is-empty'}`}>{customer.employee_count || '未填写'}</span></div>
<div className="kv"><span className="kv-label">年营收</span><span className={`kv-value${customer.annual_revenue ? '' : ' is-empty'}`}>{customer.annual_revenue || '未填写'}</span></div>
<div className="kv"><span className="kv-label">办公地址</span><span className={`kv-value${customer.address ? '' : ' is-empty'}`}>{customer.address || '未填写'}</span></div>
</div>
{customer.profile && <p className="company-profile-text">{customer.profile}</p>}
</>
) : (
<p className="panel-empty-hint">
还没填公司信息。成立年份、员工规模、年营收、业务简介这些,
在谈判和定价时常常比联系方式更有用。
</p>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
@@ -909,6 +1037,14 @@ function CustomerDetail() {
</div>
)}
{companyOpen && (
<CompanyProfileModal
customer={customer}
onClose={() => setCompanyOpen(false)}
onSaved={async () => { setCompanyOpen(false); await load() }}
/>
)}
{profileOpen && (
<CustomerProfileModal
customer={customer}
+98 -11
View File
@@ -28,15 +28,16 @@ function CustomerManagement() {
const [showAddModal, setShowAddModal] = useState(false)
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [formData, setFormData] = useState({
company: '',
country: '',
industry: '',
website: '',
email: '',
phone: '',
notes: ''
})
/* 建档时能填的东西尽量都放这儿 —— 原来只有公司名能填,联系人要等保存完
进详情页再加一遍,等于把一件事拆成三步。主联系人在另一张表,由后端在
同一个事务里一并建(分两个请求的话中间失败会留下没人能联系的半成品)。 */
const EMPTY_FORM = {
company: '', company_en: '', country: '', industry: '', website: '',
email: '', phone: '', address: '', source: '', notes: '',
primary_contact: { name: '', position: '', email: '', phone: '', whatsapp: '' }
}
const [formData, setFormData] = useState(EMPTY_FORM)
const fetchCustomers = useCallback(async (nextScope = scope) => {
setError('')
@@ -69,9 +70,13 @@ function CustomerManagement() {
setSaving(true)
setFormError('')
try {
const response = await axios.post('/api/customers', formData)
/* 联系人没填姓名就不提交这一块 —— 后端只认 name 非空才建联系人,
但显式不传更清楚,也省得将来后端改了判断条件就冒出空记录 */
const payload = { ...formData }
if (!payload.primary_contact?.name?.trim()) delete payload.primary_contact
const response = await axios.post('/api/customers', payload)
setShowAddModal(false)
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
setFormData(EMPTY_FORM)
const newId = response.data?.id
await fetchCustomers(scope)
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
@@ -344,6 +349,88 @@ function CustomerManagement() {
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>公司英文名</label>
<input
type="text"
placeholder="单证上用的抬头"
value={formData.company_en}
onChange={e => setFormData({ ...formData, company_en: e.target.value })}
/>
</div>
<div className="form-group">
<label>来源</label>
<input
type="text"
placeholder="展会 / 阿里询盘 / 老客介绍"
value={formData.source}
onChange={e => setFormData({ ...formData, source: e.target.value })}
/>
</div>
</div>
<div className="form-group">
<label>地址</label>
<input
type="text"
value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })}
/>
</div>
{/* 主要联系人:建档时顺手填掉,不用保存完再进详情页加一遍。
姓名留空则整块不创建,所以「只想先记个公司名」的场景不受影响。 */}
<div className="form-subhead">
主要联系人
<small>填了姓名才会创建,自动标为主联系人;留空可以之后再加</small>
</div>
<div className="form-row">
<div className="form-group">
<label>姓名</label>
<input
type="text"
value={formData.primary_contact.name}
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, name: e.target.value } })}
/>
</div>
<div className="form-group">
<label>职位</label>
<input
type="text"
placeholder="采购经理"
value={formData.primary_contact.position}
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, position: e.target.value } })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>联系人邮箱</label>
<input
type="email"
value={formData.primary_contact.email}
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, email: e.target.value } })}
/>
</div>
<div className="form-group">
<label>联系人电话</label>
<input
type="text"
value={formData.primary_contact.phone}
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, phone: e.target.value } })}
/>
</div>
</div>
<div className="form-group">
<label>WhatsApp</label>
<input
type="text"
placeholder="+49 176 1234567"
value={formData.primary_contact.whatsapp}
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, whatsapp: e.target.value } })}
/>
</div>
<div className="form-group">
<label>备注</label>
<textarea
+46
View File
@@ -1722,3 +1722,49 @@ body { -webkit-font-smoothing: auto; }
.oe-summary { flex-direction: column; }
.oe-grid { grid-template-columns: 1fr 1fr; }
}
/* ---------------------- 客户建档表单:分组小标题 ---------------------- */
/* 新增客户表单里把「主要联系人」跟公司字段分开 —— 混在一起会让人以为
联系人也是必填的,而它是「填了才建,留空无所谓」。 */
.form-subhead {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--border-light);
font-size: 13.5px;
font-weight: 600;
color: var(--text-primary);
}
.form-subhead small {
display: block;
margin-top: 3px;
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary);
}
/* ---------------------- 客户详情:公司简介卡片 ---------------------- */
.company-profile-text {
margin: 12px 0 0;
padding: 11px 13px;
border-radius: var(--radius-md, 8px);
background: var(--surface-deep);
color: var(--text-secondary);
font-size: 13.5px;
line-height: 1.85;
white-space: pre-wrap;
}
/* 一个字段都没填时的引导语。比摆一排「未填写」有用:
它说明白这些信息拿来干什么,人才有动力去填。 */
.panel-empty-hint {
margin: 4px 0 0;
padding: 16px 14px;
border: 1px dashed var(--border-light);
border-radius: var(--radius-md, 8px);
color: var(--text-tertiary);
font-size: 13px;
line-height: 1.8;
text-align: center;
}