主题
WellChina 全面国际化方案
产品调研、设计与实施计划
2026-04-14
目录
1. 需求总览与目标语言
1.1 三大需求
| # | 需求 | 核心挑战 |
|---|---|---|
| 1 | 全站 UI + 内容多语言 | 翻译量大、医疗术语准确性、SEO 多语言路由 |
| 2 | 多国家价格对比 | 数据采集(各国医疗价格)、汇率、数据维护 |
| 3 | 聊天实时翻译 | 低延迟、低成本、双向翻译 |
1.2 目标语言(Phase 1 → Phase 2)
Phase 1(8 种语言,覆盖核心市场):
| 语言 | 代码 | 市场理由 |
|---|---|---|
| English | en | 默认语言,已有 |
| 简体中文 | zh | 本地运营 + 华裔用户 |
| 日本語 | ja | 日本医疗旅行大市场,地理近 |
| 한국어 | ko | 韩国医疗旅行大市场,地理近 |
| Русский | ru | 俄罗斯/中亚用户,中国医疗旅行主要客源 |
| Bahasa Indonesia | id | 东南亚最大人口国 |
| Tiếng Việt | vi | 越南与中国接壤,医疗旅行需求高 |
| ภาษาไทย | th | 泰国用户对中国医疗有需求 |
Phase 2(扩展 6 种语言,覆盖欧洲市场):
| 语言 | 代码 | 市场理由 |
|---|---|---|
| Français | fr | 法语区(法国 + 非洲法语国家) |
| Deutsch | de | 德国/奥地利/瑞士 |
| Español | es | 西班牙 + 拉美 |
| Italiano | it | 意大利 |
| العربية | ar | 中东市场(RTL 支持) |
| Português | pt | 巴西 + 葡萄牙 |
1.3 语言检测优先级
1. URL path prefix(/ja/hospitals)— 最高优先级,明确意图
2. Cookie(NEXT_LOCALE)— 用户曾选择过
3. Accept-Language header — 浏览器语言偏好
4. 默认 en2. 模块一:全站多语言支持
2.1 当前状态分析
- next-intl v3.22 已安装,但仅用于英文
messages/en.json包含 ~115 个 UI 翻译 keysrc/i18n/request.ts硬编码locale = 'en'- 无
[locale]路由段、无routing.ts、无语言切换 UI - 大量页面内仍有硬编码英文字符串未提取到 messages
- 数据库内容(医院名、手术名、指南内容)仅有
nameEn/nameCn两个字段
2.2 架构设计
2.2.1 路由方案:Path Prefix(推荐)
wellchina.top/en/hospitals — English
wellchina.top/ja/hospitals — 日本語
wellchina.top/ko/hospitals — 한국어
wellchina.top/hospitals — 重定向到检测到的语言为什么选 Path Prefix 而不是 Subdomain/Query Param:
- SEO 友好:每种语言有独立 URL,Google 可分别索引
- next-intl 原生支持
[locale]路由段 - 部署简单:Vercel 无需额外域名配置
- 用户可分享特定语言的链接
2.2.2 目录结构变更
src/app/
├── [locale]/ ← 新增:所有页面移入
│ ├── layout.tsx ← 带 locale 参数的根 layout
│ ├── page.tsx ← 首页
│ ├── hospitals/
│ ├── procedures/
│ ├── pricing/
│ ├── cities/
│ ├── compare/
│ ├── guides/
│ ├── contact/
│ ├── about/
│ ├── plans/
│ ├── search/
│ ├── account/
│ └── admin/ ← admin 可保持仅英文/中文
├── api/ ← API 路由不需要 locale 前缀
messages/
├── en.json
├── zh.json
├── ja.json
├── ko.json
├── ru.json
├── id.json
├── vi.json
└── th.json2.2.3 next-intl 配置
新增 src/i18n/routing.ts:
typescript
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['en', 'zh', 'ja', 'ko', 'ru', 'id', 'vi', 'th'],
defaultLocale: 'en',
localePrefix: 'as-needed', // en 不显示前缀,其他语言显示
localeDetection: true,
})更新 src/i18n/request.ts:
typescript
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
}
})更新 src/middleware.ts:
typescript
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
const intlMiddleware = createMiddleware(routing)
// 合并现有 Supabase auth middleware 与 intl middleware
export default async function middleware(request: NextRequest) {
// 1. Admin/API 路由 → 走现有 auth 逻辑
// 2. 其他路由 → 走 intlMiddleware 处理 locale 检测和重定向
}2.3 翻译内容分层
| 层级 | 内容类型 | 翻译方式 | 存储位置 |
|---|---|---|---|
| UI 层 | 导航、按钮、标签、表单 placeholder | Claude 翻译,JSON 文件 | messages/{locale}.json |
| 半静态内容 | 手术名称、医院名称、城市描述、分类名 | Claude 翻译,写入 seed | Prisma 模型 JSONB 字段 |
| 动态内容 | 指南文章(Markdown)、FAQ | Claude 翻译 | Guide 翻译关联表 |
| 用户生成内容 | 聊天消息 | DeepL API Free 实时翻译 | message_translations 表 |
2.4 UI 层翻译实施
2.4.1 消息文件结构(以日语为例)
jsonc
// messages/ja.json
{
"nav": {
"home": "ホーム",
"hospitals": "病院",
"procedures": "診療科目",
"pricing": "料金比較",
"cities": "都市",
"compare": "比較",
"guides": "ガイド",
"contact": "お問い合わせ",
"about": "概要"
},
"home": {
"hero": {
"title": "中国で質の高い医療を、手頃な価格で",
"subtitle": "トップクラスの病院を比較し、最大70%の費用を節約しましょう"
}
},
"common": {
"cny": "人民元",
"loading": "読み込み中...",
"disclaimer": "価格は参考値です。必ず病院にご確認ください。"
},
"regions": {
"us": "アメリカ合衆国",
"jp": "日本",
"kr": "韓国",
"au": "オーストラリア",
"gb": "イギリス",
"sg": "シンガポール",
"th": "タイ",
"in": "インド",
"de": "ドイツ",
"ru": "ロシア",
},
"regionSwitcher": {
"language": "言語",
"region": "地域",
"regionHint": "地域は価格比較に影響します。言語とは独立しています。"
}
}2.4.2 硬编码文本提取
当前多数页面仍有硬编码文本。需要系统性提取:
typescript
// Before(硬编码)
<h1>Find the Best Hospitals in China</h1>
// After(使用 next-intl)
const t = useTranslations('hospitals')
<h1>{t('title')}</h1>预估工作量: ~20 个页面/组件需要提取,约 300-400 个新 key。
2.4.3 初始翻译生成策略
由 Claude 直接完成所有静态翻译(UI 文本 + 数据库内容),不使用机器翻译服务:
- Claude 翻译全部 UI 文本: 基于对 WellChina 产品定位、医疗术语、目标用户群体的完整理解,由 Claude 直接生成
messages/{locale}.json,确保翻译在语境中自然、术语准确 - Claude 翻译数据库内容: 手术名称、分类名、医院描述、城市描述、指南内容等半静态内容,同样由 Claude 在 seed 脚本中直接提供多语言版本
- 持续维护: 新增 key 或内容变更时,由 Claude 补充翻译
为什么不用机器翻译服务:
- Claude 了解整个产品上下文,翻译更贴合平台调性
- 医疗术语需要专业判断(如 "All-on-4" 在不同语言中的惯用表达),Claude 可以直接做出正确选择
- 省去"机器翻译 → 人工校对"的流程,一步到位
- 无 API 调用成本和额度限制
2.5 数据库内容多语言
2.5.1 方案选择:JSONB 字段
对于数据库中的实体名称/描述,推荐使用 JSONB 字段 替代为每种语言加独立列:
prisma
model Procedure {
id String @id @default(uuid())
slug String @unique
// 旧:nameEn, nameCn
// 新:
name Json // { "en": "Dental Implant", "ja": "歯科インプラント", "zh": "牙科种植牙" }
subtitle Json? // { "en": "Single Tooth", "ja": "シングルトゥース" }
description Json? // { "en": "...", "ja": "..." }
// ... 其他字段不变
}为什么选 JSONB 而不是每语言一列:
- 新增语言无需 schema migration
- 查询灵活:
name->>'ja'或 fallbackCOALESCE(name->>'ja', name->>'en') - 避免列爆炸(14 种语言 × 3 个文本字段 = 42 列 vs 3 个 JSONB 列)
Fallback 逻辑:
typescript
function getLocalized(field: Record<string, string>, locale: string): string {
return field[locale] || field['en'] || Object.values(field)[0] || ''
}2.5.2 需要多语言化的模型字段
| 模型 | 字段 | 说明 |
|---|---|---|
| Procedure | name, subtitle, description | 手术名、副标题、描述 |
| ProcedureCategory | name | 分类名 |
| Hospital | name, description | 医院名、描述 |
| City | name, description | 城市名、描述 |
| Specialty | name | 专科名 |
| Insurance | name | 保险名 |
| Guide | title, content | 指南标题、Markdown 内容 |
2.5.3 Guide 多语言方案
指南是长篇 Markdown 内容,不适合放在 JSONB 中。推荐新增关联表:
prisma
model GuideTranslation {
id String @id @default(uuid())
guideId String @map("guide_id")
locale String // 'ja', 'ko', 'ru' 等
title String
content String @db.Text
guide Guide @relation(fields: [guideId], references: [id])
@@unique([guideId, locale])
@@map("guide_translations")
}2.6 语言与地区选择器 UI
位置: 导航栏右侧(Desktop)/ 移动菜单底部
设计:二级选择器(语言 + 地区分开)
导航栏显示两个独立指示器,点击展开统一的下拉面板:
Desktop Navbar:
[...导航链接...] [🌐 EN · 🇺🇸 US ▾] [主题切换]
展开后的统一面板:
┌─────────────────────────────────────────────┐
│ Language Region │
│ ───────── ────── │
│ ○ English ✓ ○ 🇺🇸 United States ✓│
│ ○ 日本語 ○ 🇯🇵 Japan │
│ ○ 한국어 ○ 🇰🇷 South Korea │
│ ○ 中文 ○ 🇦🇺 Australia │
│ ○ Русский ○ 🇬🇧 United Kingdom │
│ ○ Bahasa Indonesia ○ 🇸🇬 Singapore │
│ ○ Tiếng Việt ○ 🇹🇭 Thailand │
│ ○ ไทย ○ 🇮🇳 India │
│ ○ 🇩🇪 Germany │
│ ○ 🇷🇺 Russia │
│ │
│ Region affects which country's prices you │
│ compare against. Language is independent. │
└─────────────────────────────────────────────┘
Mobile(汉堡菜单底部):
┌─ Language ──────────────────┐
│ [EN ▾] 选择语言 │
├─ Region ───────────────────┤
│ [🇺🇸 US ▾] 选择地区 │
│ Affects price comparisons │
└────────────────────────────┘交互行为:
- 语言切换 → 跳转到相同页面的对应语言 URL + 设置
NEXT_LOCALEcookie - 地区切换 → 页面不跳转,仅设置
NEXT_REGIONcookie + 刷新价格相关组件 - 语言使用各语言的原生名称(日本語、한국어,不用 Japanese、Korean)
- 地区名称跟随当前语言翻译(如日语 UI 下显示「アメリカ」「日本」) 首次访问引导:
- 首次访问用户看到语言通过
Accept-Language自动检测 - 地区默认
US(全球基准) - 不弹出强制选择弹窗,用户可随时通过导航栏切换
2.7 Region 在 Server Components 中的传递
Region 不影响 URL 路由(不像 locale 有 path prefix),通过 cookie 传递:
typescript
// middleware.ts — 读取 NEXT_REGION cookie,设置 x-region header
export default async function middleware(request: NextRequest) {
const region = request.cookies.get('NEXT_REGION')?.value || 'US'
const response = intlMiddleware(request)
response.headers.set('x-region', region)
return response
}
// Server Component 中读取 region
import { headers } from 'next/headers'
async function PricingPage() {
const headersList = await headers()
const region = headersList.get('x-region') || 'US'
// 根据 region 查询对应国家价格...
}
// Client Component 中读取 region
// RegionProvider 从 cookie 读取初始值,提供 useRegion() hook
'use client'
const { region, setRegion } = useRegion()Region 不进入 URL 的原因:
- 地区是用户偏好,不是内容维度(同一篇日语内容对所有地区用户都一样)
- 不需要 SEO 索引(搜索引擎不需要按地区区分页面)
- 避免 URL 组合爆炸(8 语言 × 11 地区 = 88 种组合)
2.8 SEO 多语言
html
<!-- 每个页面自动生成 hreflang 标签 -->
<link rel="alternate" hreflang="en" href="https://wellchina.top/en/hospitals" />
<link rel="alternate" hreflang="ja" href="https://wellchina.top/ja/hospitals" />
<link rel="alternate" hreflang="ko" href="https://wellchina.top/ko/hospitals" />
<link rel="alternate" hreflang="x-default" href="https://wellchina.top/hospitals" />实现方式: 在 [locale]/layout.tsx 的 generateMetadata 中自动生成。next-intl 提供 getAlternates() 工具函数。
2.9 RTL 支持(Phase 2 阿拉伯语)
typescript
// layout.tsx
const dir = locale === 'ar' ? 'rtl' : 'ltr'
<html lang={locale} dir={dir}>Tailwind CSS 4 原生支持 rtl: 修饰符,需在个别布局中添加 rtl: 类。
3. 模块二:多国家价格对比体系
3.1 当前状态
- 仅有
priceUsComparison字段,只对比美国价格 savingsPct是相对于美国的固定百分比- 无汇率、无多国价格数据
3.2 目标国家与理由
根据医疗旅行市场和 WellChina 目标用户,选取以下国家进行价格对比:
| 国家 | 代码 | 货币 | 理由 |
|---|---|---|---|
| 美国 | US | USD | 已有数据,全球价格基准 |
| 日本 | JP | JPY | 核心目标市场 |
| 韩国 | KR | KRW | 核心目标市场 |
| 澳大利亚 | AU | AUD | 英语市场,医疗费用高 |
| 英国 | GB | GBP | 英语市场,NHS 排队长 |
| 新加坡 | SG | SGD | 东南亚医疗中心 |
| 泰国 | TH | THB | 医疗旅行竞争对手 |
| 印度 | IN | INR | 医疗旅行竞争对手 |
| 德国 | DE | EUR | 欧洲最大经济体 |
| 俄罗斯 | RU | RUB | 核心目标市场 |
3.3 数据库设计
3.3.1 新增模型
prisma
// 国家价格参考
model CountryPriceReference {
id String @id @default(uuid())
procedureId String @map("procedure_id")
countryCode String @map("country_code") // ISO 3166-1 alpha-2
currencyCode String @map("currency_code") // ISO 4217
priceMin Int @map("price_min") // 当地货币最小值
priceMax Int @map("price_max") // 当地货币最大值
priceMedian Int? @map("price_median") // 中位数(如有)
source String? // 数据来源 URL/名称
sourceYear Int? @map("source_year") // 数据年份
notes String? @db.Text // 备注(如是否含保险等)
updatedAt DateTime @updatedAt @map("updated_at")
procedure Procedure @relation(fields: [procedureId], references: [id])
@@unique([procedureId, countryCode])
@@map("country_price_references")
}
// 汇率缓存
model ExchangeRate {
id String @id @default(uuid())
fromCurrency String @map("from_currency") // CNY
toCurrency String @map("to_currency") // USD, JPY, ...
rate Float // 1 CNY = ? toCurrency
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([fromCurrency, toCurrency])
@@map("exchange_rates")
}3.3.2 迁移旧数据
sql
-- 将现有 priceUsComparison 迁移到新表
INSERT INTO country_price_references (id, procedure_id, country_code, currency_code, price_min, price_max, source, source_year)
SELECT gen_random_uuid(), id, 'US', 'USD', price_us_comparison, price_us_comparison, 'legacy_data', 2025
FROM procedures
WHERE price_us_comparison IS NOT NULL;旧字段 priceUsComparison 和 savingsPct 可保留一段时间做兼容,后续移除。
3.4 价格数据采集方案
3.4.1 数据来源矩阵
| 国家 | 主要数据源 | 备选来源 |
|---|---|---|
| 🇺🇸 US | CMS Medicare Fee Schedule, Healthcare Bluebook | 已有 seed data |
| 🇯🇵 JP | 厚生労働省 診療報酬点数表(1点=10円) | Medical tourism agency price lists |
| 🇰🇷 KR | HIRA(건강보험심사평가원)公开价目 | Korean medical tourism portal (visitmedicalkorea.com) |
| 🇦🇺 AU | MBS Online (Medicare Benefits Schedule) | Private hospital fee surveys |
| 🇬🇧 GB | NHS Reference Costs, PHIN (Private Healthcare Info Network) | Private hospital quotes |
| 🇸🇬 SG | MOH Bill Size benchmarks | Singapore medical tourism portal |
| 🇹🇭 TH | Bumrungrad / Bangkok Hospital published prices | Medical tourism comparison sites |
| 🇮🇳 IN | CGHS/AIIMS rate cards | Medical tourism platforms (Vaidam, Lyfboat) |
| 🇩🇪 DE | GOÄ (Gebührenordnung für Ärzte) | Medical tourism portals |
| 🇷🇺 RU | ОМС тарифы / 私立医院价目表 | Medical tourism agencies |
3.4.2 数据采集策略
Phase 1 — 核心手术 × 核心国家(优先级最高):
优先采集用户最关心的手术(前 20 项高频手术)在核心市场国家的价格:
| 手术类别 | 优先手术 |
|---|---|
| Dental | Dental Implant, Porcelain Veneer, All-on-4/6 |
| Eye Care | LASIK, ICL, Cataract |
| Health Checkup | Comprehensive, Executive |
| Fertility | IVF, Egg Freezing |
| Orthopedics | Hip Replacement, Knee Replacement |
| Cosmetic | Rhinoplasty, Facelift, Liposuction |
| Cardiac | CABG, Angioplasty |
数据采集方式:
- 公开数据源爬取/整理: 政府公开价目表(如日本诊疗报酬点数表、韩国 HIRA)
- 医疗旅行平台交叉验证: Patients Beyond Borders, Bookimed, Medical Departures 等
- 手动调研验证: 对于无公开数据的国家,通过医疗旅行中介、医院网站获取报价范围
- 年度更新: 价格数据标注年份,每年更新一次
数据质量规则:
- 所有价格必须标注来源和年份
- 价格范围(min-max)优于单一数字
- 标明是否含税、是否含住院费
- 对于保险覆盖的国家(如 UK NHS),标注自费价格
3.5 语言与地区分离设计
3.5.0 核心原则:语言 ≠ 地区
语言(Locale)和地区(Region)是两个独立维度,不能互相推断:
| 维度 | 决定什么 | 示例 |
|---|---|---|
| 语言(Locale) | UI 文本、内容翻译、日期/数字格式 | ja → 所有文字显示日语 |
| 地区(Region) | 价格对比国家、默认货币、节省百分比 | US → 对比美国价格、显示 USD |
反例说明:
- 住在美国的日本人 → 语言
ja+ 地区US(看日语 UI,对比美国价格) - 住在澳洲的华裔 → 语言
zh+ 地区AU(看中文 UI,对比澳洲价格) - 住在新加坡的韩国人 → 语言
ko+ 地区SG(看韩语 UI,对比新加坡价格)
3.5.1 地区配置
typescript
// src/config/regions.ts
export interface Region {
code: string // ISO 3166-1 alpha-2
currencyCode: string // ISO 4217
flag: string // emoji flag
nameKey: string // translation key for region name
}
export const REGIONS: Region[] = [
{ code: 'US', currencyCode: 'USD', flag: '🇺🇸', nameKey: 'regions.us' },
{ code: 'JP', currencyCode: 'JPY', flag: '🇯🇵', nameKey: 'regions.jp' },
{ code: 'KR', currencyCode: 'KRW', flag: '🇰🇷', nameKey: 'regions.kr' },
{ code: 'AU', currencyCode: 'AUD', flag: '🇦🇺', nameKey: 'regions.au' },
{ code: 'GB', currencyCode: 'GBP', flag: '🇬🇧', nameKey: 'regions.gb' },
{ code: 'SG', currencyCode: 'SGD', flag: '🇸🇬', nameKey: 'regions.sg' },
{ code: 'TH', currencyCode: 'THB', flag: '🇹🇭', nameKey: 'regions.th' },
{ code: 'IN', currencyCode: 'INR', flag: '🇮🇳', nameKey: 'regions.in' },
{ code: 'DE', currencyCode: 'EUR', flag: '🇩🇪', nameKey: 'regions.de' },
{ code: 'RU', currencyCode: 'RUB', flag: '🇷🇺', nameKey: 'regions.ru' },
]
// 目标用户均为海外用户,不含 CN 地区3.5.2 地区检测与持久化
地区检测优先级:
1. Cookie(NEXT_REGION)— 用户曾手动选择过
2. URL query param(?region=JP)— 从外部链接带入
3. 默认 US — 全球基准,最安全的默认值不使用 IP Geolocation 的原因:
- Vercel Edge 的
request.geo准确度有限(VPN 用户、CDN 干扰) - 增加用户隐私担忧
- 手动选择一次后 cookie 持久化,体验已足够好
持久化方式:
typescript
// Cookie: NEXT_REGION=JP(与 NEXT_LOCALE 平行)
// 在 RegionSwitcher 切换时设置,middleware 读取并传递给 Server Components3.5.3 价格展示设计
手术卡片(改进后):
┌─────────────────────────────────────┐
│ Dental Implant │
│ Single Tooth Restoration │
│ │
│ China Price: ¥4,000 – ¥15,000 │
│ ($550 – $2,060) │
│ │
│ 🇯🇵 Japan: ¥300,000 – ¥500,000 │ ← 根据用户 region(非语言)自动展示
│ │
│ 💰 Save up to 85% │ ← 动态计算
│ │
│ [Compare All Countries ▾] │ ← 展开完整对比
└─────────────────────────────────────┘展开后的完整对比表:
┌────────────────────────────────────────────────────┐
│ Dental Implant — Global Price Comparison │
├──────────┬───────────────────┬──────────┬──────────┤
│ Country │ Price Range │ vs China │ Currency │
├──────────┼───────────────────┼──────────┼──────────┤
│ 🇨🇳 China │ ¥4,000–15,000 │ — │ CNY │
│ 🇺🇸 USA │ $3,000–5,500 │ +380% │ USD │
│ 🇯🇵 Japan │ ¥300k–500k │ +280% │ JPY │
│ 🇰🇷 Korea │ ₩1.2M–2.5M │ +150% │ KRW │
│ 🇬🇧 UK │ £2,000–3,500 │ +340% │ GBP │
│ 🇹🇭 Thai │ ฿25k–60k │ +20% │ THB │
│ 🇮🇳 India │ ₹25k–45k │ -10% │ INR │
└──────────┴───────────────────┴──────────┴──────────┘
Data sources: [i] | Rates as of Apr 20263.5.4 汇率处理(已实现)
架构:完全在 Supabase PostgreSQL 内完成,不依赖 Vercel Cron
┌─ Supabase PostgreSQL ──────────────────────────────┐
│ │
│ pg_cron (每日 UTC 06:00) │
│ ↓ │
│ update_exchange_rates_and_prices() PL/pgSQL 函数 │
│ ├─ 1. http 扩展调用 open.er-api.com/v6/latest/CNY │
│ ├─ 2. 更新 exchange_rates 表(10 个货币对) │
│ └─ 3. 预计算 converted_procedure_prices 表 │
│ (51 手术 × 10 货币 = 510 行) │
│ │
│ Admin 手动刷新:同一函数通过 API 端点触发 │
└─────────────────────────────────────────────────────┘关键设计决策:
- 使用
pg_cron+http扩展(同步 HTTP),而非pg_net(异步),因为每日一次的同步调用更简单可靠 - 转换价格预计算并存储到
converted_procedure_prices表,前端零运行时计算 - API:
open.er-api.com(免费,无需 API Key,1500 次/月,每日一次仅消耗 30 次/月) - JPY 使用
"JPY "前缀避免与 CNY¥符号冲突
前端展示逻辑:
- 所有 CNY 价格下方显示用户所选 Region 货币的转换价格,如
(~$585 – $2,195) - 数据来源:
converted_procedure_prices表(预计算,非实时换算) - HospitalListWithFilter 客户端组件通过
currencyConversionprop 接收汇率,动态转换医院特定价格
3.5.5 Pricing 页面改造
Pricing 页面从导航栏 region cookie 读取用户地区,无需页面内重复选择:
Your region: 🇯🇵 Japan [Change ▾] ← 快捷切换,同时更新全局 region
┌─ Procedure ─┬─ China ──────┬─ Japan ───────┬─ Savings ─┐
│ LASIK │ ¥8k–15k │ ¥400k–600k │ Save 85% │
│ IVF │ ¥30k–50k │ ¥500k–1M │ Save 80% │
│ ... │ │ │ │
└─────────────┴─────────────┴──────────────┴───────────┘
[Compare All 10 Countries ▾] ← 展开完整对比表3.6 savings 动态计算
移除硬编码的 savingsPct 字段,改为动态计算:
typescript
function calculateSavings(
chinaPriceMax: number,
foreignPriceMin: number,
foreignCurrency: string,
exchangeRate: number
): number {
const chinaInForeign = chinaPriceMax * exchangeRate
return Math.round((1 - chinaInForeign / foreignPriceMin) * 100)
}4. 模块三:聊天实时翻译
4.1 当前聊天架构
- Supabase Realtime(Postgres Changes)
- 消息表:
messages(conversation_id, sender_role, content, message_type) - 客服端:管理后台
/admin/chat,发送中文或英文 - 用户端:
ChatWidget浮窗,匿名或登录用户
4.2 翻译架构设计
用户 (日语) 客服 (中文/英文)
│ │
│ "歯科インプラントの │
│ 費用はいくらですか?" │
│ │
▼ ▼
┌─────────────────────────────────────┐
│ Translation Layer │
│ │
│ 1. 检测来源语言 │
│ 2. 翻译为目标语言 │
│ 3. 缓存翻译结果 │
│ 4. 返回翻译 + 原文 │
└─────────────────────────────────────┘
│ │
▼ ▼
看到日语原文 看到中文翻译 + 英文翻译
+ 英语翻译(可选) + 日语原文(可折叠)4.3 消息流程详解
4.3.1 用户发送消息
1. 用户用日语输入消息
2. 客户端发送到 Supabase messages 表:
{ content: "歯科インプラントの費用は?", sender_role: "visitor" }
3. 服务端触发翻译:
- 调用翻译 API:日语 → 中文 + 英文
- 将翻译结果存入 message_translations 表
4. 客服看到:
- 原文(日语)
- 中文翻译
- 英文翻译(可切换)4.3.2 客服回复消息
1. 客服用中文回复:"种植牙费用约 4000-15000 元"
2. 存入 messages 表:{ content: "...", sender_role: "agent" }
3. 服务端触发翻译:
- 检测用户语言偏好(日语)
- 翻译:中文 → 日语 + 英文
- 存入 message_translations 表
4. 用户看到:
- 日语翻译(主显示)
- 英文翻译(可选)
- "查看原文" 按钮(折叠)4.4 数据库扩展
sql
-- 在 Supabase 中新增(非 Prisma,与现有 chat 表一致)
CREATE TABLE message_translations (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
locale VARCHAR(5) NOT NULL, -- 'zh', 'en', 'ja' 等
content TEXT NOT NULL,
detected_source_locale VARCHAR(5), -- 检测到的原文语言
translation_provider VARCHAR(20), -- 'deepl'
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(message_id, locale)
);
-- 在 conversations 表增加用户语言字段
ALTER TABLE conversations ADD COLUMN visitor_locale VARCHAR(5) DEFAULT 'en';4.5 翻译触发机制
方案 A:API 路由同步翻译(推荐 Phase 1)
typescript
// POST /api/chat/translate
// 消息发送后,前端或 webhook 调用此 API
export async function POST(req: Request) {
const { messageId, targetLocales } = await req.json()
// 1. 获取消息原文
const message = await supabase.from('messages').select('content').eq('id', messageId).single()
// 2. 检测语言
const detectedLocale = await detectLanguage(message.content)
// 3. 翻译到目标语言
for (const locale of targetLocales) {
if (locale === detectedLocale) continue
const translated = await translate(message.content, detectedLocale, locale)
await supabase.from('message_translations').insert({
message_id: messageId,
locale,
content: translated,
detected_source_locale: detectedLocale,
translation_provider: 'deepl',
})
}
return Response.json({ ok: true })
}方案 B:Supabase Edge Function + Database Webhook(推荐 Phase 2)
使用 Supabase Database Webhook 监听 messages 表 INSERT,自动触发 Edge Function 进行翻译。优点是完全自动化,无需前端额外调用。
4.6 前端 UI 变更
4.6.1 用户端聊天窗口
┌─ WellChina Chat ─────────────────────┐
│ │
│ [系统] 您正在排队中... │
│ │
│ 🤵 客服: │
│ インプラントの費用は │ ← 翻译为用户语言
│ 約4,000~15,000元です │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ [查看原文 ▾] │ ← 折叠原文
│ │
│ 🧑 You: │
│ ありがとうございます! │ ← 用户原文
│ │
├─────────────────────────────────────┤
│ [言語: 日本語 ▾] [消息输入...] │ ← 语言选择器
└─────────────────────────────────────┘4.6.2 客服端管理后台
┌─ Admin Chat ────────────────────────────────┐
│ │
│ 👤 Visitor (🇯🇵 日本語): │ ← 显示检测到的语言
│ Original: 歯科インプラントの費用は? │
│ 中文翻译: 牙科种植牙的费用是多少? │
│ English: How much does a dental implant cost? │
│ │
│ 🤵 You: │
│ 种植牙费用约 4000-15000 元 │
│ [已翻译为日语 ✓] │
│ │
├─────────────────────────────────────────────┤
│ [回复语言: 中文 ▾] [消息输入...] │
└─────────────────────────────────────────────┘4.7 语言检测策略
typescript
// 优先级:
// 1. 用户在聊天界面选择的语言
// 2. conversation.visitor_locale(首次连接时记录)
// 3. 页面 URL 的 locale 参数
// 4. 翻译 API 自动检测
async function getVisitorLocale(conversationId: string, pageLocale?: string): Promise<string> {
const conv = await supabase
.from('conversations')
.select('visitor_locale')
.eq('id', conversationId)
.single()
return conv.data?.visitor_locale || pageLocale || 'en'
}5. 翻译服务方案
5.1 翻译架构总览
┌─────────────────────────────────────────────────┐
│ Translation Architecture │
├─────────────────────────────────────────────────┤
│ │
│ Layer 1: 静态 UI 翻译 + 数据库内容 │
│ ├── 方式: Claude 直接翻译 │
│ ├── 时机: 开发阶段一次性完成 + 增量维护 │
│ ├── 费用: $0(开发过程中完成) │
│ └── 优势: 了解产品上下文,医疗术语精准 │
│ │
│ Layer 2: 聊天实时翻译 │
│ ├── 方式: DeepL API Free │
│ ├── 额度: 50 万字符/月(免费) │
│ ├── 延迟: ~200ms │
│ └── 费用: $0 │
│ │
└─────────────────────────────────────────────────┘5.2 DeepL API Free(聊天实时翻译)
服务详情:
| 项目 | 详情 |
|---|---|
| 免费额度 | 50 万字符/月 |
| 注册门槛 | 无需信用卡,直接注册 |
| 支持语言 | 33 种(覆盖所有 Phase 1 + Phase 2 目标语言) |
| API 延迟 | ~200ms |
| 医疗术语质量 | 优秀(DeepL 在专业翻译领域领先) |
| 隐私注意 | 免费版翻译内容可能用于模型训练(聊天内容非敏感医疗数据,可接受) |
| 注册地址 | developers.deepl.com |
额度是否够用:
- 聊天场景:假设日均 50 条消息 × 平均 100 字符 × 2 方向翻译 = 10,000 字符/天 = ~30 万字符/月
- 50 万字符/月的免费额度在初期完全够用
- 如后续增长超出免费额度,再升级 DeepL API Pro($5.49/月起,按量计费 $25/百万字符)
调用方式:
typescript
// 环境变量: DEEPL_API_KEY(Free 账号的 key 以 `:fx` 结尾)
const DEEPL_API_URL = 'https://api-free.deepl.com/v2/translate'
async function translate(text: string, sourceLang: string, targetLang: string): Promise<string> {
const res = await fetch(DEEPL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `DeepL-Auth-Key ${process.env.DEEPL_API_KEY}`,
},
body: JSON.stringify({
text: [text],
source_lang: sourceLang.toUpperCase(),
target_lang: targetLang.toUpperCase(),
}),
})
const data = await res.json()
return data.translations[0].text
}语言检测(内置): DeepL API 支持省略 source_lang 参数,自动检测来源语言并返回 detected_source_language 字段,无需额外调用语言检测服务。
6. 数据库变更汇总
6.1 Prisma Schema 变更
prisma
// === 多语言字段改造 ===
model Procedure {
// 替换 nameEn/nameCn 为 JSONB
name Json // { "en": "...", "zh": "...", "ja": "..." }
subtitle Json?
description Json?
// 保留原字段做过渡,后续移除
nameEn String? @map("name_en") // @deprecated
nameCn String? @map("name_cn") // @deprecated
// 新增关联
countryPrices CountryPriceReference[]
}
model ProcedureCategory {
name Json // { "en": "Dental", "zh": "牙科", "ja": "歯科" }
nameEn String? @map("name_en") // @deprecated
}
model Hospital {
name Json // { "en": "...", "zh": "...", "ja": "..." }
description Json?
nameEn String? @map("name_en") // @deprecated
}
model City {
name Json // { "en": "Shanghai", "zh": "上海", "ja": "上海" }
description Json?
nameEn String? @map("name_en") // @deprecated
}
model Specialty {
name Json
nameEn String? @map("name_en") // @deprecated
}
model Insurance {
name Json
nameEn String? @map("name_en") // @deprecated
}
// === 新增模型 ===
model GuideTranslation {
id String @id @default(uuid())
guideId String @map("guide_id")
locale String
title String
content String @db.Text
guide Guide @relation(fields: [guideId], references: [id])
@@unique([guideId, locale])
@@map("guide_translations")
}
model CountryPriceReference {
id String @id @default(uuid())
procedureId String @map("procedure_id")
countryCode String @map("country_code")
currencyCode String @map("currency_code")
priceMin Int @map("price_min")
priceMax Int @map("price_max")
priceMedian Int? @map("price_median")
source String?
sourceYear Int? @map("source_year")
notes String? @db.Text
updatedAt DateTime @updatedAt @map("updated_at")
procedure Procedure @relation(fields: [procedureId], references: [id])
@@unique([procedureId, countryCode])
@@map("country_price_references")
}
model ExchangeRate {
id String @id @default(uuid())
fromCurrency String @map("from_currency")
toCurrency String @map("to_currency")
rate Float
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([fromCurrency, toCurrency])
@@map("exchange_rates")
}
// 预计算的 CNY→外币转换价格(由 pg_cron 定时任务自动更新)
model ConvertedProcedurePrice {
id String @id @default(uuid())
procedureId String @map("procedure_id")
currencyCode String @map("currency_code") @db.VarChar(3)
symbol String @db.VarChar(5)
priceMin Int? @map("price_min") // CNY min 转换后的外币价格
priceMax Int? @map("price_max") // CNY max 转换后的外币价格
rateUsed Float @map("rate_used") // 使用的汇率(便于溯源)
updatedAt DateTime @updatedAt @map("updated_at")
procedure Procedure @relation(fields: [procedureId], references: [id])
@@unique([procedureId, currencyCode])
@@map("converted_procedure_prices")
}6.2 Supabase 直接 SQL 变更(聊天相关)
sql
-- 聊天翻译表
CREATE TABLE message_translations (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
locale VARCHAR(5) NOT NULL,
content TEXT NOT NULL,
detected_source_locale VARCHAR(5),
translation_provider VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(message_id, locale)
);
-- 会话语言标识
ALTER TABLE conversations ADD COLUMN visitor_locale VARCHAR(5) DEFAULT 'en';
-- 翻译表索引
CREATE INDEX idx_message_translations_message_id ON message_translations(message_id);
-- RLS 策略
ALTER TABLE message_translations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view translations for their conversations"
ON message_translations FOR SELECT
USING (
message_id IN (
SELECT m.id FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE c.visitor_id = auth.uid()
)
);7. 实施路线图
Phase 0:基础设施准备(1 周)
✅ 配置 next-intl routing(routing.ts + navigation.ts + middleware 合并)
✅ 创建 [locale] 路由段,迁移所有页面(17 个路由 git mv 到 [locale]/)
✅ 拆分 root layout(根 layout 仅保留 HTML shell,[locale]/layout.tsx 承载所有 providers)
✅ 更新 middleware:intl 路由检测 + Supabase session + admin/account 鉴权合并
✅ 替换 38 个文件的 next/link → @/i18n/navigation Link
✅ 替换 7 个文件的 usePathname/useRouter → @/i18n/navigation
✅ 所有 23 个 server page 添加 setRequestLocale(locale)
✅ 提取 300+ 硬编码英文字符串到 messages/en.json(400+ keys,20+ namespaces)
✅ 所有页面/组件接入 getTranslations / useTranslations
✅ 实现语言切换 UI 组件(LanguageSwitcher),已集成到 Navbar
✅ 创建 7 个语言占位文件(messages/{zh,ja,ko,ru,id,vi,th}.json)
⏳ 注册 DeepL API Free 账号,获取 API Key(用户进行中)Phase 1a:多语言 UI(2 周)
✅ 数据库 Schema 迁移(JSONB 字段)— 8 个模型添加 JSONB 列(含新增 visaInfo)
✅ 编写数据迁移 SQL(nameEn/nameCn → JSONB name,visaRecommendation → visaInfo)
✅ 实现 getLocalized() / loc() / asLocalized() 工具函数(src/lib/i18n.ts)
✅ Claude 翻译 messages 文件 → 8 种语言各 680+ keys(含 visaFinder 135 keys)
✅ Claude 翻译数据库内容(全量 8 语言):
- 10 个手术分类名称
- 14 个城市名称 + 13 个城市描述
- 10 个专科名称
- 51 项手术名称 + 51 项手术描述 + 51 条签证提示
- 101 家医院描述(zh/ja 完成,ko/ru/id/vi/th 后台进行中)
- 4 篇指南标题 + 摘要 + 完整 Markdown 内容
- 13 个城市交通信息(airportToCity 多语言化)
✅ 更新所有 14 个公共页面查询逻辑(nameEn → JSONB name + loc())
✅ 更新 8+ 共享组件(Compare 全链路、HospitalListWithFilter、HeroSearch、ServiceCTA)
✅ 更新搜索 API 路由(返回 JSONB 字段供前端使用)
✅ 深度提取剩余硬编码文本:
- Contact 页面(form placeholders、plan 选项、CTA 描述)
- 6 个 Chat 组件(ChatHeader/Widget/Window/Input/Message/Sidebar)
- Auth 全链路(Login/Signup/ForgotPassword/UpdatePassword/GoogleSignIn)
- Account 表单(labels、placeholders、save 按钮)
- Plans/PricingCard(plan 名称、tagline、features、CTA 重构为 translation keys)
- Hospital 详情页(PAYMENT_LABELS、BOOKING_LABELS → translation keys)
- VisaFinder 组件(135 个 key,全交互流程国际化)
- UserMenu、SaveButton、CompareButton
✅ SEO:hreflang 标签 + per-page generateMetadata
✅ 构建通过 + 多语言实测验证Phase 1b:多国家价格 + 地区系统(2 周,与 1a 并行后半段)
✅ 实现地区(Region)系统:
✅ 创建 src/lib/regions.ts(10 个地区配置、RegionCode 类型、isValidRegion 验证)
✅ 改造 LanguageSwitcher → 二级选择面板(左列语言 + 右列地区,并排双列下拉)
✅ middleware 读取 NEXT_REGION cookie / ?region= query param → 设置 x-region header
✅ 创建 RegionProvider context + useRegion() hook(客户端组件使用)
✅ 创建 getRegion() server helper(Server Components 通过 headers 读取)
✅ 翻译地区名称到 8 种语言(region.* 共 10 个国家名 + 3 个 UI key)
✅ 创建 CountryPriceReference 和 ExchangeRate 表(Supabase DDL migration)
✅ 采集各国真实医疗价格数据 — 510 行真实数据(51 手术 × 10 国家 + 中国本地),来源包括各国官方费用表、私立医院价目表、医疗旅游平台交叉验证
✅ 编写价格 seed 脚本(seed-country-prices-real.ts + seed-exchange-rates.ts)— 数据结构和导入流程已就绪
✅ 两轮独立价格验证(20% 阈值):
- 第一轮:11 国独立智能体调研 → 发现 70 项问题(13.7%)→ 全部修正
- 第二轮:11 国独立智能体复验 → 发现 47 项残余问题 → 44 项修正
- 最终通过率 ~99.4%(510 条中仅 ~3 条边界争议)
✅ 手术描述升级 — 51 项手术 × 8 种语言,每个描述包含定价因素说明(耗材品牌、技术方案、药品选择、包含/不包含项目)
✅ 创建价格格式化工具库 src/lib/price.ts(formatCny、formatRegionPrice、calculateSavings)
✅ 创建 region 数据查询工具 src/lib/region-data.ts(单条 + 批量查询)
✅ 改造 Pricing 页面 UI(新增地区价格列 + 动态 savings 列)
✅ 改造首页 Step 2 价格对比区域(动态读取 region,替代硬编码 US 对比)
✅ 改造手术详情页("vs ~$X in the US" → 动态 "vs ~{price} in {region}",fallback 旧数据)
✅ 改造手术浏览页 + 分类页(动态 savings badges 替代硬编码 savingsPct)
✅ 改造搜索、对比、医院详情、HospitalListWithFilter(统一使用 formatCny())
✅ 动态计算 savings 百分比(基于 CountryPriceReference + ExchangeRate,fallback 旧 savingsPct)
✅ 汇率自动更新(Supabase pg_cron + http 扩展,不依赖 Vercel):
- 启用 pg_cron + http PostgreSQL 扩展(Supabase 免费版支持)
- 创建 update_exchange_rates_and_prices() PL/pgSQL 函数:
调用 open.er-api.com 免费 API → 更新 exchange_rates 表 → 预计算转换价格
- 注册 pg_cron 定时任务:每日 UTC 06:00(北京时间 14:00)自动执行
- Admin 手动刷新端点 POST /api/admin/exchange-rates(调用同一 PostgreSQL 函数)
- Admin Dashboard 汇率状态卡片(显示最近更新时间 + Refresh Now 按钮)
✅ CNY→用户货币转换价格预计算存储:
- 新建 converted_procedure_prices 表(51 手术 × 10 货币 = 510 行)
- 汇率更新时由 PostgreSQL 函数自动重新计算,前端零运行时计算
- 新增 getBatchConvertedPrices / getConvertedPrice 查询函数
- 新增 formatConvertedCny() 格式化函数 + ConvertedPrice 组件
✅ 所有 9 个价格显示位置添加货币转换显示:
- Pricing 表格、Procedures 列表/分类/详情、Hospital 详情、Compare 对比、Search 搜索、首页 Step 2
- HospitalListWithFilter 客户端组件通过 currencyConversion prop 支持动态转换
- JPY 使用 "JPY" 前缀避免与 CNY "¥" 符号冲突
✅ 构建通过 + lint 通过 + 多地区多语言实测验证(EN+US、JA+JP、EN+GB 组合均正常)Phase 1c:聊天翻译(1.5 周,在 1a/1b 之后)
✅ 集成 DeepL API Free(配置环境变量 DEEPL_API_KEY)
- src/lib/deepl.ts:translate() + translateToMultiple(),支持自动语言检测
- DeepL Free 端点自动识别(key 以 :fx 结尾 → api-free.deepl.com)
- 语言代码映射:next-intl locale ↔ DeepL 大写代码
✅ 创建 message_translations 表
- Supabase migration:message_translations 表 + conversations.visitor_locale 列
- RLS 策略:通过 SECURITY DEFINER 函数绕过嵌套 RLS 问题
- GRANT SELECT 给 authenticated 角色(migration 创建的表不自动 GRANT)
- 已加入 supabase_realtime publication
✅ 实现翻译 API 路由(/api/chat/translate)
- POST /api/chat/translate:通用翻译入口,接收 messageId + targetLocales
- Agent 消息:服务端直接触发翻译(在 admin messages POST 路由内),不依赖前端
- Visitor 消息:前端 useChat 触发 → 翻译为 zh + en 供客服阅读
✅ 修改 useChat hook:发送后触发翻译
- visitor 发消息后异步调用 /api/chat/translate(目标:zh + en)
- agent 消息到达后延迟 2s/5s 拉取翻译(服务端翻译需要时间)
- 加载会话时批量拉取所有消息的翻译
- 修复 Supabase 客户端单例问题(createClient 每次返回新实例导致 Realtime 订阅不断重建)
✅ 修改 ChatMessage 组件:显示翻译 + 原文折叠
- 对方消息:优先显示用户 locale 的翻译,"View original" 按钮切换原文
- 自己的消息:始终显示原文
- 翻译不可用时静默降级,只显示原文
✅ 修改 Admin Chat:显示多语言翻译
- 会话列表:显示 visitor 语言标识(🇯🇵 等 emoji flag)
- Visitor 消息:原文下方直接内联显示中文 + 英文翻译(不折叠)
- Agent 消息:显示翻译状态 "Translated to 🇯🇵 ✓",可展开查看各语言译文
- 会话头部:显示 visitor 语言(如 "🇯🇵 日本語")
✅ 实现语言检测 + 会话语言记录
- 首次创建会话时记录 visitor_locale(从页面 URL locale 获取)
- locale 变化时自动更新 conversations.visitor_locale
- DeepL 自动检测源语言(省略 source_lang 参数)
✅ 测试多语言聊天端到端流程
- DeepL API 调通(ja→zh "こんばんは"→"晚上好")
- 翻译写入 message_translations 表 ✓
- 用户端读取翻译(RLS + GRANT 修复后)✓
- Realtime 消息实时推送(修复客户端单例后)✓Phase 2:扩展(后续迭代)
□ 新增 6 种语言(fr, de, es, it, ar, pt)
□ RTL 支持(阿拉伯语)
□ Supabase Edge Function 自动翻译(替代前端触发)
□ 补充更多国家和手术的价格数据
□ Admin 后台翻译管理界面
□ DeepL 额度不足时评估升级 Pro 或替代方案时间总览
Phase 0: ████░░░░░░░░░░░░░░░░ Week 1
Phase 1a: ░░░░████████░░░░░░░░ Week 2-3
Phase 1b: ░░░░░░░░████████░░░░ Week 3-4 (部分并行)
Phase 1c: ░░░░░░░░░░░░░░██████ Week 5-6
─────────────────────────────────────────
Total Phase 1: ~6 weeks8. 风险与缓解
| 风险 | 影响 | 概率 | 缓解措施 |
|---|---|---|---|
| 价格数据采集困难 | 中 — 某些国家数据缺失 | 高 | 允许部分国家"暂无数据",持续补充 |
[locale] 迁移破坏现有路由 | 高 — 全站 404 | 低 | 渐进式迁移 + localePrefix: 'as-needed' 保持 /en/ 不变 |
| DeepL API Free 额度耗尽 | 中 — 聊天翻译暂停 | 低(初期) | 监控用量,超出时升级 Pro($5.49/月起) |
| 汇率波动影响 savings 显示 | 低 — 数据不实时 | 中 | 显示汇率更新时间 + 免责声明 |
| Supabase Realtime + 翻译延迟叠加 | 中 — 聊天体验差 | 低 | 先显示原文,翻译到达后追加 |
关键决策点
- JSONB vs 每语言独立列: 选 JSONB — 扩展性好,无 migration 负担
- 路由方案(path vs subdomain): 选 path prefix — SEO 好,部署无平台绑定
- 静态翻译方式: Claude 直接翻译 — 了解产品上下文,医疗术语准确,一步到位
- 聊天实时翻译: DeepL API Free — 50 万字符/月免费,无需信用卡,质量优秀
- 价格货币显示: 当地货币为主,可切换 USD 统一视图
- 语言与地区分离: 语言(Locale)仅影响 UI 文本,地区(Region)独立控制价格对比国家和货币。两者通过独立 cookie 持久化(
NEXT_LOCALE/NEXT_REGION),不互相推断。不依赖 IP Geolocation,默认地区 US
附录 A:翻译 Key 命名规范
{namespace}.{section}.{element}
示例:
hospitals.filter.city_label → "Filter by City"
procedures.card.price_range → "Price Range"
pricing.comparison.save_percent → "Save {pct}%"
chat.translation.view_original → "View Original"
chat.translation.auto_detected → "Auto-detected: {language}"9. 设计变更记录
相较于原始方案,实施过程中做了以下调整:
| 变更项 | 原方案 | 实际实现 | 原因 |
|---|---|---|---|
| JSONB 字段策略 | 替换 nameEn/nameCn 为 JSONB | 新增 JSONB 字段,保留旧字段 | 避免 admin 后台和 seed 脚本大规模重构,渐进迁移更安全 |
| 签证提示 | 保持 visaRecommendation 原字段 | 新增 visaInfo JSONB 字段 | 36 种不同值无法用 translation key 覆盖,需要 DB 级国际化 |
| 城市交通信息 | transportationInfo.airportToCity 保持纯字符串 | 改为多语言对象 { en: "...", zh: "..." } | 包含交通方式等需翻译的内容 |
| Plans 数据结构 | plans.ts 保持硬编码字符串 | 重构为 nameKey/taglineKey/featureKeys 引用 translation keys | Plan 名称、功能列表、CTA 都需要翻译 |
| 翻译 key 数量 | 预估 ~400 keys | 实际 680+ keys(含 visaFinder 135 keys) | 深度国际化发现更多嵌套文本 |
| 数据库 push | 使用 prisma db push | 使用 Supabase DDL migration | Supabase 的 auth schema 交叉引用导致 db push 报错 |
| 翻译触发方式 | 前端统一触发(方案 A) | 混合模式:agent 消息服务端触发,visitor 消息前端触发 | 前端触发 agent 翻译不可靠(依赖 admin 页面 JS),服务端直接在 POST route 内调 DeepL 更稳定 |
| 翻译 Realtime 推送 | 监听 message_translations INSERT | 延迟轮询(2s/5s 后拉取) | 无 filter 的 Realtime 监听干扰消息频道,改为收到消息后延迟拉取翻译 |
| 用户端聊天语言选择器 | ChatInput 内置语言 dropdown | 未实现,使用页面 locale 自动推断 | 页面 locale 已足够准确,额外 UI 增加复杂度但收益低,Phase 2 可按需添加 |
| Admin 翻译展示 | 折叠式"Translations"按钮 | visitor 消息内联显示中英翻译,agent 消息折叠 | 客服最常用操作是阅读 visitor 消息,内联显示减少点击次数 |
10. 已知限制与待优化
| 项目 | 状态 | 说明 |
|---|---|---|
| 搜索仍基于 nameEn/nameCn | 已知限制 | JSONB 全文搜索需 GIN 索引,当前搜索仅支持英文/中文关键词,其他语言 fallback 到英文 |
| 排序仍基于 nameEn | 已知限制 | orderBy: { nameEn: 'asc' } 不变,英文字母序对所有语言可接受 |
| Admin 后台保持英文 | 有意设计 | Admin 页面和 API 继续使用 nameEn/nameCn 旧字段,不做国际化 |
| 旧字段未删除 | 过渡期 | nameEn/nameCn/descriptionEn 等旧字段保留,JSONB 字段与之并存,待 admin 后台迁移后清理 |
| 医院名称翻译 | 部分完成 | JSONB name 字段有 en/zh,其他语言目前 fallback 到英文(专有名词多数语言也用英文) |
| .next 缓存问题 | 开发环境 | 文件移动/大量编辑后 dev server 需要 rm -rf .next 重启,否则出现 @formatjs 模块找不到的错误 |
| 消息文件同步 | 需注意 | 添加新 key 后必须同步到所有 8 个 locale 文件,曾因 cp en.json 覆盖已翻译内容 |
| 价格数据经两轮独立验证 | Phase 1b | 510 行真实价格来自 CMS/GOÄ/HIRA/MOH 等官方源,经两轮独立验证(20% 阈值),最终通过率 ~99.4% |
| 汇率手动更新 | Phase 1b | ExchangeRate 表由 seed 填充固定汇率,未实现自动刷新 Cron Job,种子数据已够初期使用 |
| domestic/imported 区分 | Phase 1b | Botox/HA Filler 的"国产/进口"区分仅适用于中国、韩国、俄罗斯等市场;在 SG、AU、GB 等市场没有中国国产品牌,这些条目实际代表"标准/高端"品牌分层 |
| savingsPct 旧字段保留 | 过渡期 | 动态计算 savings 优先,无 CountryPriceReference 数据时 fallback 到旧 savingsPct 字段 |
| priceUsComparison 旧字段 | 待清理 | seed 中部分旧 priceUsComparison 值为 chargemaster 价格而非自费价,已被 CountryPriceReference 系统替代,待后续清理 |
| Region 切换触发全页刷新 | 有意设计 | setRegion() 设置 cookie 后 window.location.reload(),因 region 切换不频繁,全刷新比 Suspense 方案简单可靠 |
| Supabase 客户端单例 | Phase 1c 修复 | createClient() 原本每次返回新实例,导致 Realtime 订阅不断重建、消息丢失。改为浏览器端单例 |
| DeepL 不支持 vi/th | 已知限制 | DeepL Free 不支持越南语和泰语翻译,这两个语言的聊天翻译会静默失败,只显示原文 |
| 聊天翻译延迟 | 有意设计 | agent 翻译在服务端异步执行(~1-2s),visitor 端收到消息后 2s/5s 两次拉取翻译,体验上先看原文再出翻译 |
10.1 设计变更:语言与地区分离(2026-04-15)
问题: 原方案(3.5.1)通过语言推断用户国家(ja → JP,zh → US),但语言 ≠ 地区。在美国的日本人、在中国的中国人、在澳洲的中国留学生,都无法被正确推断。
变更:
- 语言(Locale)和地区(Region)拆分为两个独立维度
- 各自有独立的 cookie 持久化(
NEXT_LOCALE/NEXT_REGION) - 语言切换器升级为二级面板(Language + Region 并列选择)
- Region 决定价格对比国家和货币,Language 只决定 UI 文本
- 不使用 IP Geolocation,默认地区 US(全球基准)
- 不含 CN 地区(目标用户均为海外用户,中文选项面向海外华裔)
10.2 Phase 1b 实施记录(2026-04-15)
新增文件:
| 文件 | 用途 |
|---|---|
src/lib/regions.ts | 10 个地区配置常量、RegionCode 类型、cookie/header 名称 |
src/lib/price.ts | formatCny()、formatRegionPrice()、calculateSavings() 工具函数 |
src/lib/region-server.ts | Server Component 读取 region 的 getRegion() helper |
src/lib/region-data.ts | 单条/批量查询 CountryPriceReference + ExchangeRate |
src/contexts/RegionContext.tsx | RegionProvider + useRegion() hook(客户端组件) |
prisma/seed/seed-exchange-rates.ts | 10 条 CNY 汇率种子数据 |
prisma/seed/seed-country-prices.ts | 509 条国家价格种子数据(51 手术 × 10 国家) |
修改文件:
| 文件 | 变更 |
|---|---|
prisma/schema.prisma | 新增 CountryPriceReference + ExchangeRate 模型 |
src/middleware.ts | 读取 NEXT_REGION cookie / ?region= 参数 → 设置 x-region header |
src/app/[locale]/layout.tsx | 包裹 RegionProvider |
src/components/layout/LanguageSwitcher.tsx | 重写为双列面板(语言 + 地区) |
src/app/[locale]/pricing/page.tsx | 新增地区价格列 + 动态 savings |
src/app/[locale]/procedures/[category]/[slug]/page.tsx | region-aware "vs ~{price} in {region}" |
src/app/[locale]/procedures/page.tsx | 动态 savings badges |
src/app/[locale]/procedures/[category]/page.tsx | 动态 savings badges |
src/app/[locale]/search/page.tsx | formatCny() 替换内联格式化 |
src/app/[locale]/compare/page.tsx | formatCny() 替换内联格式化 |
src/app/[locale]/hospitals/[slug]/page.tsx | formatCny() 替换内联格式化 |
src/components/ui/HospitalListWithFilter.tsx | formatCny() 替换内联格式化 |
messages/*.json(全部 8 个) | 新增 region 命名空间 + pricing/procedureDetail 新 keys |
与原方案的差异:
| 变更项 | 原方案 | 实际实现 | 原因 |
|---|---|---|---|
| 价格数据来源 | 手动采集各国官方数据 | 基于现有 priceUsComparison × 国家乘数自动生成 | 快速填充数据验证全链路,后续逐步替换真实数据 |
| 价格列字段类型 | INT4 | INT4(跳过溢出行) | KRW 等大数值货币在极高价手术时溢出,seed 自动跳过 |
| 汇率更新 | Cron Job 自动更新 | 种子数据固定汇率 | 初期够用,Admin 刷新端点作为后续优先项 |
| Region 切换方式 | router.refresh() + Suspense | cookie + window.location.reload() | Region 切换不频繁,全刷新更简单可靠 |
| 配置文件位置 | src/config/regions.ts | src/lib/regions.ts | 与项目现有 src/lib/ 工具文件风格一致 |
10.3 Phase 1c 实施记录(2026-04-16)
新增文件:
| 文件 | 用途 |
|---|---|
src/lib/deepl.ts | DeepL API 封装:translate() 单语言翻译 + translateToMultiple() 批量翻译,自动语言检测 |
src/app/api/chat/translate/route.ts | 通用翻译 API,接收 messageId + targetLocales,调用 DeepL 并写入 message_translations |
修改文件:
| 文件 | 变更 |
|---|---|
src/lib/supabase/client.ts | 改为浏览器端单例(修复 Realtime 订阅不断重建) |
src/hooks/useChat.ts | 新增翻译触发 + 翻译状态管理 + fetchTranslations,导出 translations |
src/components/chat/ChatMessage.tsx | 支持翻译显示 + "View original" 切换,改为客户端组件 |
src/components/chat/ChatWindow.tsx | 接收并传递 translations prop |
src/components/chat/ChatWidget.tsx | 传递 translations 到 ChatWindow |
src/app/admin/(dashboard)/chat/page.tsx | visitor 消息内联翻译、visitor 语言标识、agent 翻译状态 |
src/app/api/admin/chat/conversations/[id]/route.ts | GET 返回 { messages, translations } 格式 |
src/app/api/admin/chat/conversations/[id]/messages/route.ts | agent 消息发送后服务端触发翻译 |
src/app/api/admin/chat/conversations/route.ts | 返回 visitor_locale 字段 |
.env.example | 新增 DEEPL_API_KEY |
messages/*.json(全部 8 个) | 新增 chat.translation.* 翻译 keys |
Supabase 变更(4 个 migration):
| Migration | 内容 |
|---|---|
add_chat_translations | 创建 message_translations 表 + conversations.visitor_locale 列 + Realtime publication |
add_message_translations_rls | 启用 RLS + SECURITY DEFINER 函数绕过嵌套 RLS |
fix_message_translations_rls | 重建 RLS 策略使用 SECURITY DEFINER 函数 |
grant_message_translations_access | GRANT SELECT 给 authenticated(migration 创建的表不自动 GRANT) |
与原方案的差异:
| 变更项 | 原方案 | 实际实现 | 原因 |
|---|---|---|---|
| Agent 翻译触发 | 前端异步调 /api/chat/translate | 服务端在 POST route 内直接调 DeepL | 前端触发不可靠,服务端更稳定 |
| 翻译到达方式 | Realtime 监听 message_translations INSERT | 延迟 2s/5s 后主动拉取 | 无 filter 的 Realtime 监听干扰消息频道 |
| ChatInput 语言选择器 | 输入框内嵌语言 dropdown | 未实现,自动使用页面 locale | 收益低,Phase 2 可按需添加 |
| Admin 翻译 UI | 统一折叠式 | visitor 内联 / agent 折叠 | 客服最常用操作是读 visitor 消息,减少点击 |
遇到的问题与解决:
| 问题 | 原因 | 解决 |
|---|---|---|
| message_translations 查询 403 | Supabase migration 创建的表不自动 GRANT | GRANT SELECT TO authenticated |
| RLS 嵌套查询失败 | message_translations RLS 策略中 JOIN messages 被 messages 自身的 RLS 阻断 | 使用 SECURITY DEFINER 函数绕过 |
| 对话消息不实时更新 | createClient() 每次返回新 Supabase 实例,Realtime 订阅不断销毁重建 | 改为浏览器端单例 |
| 翻译未写入数据库 | 历史消息从未触发翻译 API | 新消息正常触发,历史消息可手动补翻译 |
附录 B:医疗术语表示例
| English | 中文 | 日本語 | 한국어 | Русский |
|---|---|---|---|---|
| Dental Implant | 牙科种植牙 | 歯科インプラント | 치과 임플란트 | Зубной имплант |
| LASIK | 准分子激光手术 | レーシック | 라식 | ЛАСИК |
| IVF | 体外受精 | 体外受精 | 체외수정 | ЭКО |
| Cataract Surgery | 白内障手术 | 白内障手術 | 백내장 수술 | Операция катаракты |
| Hip Replacement | 髋关节置换 | 人工股関節置換術 | 고관절 치환술 | Эндопротезирование |
此术语表作为 Claude 翻译时的参考基准,确保所有静态翻译使用一致术语。
附录 C:各国医疗价格参考来源
| 国家 | 官方/权威来源 | URL |
|---|---|---|
| 🇺🇸 US | CMS Medicare Physician Fee Schedule | cms.gov/medicare/payment |
| 🇯🇵 JP | 厚生労働省 診療報酬 | mhlw.go.jp |
| 🇰🇷 KR | 건강보험심사평가원 (HIRA) | hira.or.kr |
| 🇦🇺 AU | Medicare Benefits Schedule | mbsonline.gov.au |
| 🇬🇧 GB | NHS Reference Costs / PHIN | phin.org.uk |
| 🇸🇬 SG | MOH Fee Benchmarks | moh.gov.sg |
| 🇹🇭 TH | 各大私立医院公开价目 | bumrungrad.com |
| 🇮🇳 IN | CGHS Rate List | cghs.gov.in |
| 🇩🇪 DE | GOÄ Gebührenordnung | bundesaerztekammer.de |
| 🇷🇺 RU | ОМС тарифы | rosminzdrav.ru |