From 530f7261309a20c5492c99249330bbae929291ba Mon Sep 17 00:00:00 2001 From: Rei Date: Wed, 30 Sep 2026 18:23:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=92=A8=E8=AF=A2=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E8=AF=89?= =?UTF-8?q?=E6=B1=82=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cms/admin/src/App.tsx | 4 +- cms/server/src/contact-leads.js | 45 ++++++ cms/server/src/index.js | 21 +-- cms/server/src/wecom-notifier.js | 12 +- cms/server/test/contact-leads.test.js | 52 +++++++ cms/server/test/wecom-notifier.test.js | 12 +- src/pages/Contact/contact.css | 176 ++++++++++++++++++++++ src/pages/Contact/index.tsx | 196 ++++++++++++++++++------- 8 files changed, 448 insertions(+), 70 deletions(-) create mode 100644 cms/server/src/contact-leads.js create mode 100644 cms/server/test/contact-leads.test.js diff --git a/cms/admin/src/App.tsx b/cms/admin/src/App.tsx index 75191b8..0322600 100644 --- a/cms/admin/src/App.tsx +++ b/cms/admin/src/App.tsx @@ -124,8 +124,8 @@ function ContactLeads({token}:{token:string}){ const [list,setList]=useState([]) useEffect(()=>{api('/api/cms/contact-leads',token).then(j=> setList(j.data||[]))},[]) return

咨询线索(最近200条)

- - {list.map(l=>)} +
姓名企业手机需求时间
{l.name}{l.company}{l.phone}{l.need}{l.created_at}
+ {list.map(l=>)}
您的称呼企业名联系电话核心诉求时间
{l.name||'未填写'}{l.company||'未填写'}{l.phone}{l.need}{l.created_at}
} diff --git a/cms/server/src/contact-leads.js b/cms/server/src/contact-leads.js new file mode 100644 index 0000000..8ad891f --- /dev/null +++ b/cms/server/src/contact-leads.js @@ -0,0 +1,45 @@ +export const OTHER_CONTACT_NEED = '其他个性需求'; + +export const CONTACT_NEED_OPTIONS = [ + 'IT数字化部署', + 'AI智能体部署', + '经营体系建设', + '项目融资需求', + '实缴合规管理', + '获客自动化部署', + OTHER_CONTACT_NEED +]; + +export class ContactLeadValidationError extends Error {} + +function clean(value, maxLength) { + return String(value ?? '').trim().slice(0, maxLength); +} + +export function normalizeContactLeadInput(input = {}) { + const name = clean(input.name, 80); + const company = clean(input.company, 160); + const phone = clean(input.phone, 20); + const otherNeed = clean(input.otherNeed, 1000); + const rawNeeds = Array.isArray(input.needs) ? input.needs : []; + const needs = [...new Set(rawNeeds.map(item => clean(item, 40)).filter(Boolean))]; + + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new ContactLeadValidationError('请输入合法的11位中国大陆手机号'); + } + if (needs.length === 0) { + throw new ContactLeadValidationError('请至少选择一项核心诉求'); + } + if (needs.some(item => !CONTACT_NEED_OPTIONS.includes(item))) { + throw new ContactLeadValidationError('核心诉求包含无效选项'); + } + if (needs.includes(OTHER_CONTACT_NEED) && !otherNeed) { + throw new ContactLeadValidationError('请填写其他个性需求'); + } + + const need = needs + .map(item => item === OTHER_CONTACT_NEED ? `${OTHER_CONTACT_NEED}:${otherNeed}` : item) + .join(';'); + + return { name, company, phone, needs, otherNeed, need }; +} diff --git a/cms/server/src/index.js b/cms/server/src/index.js index 0de1360..4149bf5 100644 --- a/cms/server/src/index.js +++ b/cms/server/src/index.js @@ -12,6 +12,7 @@ import { initDB, getDB, UPLOAD_DIR } from './db.js'; import { signToken, verifyToken, authMiddleware } from './middleware/auth.js'; import { createContentIngestRouter } from './content-ingest.js'; import { sendContactLeadToWeCom } from './wecom-notifier.js'; +import { ContactLeadValidationError, normalizeContactLeadInput } from './contact-leads.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -131,18 +132,18 @@ app.use('/api/internal/content/v1', createContentIngestRouter()); app.use('/api/contact/leads', contactLeadRateLimit); app.post('/api/contact/leads', (req, res) => { - const { name, company = '', phone, need = '' } = req.body || {}; - if (!name || !phone) return res.status(400).json({ code: 400, msg: '姓名和手机号必填' }); - if (!/^1[3-9]\d{9}$/.test(String(phone))) return res.status(400).json({ code: 400, msg: '手机号格式错误' }); + let lead; + try { + lead = normalizeContactLeadInput(req.body); + } catch (error) { + if (error instanceof ContactLeadValidationError) { + return res.status(400).json({ code: 400, msg: error.message }); + } + return res.status(400).json({ code: 400, msg: '表单内容格式错误' }); + } const id = genId('contact'); const createdAt = nowISO(); - const lead = { - name: String(name).trim().slice(0, 80), - company: String(company).trim().slice(0, 160), - phone: String(phone), - need: String(need).trim().slice(0, 2000), - createdAt - }; + lead.createdAt = createdAt; db.run(`INSERT INTO contact_leads (id, name, company, phone, need, created_at) VALUES (?,?,?,?,?,?)`, [id, lead.name, lead.company, lead.phone, lead.need, createdAt], err => { if (err) return res.status(500).json({ code: 500, msg: '提交失败,请稍后再试' }); diff --git a/cms/server/src/wecom-notifier.js b/cms/server/src/wecom-notifier.js index aa7e4e6..cf04273 100644 --- a/cms/server/src/wecom-notifier.js +++ b/cms/server/src/wecom-notifier.js @@ -14,13 +14,19 @@ export function formatContactLeadMessage(lead, { siteUrl = 'https://www.ameiba.c hour12: false }); + const needs = Array.isArray(lead.needs) && lead.needs.length > 0 + ? lead.needs.map(item => `• ${cleanLine(item, 40)}`) + : [`• ${cleanLine(lead.need, 2000) || '未填写'}`]; + if (lead.otherNeed) needs.push(` 说明:${cleanLine(lead.otherNeed, 1000)}`); + return [ '【官网新咨询】', '', - `姓名:${cleanLine(lead.name, 80) || '未填写'}`, - `企业:${cleanLine(lead.company, 160) || '未填写'}`, + `您的称呼:${cleanLine(lead.name, 80) || '未填写'}`, `联系电话:${cleanLine(lead.phone, 30) || '未填写'}`, - `需求描述:${cleanLine(lead.need, 2000) || '未填写'}`, + `企业名:${cleanLine(lead.company, 160) || '未填写'}`, + '核心诉求:', + ...needs, `提交时间:${submittedAt}`, `来源:${cleanLine(siteUrl, 200)}` ].join('\n'); diff --git a/cms/server/test/contact-leads.test.js b/cms/server/test/contact-leads.test.js new file mode 100644 index 0000000..6a393f2 --- /dev/null +++ b/cms/server/test/contact-leads.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ContactLeadValidationError, + normalizeContactLeadInput +} from '../src/contact-leads.js'; + +test('规范化有效咨询表单并允许称呼和企业名为空', () => { + const result = normalizeContactLeadInput({ + phone: '13800138000', + needs: ['IT数字化部署', 'AI智能体部署'] + }); + + assert.equal(result.name, ''); + assert.equal(result.company, ''); + assert.equal(result.phone, '13800138000'); + assert.equal(result.need, 'IT数字化部署;AI智能体部署'); +}); + +test('拒绝不合法的中国大陆手机号', () => { + for (const phone of ['12800138000', '1380013800', '23800138000', '138001380000']) { + assert.throws( + () => normalizeContactLeadInput({ phone, needs: ['经营体系建设'] }), + ContactLeadValidationError + ); + } +}); + +test('核心诉求至少选择一项且只接受预设选项', () => { + assert.throws( + () => normalizeContactLeadInput({ phone: '13800138000', needs: [] }), + /至少选择一项/ + ); + assert.throws( + () => normalizeContactLeadInput({ phone: '13800138000', needs: ['未知选项'] }), + /无效选项/ + ); +}); + +test('选择其他个性需求时必须填写说明', () => { + assert.throws( + () => normalizeContactLeadInput({ phone: '13800138000', needs: ['其他个性需求'] }), + /填写其他个性需求/ + ); + + const result = normalizeContactLeadInput({ + phone: '13800138000', + needs: ['其他个性需求'], + otherNeed: '希望先进行现场访谈' + }); + assert.equal(result.need, '其他个性需求:希望先进行现场访谈'); +}); diff --git a/cms/server/test/wecom-notifier.test.js b/cms/server/test/wecom-notifier.test.js index c8c05eb..5faad8a 100644 --- a/cms/server/test/wecom-notifier.test.js +++ b/cms/server/test/wecom-notifier.test.js @@ -7,15 +7,17 @@ test('咨询消息包含完整字段并清理换行', () => { name: '张三\n测试', company: '', phone: '13800138000', - need: '希望\r\n了解企业进化方案', + needs: ['IT数字化部署', '其他个性需求'], + otherNeed: '希望\r\n了解企业进化方案', createdAt: '2026-09-30T07:30:00.000Z' }); assert.match(message, /【官网新咨询】/); - assert.match(message, /姓名:张三 测试/); - assert.match(message, /企业:未填写/); + assert.match(message, /您的称呼:张三 测试/); + assert.match(message, /企业名:未填写/); assert.match(message, /联系电话:13800138000/); - assert.match(message, /需求描述:希望 了解企业进化方案/); + assert.match(message, /核心诉求:\n• IT数字化部署\n• 其他个性需求/); + assert.match(message, /说明:希望 了解企业进化方案/); assert.match(message, /提交时间:2026\/9\/30 15:30:00/); }); @@ -41,7 +43,7 @@ test('按企业微信文本消息格式发送', async () => { assert.equal(request.url, 'https://example.test/webhook'); const body = JSON.parse(request.options.body); assert.equal(body.msgtype, 'text'); - assert.match(body.text.content, /姓名:李四/); + assert.match(body.text.content, /您的称呼:李四/); }); test('企业微信返回错误时抛出异常', async () => { diff --git a/src/pages/Contact/contact.css b/src/pages/Contact/contact.css index 899d749..ca23d63 100644 --- a/src/pages/Contact/contact.css +++ b/src/pages/Contact/contact.css @@ -6,6 +6,173 @@ padding: 32px; } +.contact-form-intro { + font-size: 13px; + color: #5a6b84; +} + +.contact-form { + display: grid; + gap: 18px; + margin-top: 20px; +} + +.contact-field { + display: grid; + gap: 7px; + color: #1a2332; + font-size: 14px; + font-weight: 600; +} + +.contact-field input, +.contact-field textarea { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #1a2332; + font: inherit; + font-weight: 400; + line-height: 1.5; + padding: 11px 12px; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.contact-field textarea { + min-height: 108px; + resize: vertical; +} + +.contact-field input::placeholder, +.contact-field textarea::placeholder { + color: #64748b; +} + +.contact-field input:focus, +.contact-field textarea:focus { + border-color: #163e70; + box-shadow: 0 0 0 3px rgba(22, 62, 112, 0.12); + outline: none; +} + +.contact-field input[aria-invalid="true"], +.contact-field textarea[aria-invalid="true"] { + border-color: #be123c; +} + +.field-required { + color: #c81e3a; + font-weight: 800; +} + +.field-optional, +.field-help { + color: #64748b; + font-size: 12px; + font-weight: 400; +} + +.contact-needs { + min-width: 0; + border: 0; +} + +.contact-needs legend { + color: #1a2332; + font-size: 14px; + font-weight: 600; +} + +.contact-needs .field-help { + margin-top: 3px; +} + +.contact-needs-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.contact-need-option { + display: flex; + align-items: center; + min-height: 46px; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #334155; + cursor: pointer; + font-size: 13px; + line-height: 1.4; + transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease; +} + +.contact-need-option:hover { + border-color: #7691b2; +} + +.contact-need-option.is-selected { + border-color: #163e70; + background: #eef4fb; + color: #163e70; + font-weight: 600; +} + +.contact-need-option input { + width: 17px; + height: 17px; + margin: 0 9px 0 0; + accent-color: #163e70; + flex: 0 0 auto; +} + +.field-error { + display: block; + margin-top: 5px; + color: #be123c; + font-size: 12px; + font-weight: 500; +} + +.contact-other-need { + padding-left: 14px; + border-left: 3px solid #163e70; +} + +.contact-submit { + width: 100%; + min-height: 46px; +} + +.contact-submit:disabled { + cursor: wait; + opacity: 0.68; +} + +.form-message { + border-radius: 8px; + padding: 10px 12px; + font-size: 13px; +} + +.form-message-success { + background: #ecfdf5; + color: #0f6b5b; +} + +.form-message-error { + background: #fff1f2; + color: #9f1239; +} + +.contact-privacy { + color: #64748b; + font-size: 12px; +} + @media (max-width: 768px) { .contact-form-container { max-width: none; @@ -14,4 +181,13 @@ .contact-form-card { padding: 24px 20px; } + + .contact-needs-grid { + grid-template-columns: 1fr; + } + + .contact-need-option { + min-height: 48px; + font-size: 14px; + } } diff --git a/src/pages/Contact/index.tsx b/src/pages/Contact/index.tsx index da5befa..5189463 100644 --- a/src/pages/Contact/index.tsx +++ b/src/pages/Contact/index.tsx @@ -3,16 +3,65 @@ import { Link } from 'react-router-dom' import { useCMSTexts } from '@/services/cms' import './contact.css' +const NEED_OPTIONS = [ + 'IT数字化部署', + 'AI智能体部署', + '经营体系建设', + '项目融资需求', + '实缴合规管理', + '获客自动化部署', + '其他个性需求', +] as const + +const OTHER_NEED = '其他个性需求' +const PHONE_PATTERN = /^1[3-9]\d{9}$/ + +type ContactForm = { + name: string + phone: string + company: string + needs: string[] + otherNeed: string +} + +const EMPTY_FORM: ContactForm = { name: '', phone: '', company: '', needs: [], otherNeed: '' } + export default function Contact() { const { data: texts } = useCMSTexts() - const [form, setForm] = useState({ name: '', company: '', phone: '', need: '' }) + const [form, setForm] = useState(EMPTY_FORM) const [submitted, setSubmitted] = useState(false) const [error, setError] = useState('') + const [fieldErrors, setFieldErrors] = useState({ phone: '', needs: '', otherNeed: '' }) const [saving, setSaving] = useState(false) + const toggleNeed = (need: string) => { + setSubmitted(false) + setFieldErrors(current => ({ ...current, needs: '', otherNeed: '' })) + setForm(current => { + const selected = current.needs.includes(need) + return { + ...current, + needs: selected ? current.needs.filter(item => item !== need) : [...current.needs, need], + otherNeed: selected && need === OTHER_NEED ? '' : current.otherNeed, + } + }) + } + + const validate = () => { + const nextErrors = { + phone: PHONE_PATTERN.test(form.phone) ? '' : '请输入合法的11位中国大陆手机号', + needs: form.needs.length > 0 ? '' : '请至少选择一项核心诉求', + otherNeed: form.needs.includes(OTHER_NEED) && !form.otherNeed.trim() ? '请填写其他个性需求' : '', + } + setFieldErrors(nextErrors) + return !Object.values(nextErrors).some(Boolean) + } + const submit = async (e: React.FormEvent) => { e.preventDefault() setError('') + setSubmitted(false) + if (!validate()) return setSaving(true) try { const response = await fetch('/api/contact/leads', { @@ -23,7 +72,8 @@ export default function Contact() { const result = await response.json() if (!response.ok || result.code !== 0) throw new Error(result.msg || '提交失败') setSubmitted(true) - setForm({ name: '', company: '', phone: '', need: '' }) + setForm(EMPTY_FORM) + setFieldErrors({ phone: '', needs: '', otherNeed: '' }) } catch (e) { setError(e instanceof Error ? e.message : '提交失败,请稍后再试') } finally { @@ -47,57 +97,103 @@ export default function Contact() {
-
-

咨询获取方案

-

提交后将进入顾问线索列表,请填写真实联系方式。

-
- - - -