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 cf20aec68c
9 changed files with 363 additions and 27 deletions
+16 -1
View File
@@ -256,7 +256,22 @@ function ensureColumns() {
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
['created_by', 'INTEGER'],
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT'],
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。
全部选填 —— 这些是「了解到多少写多少」的东西,不是业务必填项。
employee_count / annual_revenue 用 TEXT 而不是数字:实际拿到的几乎都是
「50-200 人」「€5-10M」这种区间带单位的表述,硬塞进 INTEGER 要么丢精度
要么存不进去,还得再加个单位列。这里的用途是「给人看」,不参与计算。 */
['founded_year', 'INTEGER'],
['employee_count', 'TEXT'],
['annual_revenue', 'TEXT'],
['profile', 'TEXT'],
/* 背调留痕:什么时候查的、信息来自哪些页面。
没有来源的「AI 说这家公司有 200 人」是不能用来做决策的,
所以来源 URL 必须跟结论一起存,界面上要能点开核对。 */
['research_at', 'TEXT'],
['research_sources', 'TEXT']
]
};
for (const [table, columns] of Object.entries(columnMigrations)) {
+10
View File
@@ -207,6 +207,16 @@ CREATE TABLE IF NOT EXISTS customers (
pool_status TEXT DEFAULT 'owned',
pool_at TEXT,
pool_note TEXT,
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。全部选填。
employee_count / annual_revenue 用 TEXT 而非数字:实际拿到的几乎都是
「50-200 人」「€5-10M」这种带单位的区间表述,用途是给人看、不参与计算。 */
founded_year INTEGER,
employee_count TEXT,
annual_revenue TEXT,
profile TEXT,
/* 背调留痕:没有来源的结论不能拿来做决策,来源 URL 必须跟结论一起存 */
research_at TEXT,
research_sources TEXT,
source TEXT,
tags TEXT,
notes TEXT,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-management-backend",
"version": "1.2.0",
"version": "1.3.0",
"description": "外贸供应链管理系统 - 后端",
"main": "server.js",
"scripts": {
+45 -3
View File
@@ -364,6 +364,14 @@ app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) =
只有拿到 customer.view_all 的管理账号才允许代别人建档。 */
const requestedOwner = customerAccess.seesAll(req.user) ? numberOrNull(req.body?.owner_id) : null;
const ownerId = requestedOwner || (req.user ? req.user.id : null);
/* 主要联系人:建档时一并填,不用「先存公司 → 进详情页 → 再加联系人」三步。
联系人在另一张表,所以必须跟客户放进**同一个事务** —— 分两次写的话,
客户写成功、联系人写失败就留下一条没人能联系的半成品客户。 */
const pc = req.body?.primary_contact || {};
const contactName = String(pc.name || '').trim();
const create = db.transaction(() => {
const result = db.prepare(`
INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes,
owner_id, created_by, org_id, source, status, pool_status, created_at)
@@ -372,15 +380,31 @@ app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) =
email || null, phone || null, notes || null, ownerId,
req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null,
source || null, status, nowIso());
const customerId = result.lastInsertRowid;
if (contactName) {
db.prepare(`
INSERT INTO contacts (customer_id, name, name_en, position, email, phone, whatsapp, wechat, is_primary, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
`).run(customerId, contactName,
pc.name_en || null, pc.position || null, pc.email || null, pc.phone || null,
pc.whatsapp || null, pc.wechat || null, pc.notes || null, nowIso());
}
return customerId;
});
const customerId = create();
auth.audit({
username: req.user?.username,
action: 'customer_create',
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}`,
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}${contactName ? `,主联系人 ${contactName}` : ''}`,
req
});
res.status(201).json({
id: result.lastInsertRowid,
id: customerId,
owner_id: ownerId,
contact_created: !!contactName,
message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)'
});
} catch (error) { jsonError(res, error); }
@@ -396,13 +420,31 @@ app.patch('/api/customers/:id', auth.requirePermission('customer.edit'), (req, r
}
const fields = [];
const values = [];
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone', 'notes', 'source']) {
/* employee_count / annual_revenue / profile 是「公司简介」卡片的字段,
走同一条通道 —— 它们跟基本信息一样是人填的文本,没有特殊校验。 */
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone',
'notes', 'source', 'employee_count', 'annual_revenue', 'profile']) {
if (req.body?.[key] === undefined) continue;
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
if (key === 'company' && !value) throw badRequest('公司名称不能为空');
fields.push(`${key} = ?`);
values.push(value || null);
}
/* 成立年份单独处理:它是数字,且范围要卡住 —— 见过 AI 背调把「2024 年营收」
当成立年份填进来的,也见过手滑打成 20225。不合理的值宁可拒掉也别存。 */
if (req.body?.founded_year !== undefined) {
const raw = req.body.founded_year;
if (raw === null || raw === '') {
fields.push('founded_year = ?'); values.push(null);
} else {
const year = Number(raw);
const thisYear = new Date().getFullYear();
if (!Number.isInteger(year) || year < 1800 || year > thisYear) {
throw badRequest(`成立年份应在 1800 ~ ${thisYear} 之间`);
}
fields.push('founded_year = ?'); values.push(year);
}
}
if (req.body?.owner_id !== undefined) {
/* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */
if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属');
+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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "happy-export",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
"repository": {