完善咨询表单与企业微信诉求推送

This commit is contained in:
Rei
2026-09-30 18:23:01 +08:00
parent f35ee24510
commit 530f726130
8 changed files with 448 additions and 70 deletions
+2 -2
View File
@@ -124,8 +124,8 @@ function ContactLeads({token}:{token:string}){
const [list,setList]=useState<any[]>([]) const [list,setList]=useState<any[]>([])
useEffect(()=>{api('/api/cms/contact-leads',token).then(j=> setList(j.data||[]))},[]) useEffect(()=>{api('/api/cms/contact-leads',token).then(j=> setList(j.data||[]))},[])
return <div className="card"><h3>咨询线索(最近200条)</h3> return <div className="card"><h3>咨询线索(最近200条)</h3>
<table className="table"><thead><tr><th>姓名</th><th>企业</th><th>手机</th><th>需求</th><th>时间</th></tr></thead> <table className="table"><thead><tr><th>您的称呼</th><th>企业名</th><th>联系电话</th><th>核心诉求</th><th>时间</th></tr></thead>
<tbody>{list.map(l=><tr key={l.id}><td>{l.name}</td><td>{l.company}</td><td>{l.phone}</td><td>{l.need}</td><td>{l.created_at}</td></tr>)}</tbody> <tbody>{list.map(l=><tr key={l.id}><td>{l.name||'未填写'}</td><td>{l.company||'未填写'}</td><td>{l.phone}</td><td>{l.need}</td><td>{l.created_at}</td></tr>)}</tbody>
</table> </table>
</div> </div>
} }
+45
View File
@@ -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 };
}
+11 -10
View File
@@ -12,6 +12,7 @@ import { initDB, getDB, UPLOAD_DIR } from './db.js';
import { signToken, verifyToken, authMiddleware } from './middleware/auth.js'; import { signToken, verifyToken, authMiddleware } from './middleware/auth.js';
import { createContentIngestRouter } from './content-ingest.js'; import { createContentIngestRouter } from './content-ingest.js';
import { sendContactLeadToWeCom } from './wecom-notifier.js'; import { sendContactLeadToWeCom } from './wecom-notifier.js';
import { ContactLeadValidationError, normalizeContactLeadInput } from './contact-leads.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@@ -131,18 +132,18 @@ app.use('/api/internal/content/v1', createContentIngestRouter());
app.use('/api/contact/leads', contactLeadRateLimit); app.use('/api/contact/leads', contactLeadRateLimit);
app.post('/api/contact/leads', (req, res) => { app.post('/api/contact/leads', (req, res) => {
const { name, company = '', phone, need = '' } = req.body || {}; let lead;
if (!name || !phone) return res.status(400).json({ code: 400, msg: '姓名和手机号必填' }); try {
if (!/^1[3-9]\d{9}$/.test(String(phone))) return res.status(400).json({ code: 400, msg: '手机号格式错误' }); 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 id = genId('contact');
const createdAt = nowISO(); const createdAt = nowISO();
const lead = { lead.createdAt = createdAt;
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
};
db.run(`INSERT INTO contact_leads (id, name, company, phone, need, created_at) VALUES (?,?,?,?,?,?)`, 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 => { [id, lead.name, lead.company, lead.phone, lead.need, createdAt], err => {
if (err) return res.status(500).json({ code: 500, msg: '提交失败,请稍后再试' }); if (err) return res.status(500).json({ code: 500, msg: '提交失败,请稍后再试' });
+9 -3
View File
@@ -14,13 +14,19 @@ export function formatContactLeadMessage(lead, { siteUrl = 'https://www.ameiba.c
hour12: false 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 [ return [
'【官网新咨询】', '【官网新咨询】',
'', '',
`姓名:${cleanLine(lead.name, 80) || '未填写'}`, `您的称呼:${cleanLine(lead.name, 80) || '未填写'}`,
`企业:${cleanLine(lead.company, 160) || '未填写'}`,
`联系电话:${cleanLine(lead.phone, 30) || '未填写'}`, `联系电话:${cleanLine(lead.phone, 30) || '未填写'}`,
`需求描述:${cleanLine(lead.need, 2000) || '未填写'}`, `企业名:${cleanLine(lead.company, 160) || '未填写'}`,
'核心诉求:',
...needs,
`提交时间:${submittedAt}`, `提交时间:${submittedAt}`,
`来源:${cleanLine(siteUrl, 200)}` `来源:${cleanLine(siteUrl, 200)}`
].join('\n'); ].join('\n');
+52
View File
@@ -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, '其他个性需求:希望先进行现场访谈');
});
+7 -5
View File
@@ -7,15 +7,17 @@ test('咨询消息包含完整字段并清理换行', () => {
name: '张三\n测试', name: '张三\n测试',
company: '', company: '',
phone: '13800138000', phone: '13800138000',
need: '希望\r\n了解企业进化方案', needs: ['IT数字化部署', '其他个性需求'],
otherNeed: '希望\r\n了解企业进化方案',
createdAt: '2026-09-30T07:30:00.000Z' 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, /企业名:未填写/);
assert.match(message, /联系电话:13800138000/); 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/); assert.match(message, /提交时间:2026\/9\/30 15:30:00/);
}); });
@@ -41,7 +43,7 @@ test('按企业微信文本消息格式发送', async () => {
assert.equal(request.url, 'https://example.test/webhook'); assert.equal(request.url, 'https://example.test/webhook');
const body = JSON.parse(request.options.body); const body = JSON.parse(request.options.body);
assert.equal(body.msgtype, 'text'); assert.equal(body.msgtype, 'text');
assert.match(body.text.content, /姓名:李四/); assert.match(body.text.content, /您的称呼:李四/);
}); });
test('企业微信返回错误时抛出异常', async () => { test('企业微信返回错误时抛出异常', async () => {
+176
View File
@@ -6,6 +6,173 @@
padding: 32px; 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) { @media (max-width: 768px) {
.contact-form-container { .contact-form-container {
max-width: none; max-width: none;
@@ -14,4 +181,13 @@
.contact-form-card { .contact-form-card {
padding: 24px 20px; padding: 24px 20px;
} }
.contact-needs-grid {
grid-template-columns: 1fr;
}
.contact-need-option {
min-height: 48px;
font-size: 14px;
}
} }
+146 -50
View File
@@ -3,16 +3,65 @@ import { Link } from 'react-router-dom'
import { useCMSTexts } from '@/services/cms' import { useCMSTexts } from '@/services/cms'
import './contact.css' 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() { export default function Contact() {
const { data: texts } = useCMSTexts() const { data: texts } = useCMSTexts()
const [form, setForm] = useState({ name: '', company: '', phone: '', need: '' }) const [form, setForm] = useState<ContactForm>(EMPTY_FORM)
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [fieldErrors, setFieldErrors] = useState({ phone: '', needs: '', otherNeed: '' })
const [saving, setSaving] = useState(false) 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) => { const submit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setSubmitted(false)
if (!validate()) return
setSaving(true) setSaving(true)
try { try {
const response = await fetch('/api/contact/leads', { const response = await fetch('/api/contact/leads', {
@@ -23,7 +72,8 @@ export default function Contact() {
const result = await response.json() const result = await response.json()
if (!response.ok || result.code !== 0) throw new Error(result.msg || '提交失败') if (!response.ok || result.code !== 0) throw new Error(result.msg || '提交失败')
setSubmitted(true) setSubmitted(true)
setForm({ name: '', company: '', phone: '', need: '' }) setForm(EMPTY_FORM)
setFieldErrors({ phone: '', needs: '', otherNeed: '' })
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '提交失败,请稍后再试') setError(e instanceof Error ? e.message : '提交失败,请稍后再试')
} finally { } finally {
@@ -47,57 +97,103 @@ export default function Contact() {
</div> </div>
<section className="section"> <section className="section">
<div className="container contact-form-container"> <div className="container contact-form-container">
<div className="card contact-form-card"> <div className="card contact-form-card">
<h3>咨询获取方案</h3> <h3>咨询获取方案</h3>
<p style={{ fontSize: 13, color: '#64748b' }}>提交后将进入顾问线索列表,请填写真实联系方式。</p> <p className="contact-form-intro">请留下有效联系方式,并选择希望优先解决的核心诉求。</p>
<form onSubmit={submit} style={{ marginTop: 16, display: 'grid', gap: 12 }}> <form className="contact-form" onSubmit={submit} noValidate>
<label style={{ fontSize: 13 }}> <label className="contact-field">
姓名 <span>您的称呼 <span className="field-optional">(选填)</span></span>
<input <input
required value={form.name}
value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
onChange={e => setForm({ ...form, name: e.target.value })} placeholder="请输入您的称呼"
placeholder="请输入姓名" autoComplete="name"
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }} maxLength={80}
/> />
</label> </label>
<label style={{ fontSize: 13 }}>
企业名称 <label className="contact-field">
<input <span>联系电话 <span className="field-required" aria-hidden="true">*</span></span>
value={form.company} <input
onChange={e => setForm({ ...form, company: e.target.value })} required
placeholder={texts['contact.company_placeholder'] || '选填'} type="tel"
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }} inputMode="numeric"
/> autoComplete="tel"
</label> value={form.phone}
<label style={{ fontSize: 13 }}> onChange={e => {
联系电话 const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
<input setForm({ ...form, phone })
required setFieldErrors(current => ({ ...current, phone: '' }))
value={form.phone} }}
onChange={e => setForm({ ...form, phone: e.target.value })} onBlur={() => setFieldErrors(current => ({
placeholder="请输入手机号" ...current,
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }} phone: PHONE_PATTERN.test(form.phone) ? '' : '请输入合法的11位中国大陆手机号',
/> }))}
</label> placeholder="请输入11位手机号"
<label style={{ fontSize: 13 }}> maxLength={11}
需求简述 aria-invalid={Boolean(fieldErrors.phone)}
aria-describedby={fieldErrors.phone ? 'phone-error' : undefined}
/>
{fieldErrors.phone && <span id="phone-error" className="field-error">{fieldErrors.phone}</span>}
</label>
<label className="contact-field">
<span>企业名 <span className="field-optional">(可选)</span></span>
<input
value={form.company}
onChange={e => setForm({ ...form, company: e.target.value })}
placeholder={texts['contact.company_placeholder'] || '请输入企业名称'}
autoComplete="organization"
maxLength={160}
/>
</label>
<fieldset className="contact-needs" aria-describedby={fieldErrors.needs ? 'needs-error' : 'needs-help'}>
<legend>核心诉求 <span className="field-required" aria-hidden="true">*</span></legend>
<p id="needs-help" className="field-help">可多选,请至少选择一项</p>
<div className="contact-needs-grid">
{NEED_OPTIONS.map(option => (
<label className={`contact-need-option${form.needs.includes(option) ? ' is-selected' : ''}`} key={option}>
<input
type="checkbox"
checked={form.needs.includes(option)}
onChange={() => toggleNeed(option)}
/>
<span>{option}</span>
</label>
))}
</div>
{fieldErrors.needs && <span id="needs-error" className="field-error">{fieldErrors.needs}</span>}
</fieldset>
{form.needs.includes(OTHER_NEED) && (
<label className="contact-field contact-other-need">
<span>其他个性需求 <span className="field-required" aria-hidden="true">*</span></span>
<textarea <textarea
value={form.need} required
onChange={e => setForm({ ...form, need: e.target.value })} value={form.otherNeed}
placeholder={texts['contact.need_placeholder'] || '请简述你的企业现状与期望'} onChange={e => {
rows={3} setForm({ ...form, otherNeed: e.target.value })
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }} setFieldErrors(current => ({ ...current, otherNeed: '' }))
}}
placeholder="请具体描述您的个性需求"
rows={4}
maxLength={1000}
aria-invalid={Boolean(fieldErrors.otherNeed)}
aria-describedby={fieldErrors.otherNeed ? 'other-need-error' : undefined}
/> />
{fieldErrors.otherNeed && <span id="other-need-error" className="field-error">{fieldErrors.otherNeed}</span>}
</label> </label>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={saving}> )}
{saving ? '提交中…' : '提交咨询'}
</button> <button type="submit" className="btn btn-primary contact-submit" disabled={saving}>
{submitted && <div style={{ fontSize: 13, color: '#0f6b5b', background: '#ecfdf5', padding: 10, borderRadius: 8 }}>已提交,顾问将尽快联系。</div>} {saving ? '提交中...' : '提交咨询'}
{error && <div style={{ fontSize: 13, color: '#be123c', background: '#fff1f2', padding: 10, borderRadius: 8 }}>{error}</div>} </button>
<p style={{ fontSize: 11, color: '#94a3b8' }}>{texts['contact.privacy_note'] || '提交即视为同意隐私与合规说明。'}</p> {submitted && <div className="form-message form-message-success" role="status">已提交,顾问将尽快联系。</div>}
</form> {error && <div className="form-message form-message-error" role="alert">{error}</div>}
</div> <p className="contact-privacy">{texts['contact.privacy_note'] || '提交即视为同意隐私与合规说明。'}</p>
</form>
</div>
</div> </div>
</section> </section>
<section className="cta-band"> <section className="cta-band">