关键词:Nuxt 4、SSG、@nuxt/content、Zod、i18n、wa.me、WhatsApp 引流、静态站 SEO
做海外营销 / 跨境电商的团队,基本都绕不开一个需求:让客户点一下就直接发起 WhatsApp 对话,而不用先保存手机号 。官方的 wa.me 短链就是干这个的,但手写链接、拼参数、生成二维码对运营来说门槛不低。
本文不聊营销话术,直接拆一个开源风格的 Nuxt 4 实现 ------WALink(一个 WhatsApp 链接 / 二维码生成站,线上跑在 link.wadesk.io)。我会把架构、核心代码、以及团队真实踩过的坑都摊开讲,重点放在能复用的工程细节上。读完你应该能照着搭出一个类似的内容驱动型静态站。
一、先讲清楚 wa.me 到底是怎么回事
很多人以为生成 WhatsApp 链接是个黑盒,其实底层就一条规则:
https://wa.me/<纯数字手机号>?text=<预填充消息(URL编码)>
- 手机号必须是国际格式、纯数字、带国家码、不带
+和空格; text是可选的预填充消息,要encodeURIComponent;- 客户端打开这个链接,WhatsApp(App 或 Web)会直接拉起对话窗口。
所以"生成器"的本质,就是把用户填的「国家 + 号码 + 消息」拼成上面这条 URL,再包一层二维码。下面看真实源码怎么做的。
二、技术栈总览
| 维度 | 选型 |
|---|---|
| 框架 | Nuxt 4.1.2,静态站点生成(SSG) |
| UI | @nuxt/ui v4(基于 Tailwind) |
| 内容 | @nuxt/content v3(YAML + Markdown) |
| 国际化 | @nuxtjs/i18n v10(11 种语言) |
| 校验 | Zod(内容 schema + 表单) |
| 二维码 | qrcode.vue |
| 其他 | better-sqlite3(dev 内容索引)、nuxt-og-image、@nuxtjs/seo、@nuxtjs/sitemap |
整个站的架构可以这样理解:
┌─────────────────────────────────────────┐
运营/市场 ──▶ │ content/{locale}/*.yml + 3.blog/*.md │ ← 内容层(Zod 校验)
└─────────────────────────────────────────┘
│ queryCollection()
┌─────────────────────────────────────────┐
开发者 ──▶ │ app/ (pages/components/composables) │ ← Nuxt 4 源码层
└─────────────────────────────────────────┘
│ nuxt generate
┌─────────────────────────────────────────┐
产物 ──▶ │ dist/public (纯静态 HTML + JSON) │ ← 预渲染 + 301 重定向
└─────────────────────────────────────────┘
│
┌─────────────────────────────────────────┐
动态能力 ──▶ │ 后端 API (wadesk.io) : 品牌短链/表单/分析 │ ← 仅 Dashboard/匿名态
└─────────────────────────────────────────┘
关键设计哲学:内容(文案、功能介绍、FAQ)和代码彻底分离,运营改 YAML 就能改页面,不用动 Vue 组件。
三、核心一:wa.me 链接生成的真实实现
核心组件 app/components/GeneratorTool.vue 的 handleGenerate,值得逐行看:
ts
const handleGenerate = () => {
if (!phoneNumber.value) return
// 1. 清洗:只保留数字
let cleanNumber = phoneNumber.value.replace(/\D/g, '')
const cleanCountry = countryCode.value.replace(/\D/g, '')
// 2. 特殊国家规则(很多生成器会在这里翻车)
let finalCountryCode = cleanCountry
if (cleanCountry === '54') {
// 阿根廷:国家码和区号之间补 9,并去掉 15 前缀
if (cleanNumber.startsWith('15')) cleanNumber = cleanNumber.substring(2)
finalCountryCode = '549'
} else if (cleanCountry === '52') {
// 墨西哥:国家码后补 1
finalCountryCode = '521'
}
// 3. 拼国际格式纯数字号码
const fullNumber = `${finalCountryCode}${cleanNumber}`
const encodedMessage = encodeURIComponent(message.value)
// 4. 生成最终 wa.me 链接
const link = `https://wa.me/${fullNumber}${message.value ? `?text=${encodedMessage}` : ''}`
generatedLink.value = link
}
可复用点:
- 号码清洗一定要在前端就做 ,不要等后端正则报错才提示。这里用
replace(/\D/g, '')一把梭。 - 阿根廷 / 墨西哥的特殊规则 是隐形坑。阿根廷手机号实际是
54 9 <区号><号码>,墨西哥是52 1 <号码>,直接拼54<号码>会打不通。这种区域规则建议抽成一张配置表,而不是硬编码 if。 - 预填充消息必须
encodeURIComponent,否则#、&、换行会截断链接。
四、核心二:二维码生成与下载
二维码用的是 qrcode.vue,生成很省事,但下载这一步有个实用技巧------直接读 canvas 转 DataURL:
ts
const downloadQRCode = () => {
const canvas = qrRef.value?.querySelector('canvas')
if (canvas) {
const url = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = 'whatsapp-qr-code.png'
link.href = url
link.click()
}
}
模板里 <QrcodeVue :value="generatedLink" :size="180" level="H" /> 即可。level="H" 是纠错等级最高,适合印在海报 / 名片上被部分遮挡仍能扫。
五、核心三:内容驱动架构(运营改文案不碰代码)
这是整个项目最值得抄的部分。页面内容全部抽到 content/{locale}/*.yml,并用 Zod schema 强约束 (content.config.ts):
ts
// content.config.ts ------ 首页内容集合
export const collections = {
index: defineCollection({
source: '*/0.index.yml',
type: 'page',
schema: z.object({
canonical: z.string(),
seo: z.object({
title: z.string(),
description: z.string(),
keywords: z.string()
}),
generator: z.object({
heroTitle: z.string(),
generateBtn: z.string(),
copy: z.string(),
// ... 几十个字段都有类型
}),
faq: z.object({
items: z.array(z.object({
question: z.string(),
answer: z.string()
}))
})
})
})
}
页面里查询内容时,Nuxt Content v3 提供 queryCollection:
ts
const { getContentPath } = useContentPath()
const { data: page } = await useAsyncData(route.path,
() => queryCollection('index').where('path', '=', getContentPath()).first()
)
好处:
- 字段写错、漏写,构建期 Zod 直接报错,不会上线才发现文案缺失;
- 多语言只需在
content/zh-cn、content/es、content/pt各放一份同结构 YAML; - 内容层用
@nuxt/content在 dev 下编译进 SQLite 索引,查询走queryCollection,SSR/SSG 都能用。
⚠️ 真实踩坑:dev 模式下内容索引写进
.data/content/*.sqlite,同时跑两个nuxt dev会并发写同一个库 ,报UNIQUE constraint failed: _content_info.__hash__。排查信号:ls node_modules/.package-lock.json是否多出 npm 的锁文件------说明 Nuxt Icon 自动帮你装缺失图标集时用的是npm install而不是pnpm,跨包管理器污染会导致 SSR 报'set' on proxy之类诡异错误。修复就是删掉node_modules重新pnpm install。
六、核心四:11 语言 i18n 策略
@nuxtjs/i18n 的配置有几个反直觉但很重要的决定:
ts
i18n: {
defaultLocale: 'en',
strategy: 'prefix_and_default', // en 无前缀,其他语言有前缀(/es /zh-cn ...)
detectBrowserLanguage: false, // 完全禁用浏览器语言自动重定向
vueI18n: './i18n/configs/i18n.config.ts'
}
为什么禁用浏览器语言检测? 对营销站来说,SEO 收录要求每个语言有稳定、可索引的独立 URL 。如果根据用户浏览器自动跳转,爬虫拿到的可能是错乱的语言页,且用户分享链接时语言会漂。固定 prefix_and_default 让每种语言都有确定 URL,对搜索引擎更友好。
站内跳转一律用 <NuxtLink :to="localePath(path)">,不要 对内部路由用原生 <a href>------后者会绕过 Vue Router 触发整页硬刷新,后退时还会 504。只有真正的外链(wa.me、短链域名)才用 <a target="_blank">。
七、核心五:匿名优先的身份与认领体系
这是个很巧妙的设计。营销工具希望不强制登录------用户进来就能生成链接 / 填表单。但数据要归到"某人"名下,登录后才能管理。解法:
app/composables/useOwnerId.ts:
ts
const ownerId = computed(() => {
if (isLoggedIn.value && user.value) {
return `user:${user.value.userId}` // 登录态:账号身份
}
return `anon:${ensureAnonId()}` // 未登录:本地匿名 UUID
})
const getOwnerHeaders = (): Record<string, string> => {
if (typeof window === 'undefined') return {}
const token = localStorage.getItem('access_token')
if (token && isWALinkToken(token)) {
return { 'Authorization': `Bearer ${token}` } // JWT 且 product_id===15 才认
}
return { 'X-WALink-Owner-Id': `anon:${ensureAnonId()}` }
}
三个细节很值得抄:
- JWT 产品隔离 :同域 localStorage 可能混存多个产品的 token。
isWALinkToken解析 JWT payload 校验product_id === 15,避免用错凭证导致"登录态无效"。 - 匿名 ID 降级 :
crypto.randomUUID()只在安全上下文(https / localhost)存在,用局域网 IP 访问 dev server 时是undefined。generateAnonId()依次降级到getRandomValues→Math.random,避免直接TypeError。 - 登录后认领 :
useClaimAnonymousData().claim()调POST /owners/claim,把匿名 UUID 下的数据转移给刚登录的账号,用户无感。
八、核心六:SSG 与 SEO 工程
静态站的优势是快、便宜、好缓存,但 SEO 和动态能力要额外规划。nuxt.config.ts 的 routeRules 是关键:
ts
routeRules: {
'/': { prerender: true }, // 首页预渲染
'/zh-cn': { prerender: true },
// 旧 URL 301 重定向到新结构(保留历史权重)
'/whatsapp-multi-agent-link': { redirect: { to: '/whatsapp-link-rotator', statusCode: 301 } },
// 登录 / Dashboard / 表单填写页:纯客户端渲染,不预渲染
'/login': { ssr: false },
'/dashboard/**': { ssr: false },
'/f/**': { ssr: false }, // 表单匿名页 noindex
'/m/**': { ssr: false } // 多链接聚合页 noindex
}
配套还有:
@nuxtjs/seo+@nuxtjs/sitemap自动生成多语言 sitemap;robots配置精确 disallow/api /login /dashboard /f /m;nuxt-og-image自动生成社交分享图;- 资源走 CDN,并加
preconnect/dns-prefetch提前建连。
一个隐蔽但高危的坑(来自真实事故): 注入 JSON-LD 结构化数据时,字段名必须是 innerHTML 不是 children:
ts
// ✅ 正确
useHead({ script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(data) }] })
// ❌ 错误(曾有人写成 children 还套了 as any 压类型)
useHead({ script: [{ type: 'application/ld+json', children: JSON.stringify(data) }] })
写成 children 时,Unhead 会把它当普通 HTML 属性,渲染出 <script ... children="...">,脚本内容是空的 。页面肉眼看不出问题,但 Google 结构化数据测试直接解析失败。上线前务必 curl 一下真实 HTML 看 <script> 的 textContent。
九、8 个真坑复盘(团队踩过的,你直接避开)
| # | 坑 | 根因 | 解法 |
|---|---|---|---|
| 1 | 内容"消失",只剩 header/footer | @nuxt/content 的 SQLite dev 缓存损坏 |
杀进程 → rm -rf .data/content .nuxt → 重启 |
| 2 | 后退时整页闪烁 + 504 | 内部路由用了 <a> 而非 <NuxtLink> |
内部跳转一律 localePath() |
| 3 | 组件静默加载失败 | components/dashboard/X.vue 被注册成 <DashboardX> |
跨目录显式 import |
| 4 | SSR 报 'set' on proxy |
Nuxt Icon 用 npm 自动装图标,pnpm 混装 | 删 node_modules + package-lock.json 重装 |
| 5 | 结构化数据为空 | JSON-LD 用了 children |
改用 innerHTML |
| 6 | 非 https 下生成失败 | crypto.randomUUID / navigator.clipboard 不可用 |
加 getRandomValues / execCommand 降级 |
| 7 | 编辑页 DataCloneError | 把 reactive proxy 传给 structuredClone |
改用 JSON 深拷贝 |
| 8 | 复制出 https://https://... |
后端 shortUrl 已带协议,前端又拼前缀 | 复制前去前缀 |
其中 #6 的降级 和 #7 的 proxy 深拷贝 是最容易在真实业务里中招的两类,建议直接封装成工具函数(utils/clipboard.ts 的 copyText()、JSON.parse(JSON.stringify(x)) 代替 structuredClone)。
十、总结:能直接抄的清单
如果你也想做一个"内容驱动 + 多语言 + 轻后端"的营销工具站,这套架构的要点是:
- 正文内容全部 YAML/Markdown 化 + Zod schema 校验,运营改文案不碰代码;
- wa.me 生成逻辑前端做清洗 + 区域规则抽表,别把脏数据丢给后端;
- i18n 固定语言前缀、禁用浏览器检测,每个语言一个稳定可收录 URL;
- 匿名优先身份 + 登录后认领,降低使用门槛同时不丢数据归属;
- SSG 下把登录/Dashboard 设
ssr:false、旧链 301、动态内容noindex; - JSON-LD 用
innerHTML、内部跳转用NuxtLink,两个坑上线前必查。
示例项目 WALink 以 Nuxt 4 实现并开源风格地跑在 link.wadesk.io,上面的代码片段均取自其真实仓库,可直接对照参考。