From e2850586a995c6122106bb9bca53386576ba495c Mon Sep 17 00:00:00 2001 From: JanYork Date: Wed, 18 Mar 2026 13:20:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=81=94=E7=B3=BB?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E6=8F=90=E4=BA=A4=E4=B8=8E=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加联系表单提交接口和邮件通知功能,支持从环境变量读取 SMTP 配置 重构 SEO 配置到 site.json,新增 robots.txt 和 sitemap.xml 生成 更新公司电话并添加 PM2 生产运行配置 --- .env.example | 7 + .env.local | 7 + .gitignore | 2 +- README.md | 204 ++++++++++++++++++++++++ app/api/contact/route.ts | 79 +++++++++ app/layout.tsx | 13 +- app/robots.ts | 6 + app/sitemap.ts | 6 + components/sections/contact-section.tsx | 74 +++++++-- config/contact-mailer.yaml | 19 +++ data/home.json | 2 +- data/site.json | 38 ++++- ecosystem.config.cjs | 20 +++ lib/contact-form.ts | 27 ++++ lib/contact-mailer-config.ts | 99 ++++++++++++ lib/contact-mailer.ts | 116 ++++++++++++++ lib/seo.ts | 145 +++++++++++++++++ package.json | 5 +- pnpm-lock.yaml | 29 ++++ tsconfig.tsbuildinfo | 2 +- 20 files changed, 872 insertions(+), 28 deletions(-) create mode 100644 .env.example create mode 100644 .env.local create mode 100644 README.md create mode 100644 app/api/contact/route.ts create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 config/contact-mailer.yaml create mode 100644 ecosystem.config.cjs create mode 100644 lib/contact-form.ts create mode 100644 lib/contact-mailer-config.ts create mode 100644 lib/contact-mailer.ts create mode 100644 lib/seo.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ff4163 --- /dev/null +++ b/.env.example @@ -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=智店软件官网 diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..dae4231 --- /dev/null +++ b/.env.local @@ -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=智店软件官网 diff --git a/.gitignore b/.gitignore index c138a60..9346016 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ __v0_jsx-dev-runtime.ts next.user-config.* # Environment variables -.env*.local +# .env*.local # Common ignores node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..353fb6a --- /dev/null +++ b/README.md @@ -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` 还不能全绿。 diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..b813696 --- /dev/null +++ b/app/api/contact/route.ts @@ -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 } + ) + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 10b104d..18fb77b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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, diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..0af3d4e --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,6 @@ +import type { MetadataRoute } from "next" +import { buildRobots } from "@/lib/seo" + +export default function robots(): MetadataRoute.Robots { + return buildRobots() +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..01f6358 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,6 @@ +import type { MetadataRoute } from "next" +import { buildSitemap } from "@/lib/seo" + +export default function sitemap(): MetadataRoute.Sitemap { + return buildSitemap() +} diff --git a/components/sections/contact-section.tsx b/components/sections/contact-section.tsx index dd7ff11..0384015 100644 --- a/components/sections/contact-section.tsx +++ b/components/sections/contact-section.tsx @@ -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 = { +const iconMap: Record = { phone: , mail: , mappin: , @@ -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(null) - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault() - setSubmitted(true) + + 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() {

信息已提交

- 工作时间内 2 小时响应,非工作时间次一工作日上午回复。 + 管理人员已收到邮件通知,工作时间内 2 小时响应,非工作时间次一工作日上午回复。

- ) : ( -
+

留下信息,先拿一版初步建议

- +
- +
- +
- +