feat: 实现联系表单提交与邮件通知功能
添加联系表单提交接口和邮件通知功能,支持从环境变量读取 SMTP 配置 重构 SEO 配置到 site.json,新增 robots.txt 和 sitemap.xml 生成 更新公司电话并添加 PM2 生产运行配置
This commit is contained in:
27
lib/contact-form.ts
Normal file
27
lib/contact-form.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod"
|
||||
|
||||
function requiredTextField(label: string, maxLength: number) {
|
||||
return z.preprocess(
|
||||
(value) => typeof value === "string" ? value.trim() : "",
|
||||
z.string()
|
||||
.min(1, `请填写${label}`)
|
||||
.max(maxLength, `${label}不能超过 ${maxLength} 个字符`)
|
||||
)
|
||||
}
|
||||
|
||||
function optionalTextField(label: string, maxLength: number) {
|
||||
return z.preprocess(
|
||||
(value) => typeof value === "string" ? value.trim() : "",
|
||||
z.string().max(maxLength, `${label}不能超过 ${maxLength} 个字符`)
|
||||
)
|
||||
}
|
||||
|
||||
export const contactFormSchema = z.object({
|
||||
name: requiredTextField("姓名", 80),
|
||||
company: requiredTextField("公司 / 品牌", 120),
|
||||
phone: requiredTextField("联系电话", 40),
|
||||
scale: optionalTextField("门店规模", 120),
|
||||
message: optionalTextField("需求描述", 2000),
|
||||
})
|
||||
|
||||
export type ContactFormData = z.infer<typeof contactFormSchema>
|
||||
99
lib/contact-mailer-config.ts
Normal file
99
lib/contact-mailer-config.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { parse } from "yaml"
|
||||
import { z } from "zod"
|
||||
|
||||
export const CONTACT_MAILER_CONFIG_PATH = path.join(
|
||||
process.cwd(),
|
||||
"config",
|
||||
"contact-mailer.yaml"
|
||||
)
|
||||
|
||||
export class ContactMailerConfigError extends Error {}
|
||||
|
||||
function coerceBoolean(value: unknown) {
|
||||
if (typeof value === "boolean") {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
if (normalized === "true") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (normalized === "false") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveEnvPlaceholders(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\$\{([A-Z0-9_]+)(?::([^}]*))?\}/gi, (_, envName: string, fallback?: string) => {
|
||||
return process.env[envName] ?? fallback ?? ""
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(resolveEnvPlaceholders)
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [key, resolveEnvPlaceholders(nestedValue)])
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const contactMailerConfigSchema = z.object({
|
||||
smtp: z.object({
|
||||
host: z.string().trim().min(1, "SMTP host 未配置"),
|
||||
port: z.coerce.number().int().positive("SMTP port 未配置"),
|
||||
secure: z.preprocess(coerceBoolean, z.boolean()),
|
||||
auth: z.object({
|
||||
user: z.string().trim().min(1, "SMTP 用户名未配置"),
|
||||
pass: z.string().min(1, "SMTP 密码未配置"),
|
||||
}),
|
||||
from: z.object({
|
||||
email: z.string().email("发件邮箱格式不正确"),
|
||||
name: z.string().trim().min(1).default("智店软件官网"),
|
||||
}),
|
||||
}),
|
||||
notification: z.object({
|
||||
to: z.array(z.string().email("通知邮箱格式不正确")).min(1).default(["bd@zhidiansoft.com"]),
|
||||
subjectPrefix: z.string().trim().min(1).default("官网线索"),
|
||||
}).default({
|
||||
to: ["bd@zhidiansoft.com"],
|
||||
subjectPrefix: "官网线索",
|
||||
}),
|
||||
})
|
||||
|
||||
export type ContactMailerConfig = z.infer<typeof contactMailerConfigSchema>
|
||||
|
||||
export async function loadContactMailerConfig() {
|
||||
try {
|
||||
const fileContent = await readFile(CONTACT_MAILER_CONFIG_PATH, "utf8")
|
||||
const parsed = parse(fileContent) ?? {}
|
||||
const resolved = resolveEnvPlaceholders(parsed)
|
||||
|
||||
return contactMailerConfigSchema.parse(resolved)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ContactMailerConfigError(
|
||||
`contact-mailer.yaml 配置不完整:${error.issues.map((issue) => issue.message).join(";")}`
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new ContactMailerConfigError(`读取邮件配置失败:${error.message}`)
|
||||
}
|
||||
|
||||
throw new ContactMailerConfigError("读取邮件配置失败")
|
||||
}
|
||||
}
|
||||
116
lib/contact-mailer.ts
Normal file
116
lib/contact-mailer.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import type { ContactFormData } from "@/lib/contact-form"
|
||||
import type { ContactMailerConfig } from "@/lib/contact-mailer-config"
|
||||
|
||||
type ContactNotificationMeta = {
|
||||
submittedAt: string
|
||||
sourceUrl: string
|
||||
ipAddress: string
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
function formatOptionalField(value: string) {
|
||||
return value || "未填写"
|
||||
}
|
||||
|
||||
export async function sendContactNotification({
|
||||
config,
|
||||
payload,
|
||||
meta,
|
||||
}: {
|
||||
config: ContactMailerConfig
|
||||
payload: ContactFormData
|
||||
meta: ContactNotificationMeta
|
||||
}) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
auth: {
|
||||
user: config.smtp.auth.user,
|
||||
pass: config.smtp.auth.pass,
|
||||
},
|
||||
})
|
||||
|
||||
const subject = `${config.notification.subjectPrefix} - ${payload.company} / ${payload.name}`
|
||||
const text = [
|
||||
"官网获取初步方案表单有新提交。",
|
||||
"",
|
||||
`姓名:${payload.name}`,
|
||||
`公司 / 品牌:${payload.company}`,
|
||||
`电话:${payload.phone}`,
|
||||
`门店规模:${formatOptionalField(payload.scale)}`,
|
||||
`需求描述:${formatOptionalField(payload.message)}`,
|
||||
"",
|
||||
`提交时间:${meta.submittedAt}`,
|
||||
`来源页面:${meta.sourceUrl}`,
|
||||
`来源 IP:${meta.ipAddress}`,
|
||||
`User-Agent:${meta.userAgent}`,
|
||||
].join("\n")
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.7;">
|
||||
<h2 style="margin: 0 0 16px;">官网获取初步方案表单有新提交</h2>
|
||||
<table style="border-collapse: collapse; width: 100%; max-width: 720px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; width: 160px; background: #f9fafb;">姓名</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(payload.name)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">公司 / 品牌</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(payload.company)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">电话</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(payload.phone)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">门店规模</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(formatOptionalField(payload.scale))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">需求描述</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; white-space: pre-wrap;">${escapeHtml(formatOptionalField(payload.message))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">提交时间</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.submittedAt)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">来源页面</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.sourceUrl)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">来源 IP</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.ipAddress)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">User-Agent</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.userAgent)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: {
|
||||
name: config.smtp.from.name,
|
||||
address: config.smtp.from.email,
|
||||
},
|
||||
to: config.notification.to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
}
|
||||
145
lib/seo.ts
Normal file
145
lib/seo.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Metadata, MetadataRoute } from "next"
|
||||
import siteData from "@/data/site.json"
|
||||
|
||||
type SiteSeoConfig = {
|
||||
siteUrl: string
|
||||
siteName: string
|
||||
defaultTitle: string
|
||||
titleTemplate: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
canonicalPath: string
|
||||
locale?: string
|
||||
robots?: {
|
||||
index?: boolean
|
||||
follow?: boolean
|
||||
}
|
||||
openGraph?: {
|
||||
type?: "website" | "article"
|
||||
image?: string
|
||||
}
|
||||
twitter?: {
|
||||
card?: "summary" | "summary_large_image" | "app" | "player"
|
||||
site?: string
|
||||
creator?: string
|
||||
}
|
||||
verification?: {
|
||||
google?: string
|
||||
yandex?: string
|
||||
other?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
const seo = siteData.seo as SiteSeoConfig
|
||||
|
||||
function nonEmpty(value?: string) {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
function getMetadataBase() {
|
||||
return new URL(seo.siteUrl)
|
||||
}
|
||||
|
||||
function getCanonicalPath() {
|
||||
return nonEmpty(seo.canonicalPath) ?? "/"
|
||||
}
|
||||
|
||||
function getVerificationOther() {
|
||||
const otherEntries = Object.entries(seo.verification?.other ?? {}).filter(([, value]) => Boolean(nonEmpty(value)))
|
||||
|
||||
return otherEntries.length > 0 ? Object.fromEntries(otherEntries) : undefined
|
||||
}
|
||||
|
||||
function getOpenGraphImages() {
|
||||
const image = nonEmpty(seo.openGraph?.image)
|
||||
|
||||
return image ? [{ url: image }] : undefined
|
||||
}
|
||||
|
||||
export function buildSiteMetadata(): Metadata {
|
||||
const index = seo.robots?.index ?? true
|
||||
const follow = seo.robots?.follow ?? true
|
||||
const verificationOther = getVerificationOther()
|
||||
const canonicalPath = getCanonicalPath()
|
||||
const openGraphImages = getOpenGraphImages()
|
||||
|
||||
return {
|
||||
metadataBase: getMetadataBase(),
|
||||
applicationName: seo.siteName,
|
||||
title: {
|
||||
default: seo.defaultTitle,
|
||||
template: seo.titleTemplate,
|
||||
},
|
||||
description: seo.description,
|
||||
keywords: seo.keywords,
|
||||
authors: [{ name: siteData.company.name }],
|
||||
creator: siteData.company.name,
|
||||
publisher: siteData.company.name,
|
||||
alternates: {
|
||||
canonical: canonicalPath,
|
||||
},
|
||||
robots: {
|
||||
index,
|
||||
follow,
|
||||
googleBot: {
|
||||
index,
|
||||
follow,
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
google: nonEmpty(seo.verification?.google),
|
||||
yandex: nonEmpty(seo.verification?.yandex),
|
||||
other: verificationOther,
|
||||
},
|
||||
openGraph: {
|
||||
title: seo.defaultTitle,
|
||||
description: seo.description,
|
||||
url: canonicalPath,
|
||||
siteName: seo.siteName,
|
||||
locale: nonEmpty(seo.locale),
|
||||
type: seo.openGraph?.type ?? "website",
|
||||
images: openGraphImages,
|
||||
},
|
||||
twitter: {
|
||||
card: seo.twitter?.card ?? "summary_large_image",
|
||||
title: seo.defaultTitle,
|
||||
description: seo.description,
|
||||
images: openGraphImages?.map((image) => image.url),
|
||||
site: nonEmpty(seo.twitter?.site),
|
||||
creator: nonEmpty(seo.twitter?.creator),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRobots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
],
|
||||
sitemap: `${seo.siteUrl.replace(/\/$/, "")}/sitemap.xml`,
|
||||
host: seo.siteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSitemap(): MetadataRoute.Sitemap {
|
||||
const canonicalPath = getCanonicalPath()
|
||||
const normalizedSiteUrl = seo.siteUrl.replace(/\/$/, "")
|
||||
|
||||
return [
|
||||
{
|
||||
url: `${normalizedSiteUrl}${canonicalPath}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user