完善咨询表单与企业微信诉求推送
This commit is contained in:
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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: '提交失败,请稍后再试' });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-32
@@ -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 {
|
||||||
@@ -49,53 +99,99 @@ export default function Contact() {
|
|||||||
<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="请输入您的称呼"
|
||||||
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }}
|
autoComplete="name"
|
||||||
|
maxLength={80}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ fontSize: 13 }}>
|
|
||||||
企业名称
|
<label className="contact-field">
|
||||||
|
<span>联系电话 <span className="field-required" aria-hidden="true">*</span></span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={e => {
|
||||||
|
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||||
|
setForm({ ...form, phone })
|
||||||
|
setFieldErrors(current => ({ ...current, phone: '' }))
|
||||||
|
}}
|
||||||
|
onBlur={() => setFieldErrors(current => ({
|
||||||
|
...current,
|
||||||
|
phone: PHONE_PATTERN.test(form.phone) ? '' : '请输入合法的11位中国大陆手机号',
|
||||||
|
}))}
|
||||||
|
placeholder="请输入11位手机号"
|
||||||
|
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
|
<input
|
||||||
value={form.company}
|
value={form.company}
|
||||||
onChange={e => setForm({ ...form, company: e.target.value })}
|
onChange={e => setForm({ ...form, company: e.target.value })}
|
||||||
placeholder={texts['contact.company_placeholder'] || '选填'}
|
placeholder={texts['contact.company_placeholder'] || '请输入企业名称'}
|
||||||
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }}
|
autoComplete="organization"
|
||||||
|
maxLength={160}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ fontSize: 13 }}>
|
|
||||||
联系电话
|
<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
|
<input
|
||||||
required
|
type="checkbox"
|
||||||
value={form.phone}
|
checked={form.needs.includes(option)}
|
||||||
onChange={e => setForm({ ...form, phone: e.target.value })}
|
onChange={() => toggleNeed(option)}
|
||||||
placeholder="请输入手机号"
|
|
||||||
style={{ width: '100%', marginTop: 6, padding: '10px 12px', border: '1px solid #e2e8f0', borderRadius: 8 }}
|
|
||||||
/>
|
/>
|
||||||
|
<span>{option}</span>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ fontSize: 13 }}>
|
))}
|
||||||
需求简述
|
</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 type="submit" className="btn btn-primary contact-submit" disabled={saving}>
|
||||||
|
{saving ? '提交中...' : '提交咨询'}
|
||||||
</button>
|
</button>
|
||||||
{submitted && <div style={{ fontSize: 13, color: '#0f6b5b', background: '#ecfdf5', padding: 10, borderRadius: 8 }}>已提交,顾问将尽快联系。</div>}
|
{submitted && <div className="form-message form-message-success" role="status">已提交,顾问将尽快联系。</div>}
|
||||||
{error && <div style={{ fontSize: 13, color: '#be123c', background: '#fff1f2', padding: 10, borderRadius: 8 }}>{error}</div>}
|
{error && <div className="form-message form-message-error" role="alert">{error}</div>}
|
||||||
<p style={{ fontSize: 11, color: '#94a3b8' }}>{texts['contact.privacy_note'] || '提交即视为同意隐私与合规说明。'}</p>
|
<p className="contact-privacy">{texts['contact.privacy_note'] || '提交即视为同意隐私与合规说明。'}</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user