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

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,
},
]
}