feat: 实现联系表单提交与邮件通知功能

添加联系表单提交接口和邮件通知功能,支持从环境变量读取 SMTP 配置
重构 SEO 配置到 site.json,新增 robots.txt 和 sitemap.xml 生成
更新公司电话并添加 PM2 生产运行配置
This commit is contained in:
JanYork
2026-03-18 13:20:40 +08:00
parent 59e80bf938
commit e2850586a9
20 changed files with 872 additions and 28 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
MAIL_HOST=smtp.feishu.cn
MAIL_PORT=465
MAIL_USER=your-mail-account@example.com
MAIL_PASS=your-mail-password
MAIL_SSL_ENABLED=true
MAIL_FROM_EMAIL=your-mail-account@example.com
MAIL_FROM_NAME=智店软件官网

7
.env.local Normal file
View File

@@ -0,0 +1,7 @@
MAIL_HOST=smtp.feishu.cn
MAIL_PORT=465
MAIL_USER=hi@jiuchan.org
MAIL_PASS=tRU0aI0Bc4fPcDlz
MAIL_SSL_ENABLED=true
MAIL_FROM_EMAIL=hi@jiuchan.org
MAIL_FROM_NAME=智店软件官网

2
.gitignore vendored
View File

@@ -9,7 +9,7 @@ __v0_jsx-dev-runtime.ts
next.user-config.*
# Environment variables
.env*.local
# .env*.local
# Common ignores
node_modules/

204
README.md Normal file
View File

@@ -0,0 +1,204 @@
# 智店官网
基于 Next.js 16 全栈框架的企业官网项目当前实现了单页官网展示、联系表单提交、SMTP 邮件通知、可配置 SEO、`robots.txt` / `sitemap.xml` 输出,以及 PM2 生产运行配置。
## 功能概览
- 单页官网:首页内容主要由 `data/*.json` 驱动。
- 联系表单:提交“获取初步方案”后,会调用 `/api/contact`,由服务端通过 SMTP 发邮件到配置的多个管理邮箱。
- SEO 配置站点标题、描述、关键词、canonical、Open Graph、Twitter、站点验证、robots、sitemap 都可配置。
- 生产部署:支持 `pnpm build && pnpm start`,也提供了 PM2 的 `ecosystem.config.cjs`
## 快速启动
### 1. 安装依赖
```bash
pnpm install
```
### 2. 配置环境变量
本地开发建议使用 `.env.local`。当前项目已经按飞书企业邮箱写入了测试配置。
可参考 `./.env.example`
```bash
MAIL_HOST=smtp.feishu.cn
MAIL_PORT=465
MAIL_USER=your-mail-account@example.com
MAIL_PASS=your-mail-password
MAIL_SSL_ENABLED=true
MAIL_FROM_EMAIL=your-mail-account@example.com
MAIL_FROM_NAME=智店软件官网
```
### 3. 启动开发环境
```bash
pnpm dev
```
默认访问:
```text
http://localhost:3000
```
## 构建与运行
### 本地生产模式
```bash
pnpm build
pnpm start
```
### 使用 PM2 运行
项目已提供 `./ecosystem.config.cjs`
```bash
pnpm build
pm2 start ecosystem.config.cjs
pm2 status
pm2 logs com-index
```
常用 PM2 命令:
```bash
pm2 restart com-index
pm2 stop com-index
pm2 delete com-index
pm2 save
pm2 startup
```
## 配置说明
### 1. 站点基础配置
文件:`data/site.json`
主要负责:
- 公司名称、英文名、描述、电话、邮箱、地址
- 顶部导航和页脚导航
- SEO 全局配置
重点字段:
- `company.phone`:站点展示电话
- `company.email`:页脚展示邮箱
- `seo.siteUrl`:线上正式域名,用于 canonical、Open Graph、sitemap、robots
- `seo.defaultTitle`:默认页面标题
- `seo.titleTemplate`:子页面标题模板
- `seo.description`:默认描述
- `seo.keywords`:关键词数组
- `seo.canonicalPath`:当前站点 canonical 路径,单页官网一般为 `/`
- `seo.robots.index` / `seo.robots.follow`:搜索引擎抓取控制
- `seo.openGraph.image`:社交分享图路径
- `seo.verification`:搜索引擎站点验证
### 2. 首页文案与区块配置
文件:`data/home.json`
主要负责:
- Hero 文案
- 产品、方案、案例、联系表单文案
- “获取初步方案”按钮和联系信息展示
### 3. 邮件通知配置
文件:`config/contact-mailer.yaml`
这个文件负责:
- SMTP 主机、端口、SSL、账号密码引用
- 发件人邮箱和名称
- 收件人列表
- 邮件标题前缀
当前配置方式:
- SMTP 参数优先从 `.env.local` 里的 `MAIL_*` 变量读取
- `notification.to` 支持配置多个邮箱
- YAML 里可以直接写死,也可以用 `${ENV_NAME}` / `${ENV_NAME:default}` 引用环境变量
示例:
```yaml
notification:
to:
- bd@zhidiansoft.com
- ops@zhidiansoft.com
subjectPrefix: 官网线索
```
### 4. SEO 输出文件
文件:
- `app/layout.tsx`
- `lib/seo.ts`
- `app/robots.ts`
- `app/sitemap.ts`
职责:
- `app/layout.tsx`:把 SEO 元数据挂到 Next Metadata
- `lib/seo.ts`:统一把 `site.json` 里的 SEO 配置转换成 Metadata / robots / sitemap
- `app/robots.ts`:生成 `/robots.txt`
- `app/sitemap.ts`:生成 `/sitemap.xml`
## 目录树与作用
```text
.
├── app/
│ ├── api/contact/route.ts # 联系表单提交接口,负责校验和发邮件
│ ├── layout.tsx # 全局布局与 Metadata 入口
│ ├── page.tsx # 首页路由
│ ├── robots.ts # 生成 robots.txt
│ └── sitemap.ts # 生成 sitemap.xml
├── components/
│ ├── sections/ # 首页各业务区块
│ ├── footer.tsx # 页脚
│ └── navbar.tsx # 顶部导航
├── config/
│ └── contact-mailer.yaml # 邮件通知 YAML 配置
├── data/
│ ├── home.json # 首页文案和模块数据
│ └── site.json # 公司信息、导航、SEO 配置
├── lib/
│ ├── contact-form.ts # 表单字段校验
│ ├── contact-mailer.ts # SMTP 发信逻辑
│ ├── contact-mailer-config.ts # YAML 配置读取与校验
│ ├── seo.ts # SEO 配置转换
│ └── utils.ts # 通用工具
├── public/ # 静态资源
├── .env.example # 环境变量示例
├── ecosystem.config.cjs # PM2 配置
├── next.config.mjs # Next.js 配置
├── package.json # 依赖与脚本
└── tsconfig.json # TypeScript 配置
```
## 表单提交流程
1. 用户在“获取初步方案”表单输入姓名、公司、电话、门店规模和需求描述。
2. 前端组件 `components/sections/contact-section.tsx``/api/contact` 发起 POST 请求。
3. API 路由 `app/api/contact/route.ts` 使用 Zod 校验请求体。
4. 服务端读取 `config/contact-mailer.yaml` 并解析环境变量。
5. `nodemailer` 通过 SMTP 给 `notification.to` 中的所有邮箱发送通知邮件。
6. 邮件发送成功后,前端才显示“信息已提交”。
## 注意事项
- `.env.local` 已在 `.gitignore` 中忽略,不会默认提交到仓库。
- 线上部署前,请确认 `data/site.json` 中的 `seo.siteUrl` 已改成真实域名。
- 如果邮件发送失败,优先检查 `.env.local``contact-mailer.yaml` 和 SMTP 账号权限。
- 当前仓库存在一些与本次需求无关的历史 TypeScript 报错,`next build` 不受影响,但 `pnpm exec tsc --noEmit` 还不能全绿。

79
app/api/contact/route.ts Normal file
View File

@@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import { ZodError } from "zod"
import { contactFormSchema } from "@/lib/contact-form"
import { sendContactNotification } from "@/lib/contact-mailer"
import { ContactMailerConfigError, loadContactMailerConfig } from "@/lib/contact-mailer-config"
export const runtime = "nodejs"
function getClientIp(request: Request) {
const forwardedFor = request.headers.get("x-forwarded-for")
if (forwardedFor) {
return forwardedFor.split(",")[0]?.trim() || "unknown"
}
return request.headers.get("x-real-ip") ?? "unknown"
}
function formatSubmittedAt() {
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
dateStyle: "full",
timeStyle: "long",
}).format(new Date())
}
export async function POST(request: Request) {
let requestBody: unknown
try {
requestBody = await request.json()
} catch {
return NextResponse.json(
{ error: "请求格式不正确,请刷新后重试。" },
{ status: 400 }
)
}
try {
const payload = contactFormSchema.parse(requestBody)
const config = await loadContactMailerConfig()
await sendContactNotification({
config,
payload,
meta: {
submittedAt: formatSubmittedAt(),
sourceUrl: request.headers.get("referer") ?? request.headers.get("origin") ?? new URL(request.url).origin,
ipAddress: getClientIp(request),
userAgent: request.headers.get("user-agent") ?? "unknown",
},
})
return NextResponse.json({ ok: true })
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "提交信息不完整,请检查后重试。" },
{ status: 400 }
)
}
if (error instanceof ContactMailerConfigError) {
console.error(error.message)
return NextResponse.json(
{ error: "邮件通知配置不完整,请联系管理员检查 SMTP 设置。" },
{ status: 500 }
)
}
console.error("Failed to send contact notification", error)
return NextResponse.json(
{ error: "提交失败,邮件通知未发送成功,请稍后重试。" },
{ status: 500 }
)
}
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import { buildSiteMetadata } from '@/lib/seo'
import './globals.css'
const inter = Inter({
@@ -9,17 +10,7 @@ const inter = Inter({
display: 'swap',
})
export const metadata: Metadata = {
title: '连锁门店收银、分账、监管系统 | 智店软件',
description: '面向连锁餐饮、零售与加盟品牌,提供门店收银、自动分账、总部监管一体化系统定制与升级服务。兼容现有设备与流程,支持不停业切换。',
keywords: '连锁门店系统, 收银系统, 自动分账系统, 总部监管系统, 加盟门店结算, 门店数字化, 智店软件',
authors: [{ name: '智店软件' }],
openGraph: {
title: '连锁门店收银、分账、监管系统 | 智店软件',
description: '为连锁餐饮、零售与加盟品牌提供门店收银、自动分账、总部监管一体化系统定制与升级服务。',
type: 'website',
},
}
export const metadata: Metadata = buildSiteMetadata()
export default function RootLayout({
children,

6
app/robots.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { MetadataRoute } from "next"
import { buildRobots } from "@/lib/seo"
export default function robots(): MetadataRoute.Robots {
return buildRobots()
}

6
app/sitemap.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { MetadataRoute } from "next"
import { buildSitemap } from "@/lib/seo"
export default function sitemap(): MetadataRoute.Sitemap {
return buildSitemap()
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, type FormEvent, type ReactNode } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -9,7 +9,7 @@ import { SectionReveal } from "@/components/ui/section-reveal"
import { Phone, Mail, MapPin, Clock3, ArrowRight, CheckCircle2 } from "lucide-react"
import homeData from "@/data/home.json"
const iconMap: Record<string, React.ReactNode> = {
const iconMap: Record<string, ReactNode> = {
phone: <Phone className="w-5 h-5" />,
mail: <Mail className="w-5 h-5" />,
mappin: <MapPin className="w-5 h-5" />,
@@ -27,10 +27,46 @@ export function ContactSection() {
}))) as Array<{ icon: string; label: string; value: string; sub?: string }>
const submitLabel = (contact.form as any).submitLabel ?? (contact.form as any).submit
const [submitted, setSubmitted] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
setErrorMessage(null)
setIsSubmitting(true)
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: String(formData.get("name") ?? ""),
company: String(formData.get("company") ?? ""),
phone: String(formData.get("phone") ?? ""),
scale: String(formData.get("scale") ?? ""),
message: String(formData.get("message") ?? ""),
}),
})
const result = (await response.json().catch(() => null)) as { error?: string } | null
if (!response.ok) {
throw new Error(result?.error ?? "提交失败,请稍后重试。")
}
form.reset()
setSubmitted(true)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "提交失败,请稍后重试。")
} finally {
setIsSubmitting(false)
}
}
return (
@@ -91,38 +127,46 @@ export function ContactSection() {
<div>
<h3 className="text-xl font-bold text-foreground mb-2"></h3>
<p className="text-muted-foreground text-sm">
2
2
</p>
</div>
<Button variant="outline" onClick={() => setSubmitted(false)} className="mt-2">
<Button
variant="outline"
onClick={() => {
setSubmitted(false)
setErrorMessage(null)
}}
className="mt-2"
>
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-5" noValidate>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<h3 className="text-lg font-bold text-foreground"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="name"></label>
<Input id="name" placeholder={contact.form.namePlaceholder} required className="h-11" />
<Input id="name" name="name" placeholder={contact.form.namePlaceholder} required className="h-11" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="company"> / </label>
<Input id="company" placeholder={contact.form.companyPlaceholder} required className="h-11" />
<Input id="company" name="company" placeholder={contact.form.companyPlaceholder} required className="h-11" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="phone"></label>
<Input id="phone" type="tel" placeholder={contact.form.phonePlaceholder} required className="h-11" />
<Input id="phone" name="phone" type="tel" placeholder={contact.form.phonePlaceholder} required className="h-11" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="scale"></label>
<Input id="scale" placeholder={contact.form.scalePlaceholder} className="h-11" />
<Input id="scale" name="scale" placeholder={contact.form.scalePlaceholder} className="h-11" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="message"></label>
<Textarea
id="message"
name="message"
placeholder={contact.form.messagePlaceholder}
rows={4}
className="resize-none"
@@ -131,13 +175,19 @@ export function ContactSection() {
<Button
type="submit"
size="lg"
disabled={isSubmitting}
className="w-full bg-primary hover:bg-primary-dark text-white h-12 text-base font-semibold group shadow-lg shadow-primary/25"
>
<span className="flex items-center gap-2">
{submitLabel}
{isSubmitting ? "提交中..." : submitLabel}
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</span>
</Button>
{errorMessage ? (
<p className="text-sm text-red-600 text-center" aria-live="polite">
{errorMessage}
</p>
) : null}
<p className="text-xs text-muted-foreground text-center">
{" "}
<span className="text-primary cursor-pointer hover:underline"></span>

View File

@@ -0,0 +1,19 @@
# 官网“获取初步方案”邮件通知配置
# 支持直接写死 SMTP 参数,也支持通过 ${ENV_NAME} / ${ENV_NAME:default} 引用环境变量。
smtp:
host: ${MAIL_HOST:smtp.feishu.cn}
port: ${MAIL_PORT:465}
secure: ${MAIL_SSL_ENABLED:true}
auth:
user: ${MAIL_USER}
pass: ${MAIL_PASS}
from:
email: ${MAIL_FROM_EMAIL:hi@jiuchan.org}
name: ${MAIL_FROM_NAME:智店软件官网}
notification:
to:
- ixor@qq.com
# - bd@zhidiansoft.com
subjectPrefix: 官网线索

View File

@@ -288,7 +288,7 @@
"title": "先聊清楚,再决定要不要做",
"subtitle": "告诉我们品牌规模、现用系统和核心问题,我们会给您一版可落地的改造建议。",
"infoItems": [
{ "icon": "Phone", "label": "销售热线", "value": "400-888-6688" },
{ "icon": "Phone", "label": "销售热线", "value": "400-888-8888" },
{ "icon": "Mail", "label": "商务邮箱", "value": "bd@zhidiansoft.com" },
{ "icon": "MapPin", "label": "总部地址", "value": "上海市浦东新区张江高科技园区博云路2号" },
{ "icon": "Clock", "label": "服务时间", "value": "周一至周六 9:00 - 18:00" }

View File

@@ -7,10 +7,46 @@
"founded": "2018",
"employees": "500+",
"address": "上海市浦东新区张江高科技园区博云路2号",
"phone": "400-888-6688",
"phone": "400-888-8888",
"email": "bd@zhidiansoft.com",
"icp": "沪ICP备XXXXXXXX号"
},
"seo": {
"siteUrl": "https://www.zhidiansoft.com",
"siteName": "智店软件",
"defaultTitle": "连锁门店收银、分账、监管系统 | 智店软件",
"titleTemplate": "%s | 智店软件",
"description": "面向连锁餐饮、零售与加盟品牌,提供门店收银、自动分账、总部监管一体化系统定制与升级服务。兼容现有设备与流程,支持不停业切换。",
"keywords": [
"连锁门店系统",
"收银系统",
"自动分账系统",
"总部监管系统",
"加盟门店结算",
"门店数字化",
"智店软件"
],
"canonicalPath": "/",
"locale": "zh_CN",
"robots": {
"index": true,
"follow": true
},
"openGraph": {
"type": "website",
"image": "/images/hero-bg.jpg"
},
"twitter": {
"card": "summary_large_image"
},
"verification": {
"google": "",
"yandex": "",
"other": {
"baidu-site-verification": ""
}
}
},
"nav": {
"links": [
{ "label": "首页", "href": "#home" },

20
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
apps: [
{
name: "com-index",
cwd: __dirname,
script: "./node_modules/next/dist/bin/next",
args: "start -p 3000 -H 0.0.0.0",
exec_mode: "fork",
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "512M",
env: {
NODE_ENV: "production",
PORT: "3000",
HOSTNAME: "0.0.0.0",
},
},
],
}

27
lib/contact-form.ts Normal file
View 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>

View 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
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;")
}
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
View 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,
},
]
}

View File

@@ -10,7 +10,6 @@
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@vercel/analytics": "1.6.1",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
@@ -38,6 +37,7 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -48,6 +48,7 @@
"lucide-react": "^0.564.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.2",
"react": "19.2.4",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
@@ -57,11 +58,13 @@
"sonner": "^1.7.1",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"yaml": "^2.8.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^22",
"@types/nodemailer": "^7.0.11",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"postcss": "^8.5",

29
pnpm-lock.yaml generated
View File

@@ -125,6 +125,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nodemailer:
specifier: ^8.0.2
version: 8.0.2
react:
specifier: 19.2.4
version: 19.2.4
@@ -152,6 +155,9 @@ importers:
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
yaml:
specifier: ^2.8.2
version: 2.8.2
zod:
specifier: ^3.24.1
version: 3.25.76
@@ -162,6 +168,9 @@ importers:
'@types/node':
specifier: ^22
version: 22.19.11
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/react':
specifier: 19.2.14
version: 19.2.14
@@ -1222,6 +1231,9 @@ packages:
'@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1543,6 +1555,10 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@8.0.2:
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
engines: {node: '>=6.0.0'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1751,6 +1767,11 @@ packages:
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -2726,6 +2747,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 22.19.11
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -2977,6 +3002,8 @@ snapshots:
node-releases@2.0.27: {}
nodemailer@8.0.2: {}
object-assign@4.1.1: {}
picocolors@1.1.1: {}
@@ -3205,4 +3232,6 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
yaml@2.8.2: {}
zod@3.25.76: {}

File diff suppressed because one or more lines are too long