Skip to content

WellChina 全面国际化方案

产品调研、设计与实施计划
2026-04-14


目录

  1. 需求总览与目标语言
  2. 模块一:全站多语言支持
  3. 模块二:多国家价格对比体系
  4. 模块三:聊天实时翻译
  5. 翻译服务方案
  6. 数据库变更汇总
  7. 实施路线图
  8. 风险与缓解

1. 需求总览与目标语言

1.1 三大需求

#需求核心挑战
1全站 UI + 内容多语言翻译量大、医疗术语准确性、SEO 多语言路由
2多国家价格对比数据采集(各国医疗价格)、汇率、数据维护
3聊天实时翻译低延迟、低成本、双向翻译

1.2 目标语言(Phase 1 → Phase 2)

Phase 1(8 种语言,覆盖核心市场):

语言代码市场理由
Englishen默认语言,已有
简体中文zh本地运营 + 华裔用户
日本語ja日本医疗旅行大市场,地理近
한국어ko韩国医疗旅行大市场,地理近
Русскийru俄罗斯/中亚用户,中国医疗旅行主要客源
Bahasa Indonesiaid东南亚最大人口国
Tiếng Việtvi越南与中国接壤,医疗旅行需求高
ภาษาไทยth泰国用户对中国医疗有需求

Phase 2(扩展 6 种语言,覆盖欧洲市场):

语言代码市场理由
Françaisfr法语区(法国 + 非洲法语国家)
Deutschde德国/奥地利/瑞士
Españoles西班牙 + 拉美
Italianoit意大利
العربيةar中东市场(RTL 支持)
Portuguêspt巴西 + 葡萄牙

1.3 语言检测优先级

1. URL path prefix(/ja/hospitals)— 最高优先级,明确意图
2. Cookie(NEXT_LOCALE)— 用户曾选择过
3. Accept-Language header — 浏览器语言偏好
4. 默认 en

2. 模块一:全站多语言支持

2.1 当前状态分析

  • next-intl v3.22 已安装,但仅用于英文
  • messages/en.json 包含 ~115 个 UI 翻译 key
  • src/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.json

2.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 层导航、按钮、标签、表单 placeholderClaude 翻译,JSON 文件messages/{locale}.json
半静态内容手术名称、医院名称、城市描述、分类名Claude 翻译,写入 seedPrisma 模型 JSONB 字段
动态内容指南文章(Markdown)、FAQClaude 翻译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 文本 + 数据库内容),不使用机器翻译服务:

  1. Claude 翻译全部 UI 文本: 基于对 WellChina 产品定位、医疗术语、目标用户群体的完整理解,由 Claude 直接生成 messages/{locale}.json,确保翻译在语境中自然、术语准确
  2. Claude 翻译数据库内容: 手术名称、分类名、医院描述、城市描述、指南内容等半静态内容,同样由 Claude 在 seed 脚本中直接提供多语言版本
  3. 持续维护: 新增 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' 或 fallback COALESCE(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 需要多语言化的模型字段

模型字段说明
Procedurename, subtitle, description手术名、副标题、描述
ProcedureCategoryname分类名
Hospitalname, description医院名、描述
Cityname, description城市名、描述
Specialtyname专科名
Insurancename保险名
Guidetitle, 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_LOCALE cookie
  • 地区切换 → 页面不跳转,仅设置 NEXT_REGION cookie + 刷新价格相关组件
  • 语言使用各语言的原生名称(日本語、한국어,不用 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.tsxgenerateMetadata 中自动生成。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 目标用户,选取以下国家进行价格对比:

国家代码货币理由
美国USUSD已有数据,全球价格基准
日本JPJPY核心目标市场
韩国KRKRW核心目标市场
澳大利亚AUAUD英语市场,医疗费用高
英国GBGBP英语市场,NHS 排队长
新加坡SGSGD东南亚医疗中心
泰国THTHB医疗旅行竞争对手
印度ININR医疗旅行竞争对手
德国DEEUR欧洲最大经济体
俄罗斯RURUB核心目标市场

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;

旧字段 priceUsComparisonsavingsPct 可保留一段时间做兼容,后续移除。

3.4 价格数据采集方案

3.4.1 数据来源矩阵

国家主要数据源备选来源
🇺🇸 USCMS Medicare Fee Schedule, Healthcare Bluebook已有 seed data
🇯🇵 JP厚生労働省 診療報酬点数表(1点=10円)Medical tourism agency price lists
🇰🇷 KRHIRA(건강보험심사평가원)公开价目Korean medical tourism portal (visitmedicalkorea.com)
🇦🇺 AUMBS Online (Medicare Benefits Schedule)Private hospital fee surveys
🇬🇧 GBNHS Reference Costs, PHIN (Private Healthcare Info Network)Private hospital quotes
🇸🇬 SGMOH Bill Size benchmarksSingapore medical tourism portal
🇹🇭 THBumrungrad / Bangkok Hospital published pricesMedical tourism comparison sites
🇮🇳 INCGHS/AIIMS rate cardsMedical tourism platforms (Vaidam, Lyfboat)
🇩🇪 DEGOÄ (Gebührenordnung für Ärzte)Medical tourism portals
🇷🇺 RUОМС тарифы / 私立医院价目表Medical tourism agencies

3.4.2 数据采集策略

Phase 1 — 核心手术 × 核心国家(优先级最高):

优先采集用户最关心的手术(前 20 项高频手术)在核心市场国家的价格:

手术类别优先手术
DentalDental Implant, Porcelain Veneer, All-on-4/6
Eye CareLASIK, ICL, Cataract
Health CheckupComprehensive, Executive
FertilityIVF, Egg Freezing
OrthopedicsHip Replacement, Knee Replacement
CosmeticRhinoplasty, Facelift, Liposuction
CardiacCABG, Angioplasty

数据采集方式:

  1. 公开数据源爬取/整理: 政府公开价目表(如日本诊疗报酬点数表、韩国 HIRA)
  2. 医疗旅行平台交叉验证: Patients Beyond Borders, Bookimed, Medical Departures 等
  3. 手动调研验证: 对于无公开数据的国家,通过医疗旅行中介、医院网站获取报价范围
  4. 年度更新: 价格数据标注年份,每年更新一次

数据质量规则:

  • 所有价格必须标注来源和年份
  • 价格范围(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 Components

3.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 2026

3.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 客户端组件通过 currencyConversion prop 接收汇率,动态转换医院特定价格

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 weeks

8. 风险与缓解

风险影响概率缓解措施
价格数据采集困难中 — 某些国家数据缺失允许部分国家"暂无数据",持续补充
[locale] 迁移破坏现有路由高 — 全站 404渐进式迁移 + localePrefix: 'as-needed' 保持 /en/ 不变
DeepL API Free 额度耗尽中 — 聊天翻译暂停低(初期)监控用量,超出时升级 Pro($5.49/月起)
汇率波动影响 savings 显示低 — 数据不实时显示汇率更新时间 + 免责声明
Supabase Realtime + 翻译延迟叠加中 — 聊天体验差先显示原文,翻译到达后追加

关键决策点

  1. JSONB vs 每语言独立列: 选 JSONB — 扩展性好,无 migration 负担
  2. 路由方案(path vs subdomain): 选 path prefix — SEO 好,部署无平台绑定
  3. 静态翻译方式: Claude 直接翻译 — 了解产品上下文,医疗术语准确,一步到位
  4. 聊天实时翻译: DeepL API Free — 50 万字符/月免费,无需信用卡,质量优秀
  5. 价格货币显示: 当地货币为主,可切换 USD 统一视图
  6. 语言与地区分离: 语言(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 keysPlan 名称、功能列表、CTA 都需要翻译
翻译 key 数量预估 ~400 keys实际 680+ keys(含 visaFinder 135 keys)深度国际化发现更多嵌套文本
数据库 push使用 prisma db push使用 Supabase DDL migrationSupabase 的 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 1b510 行真实价格来自 CMS/GOÄ/HIRA/MOH 等官方源,经两轮独立验证(20% 阈值),最终通过率 ~99.4%
汇率手动更新Phase 1bExchangeRate 表由 seed 填充固定汇率,未实现自动刷新 Cron Job,种子数据已够初期使用
domestic/imported 区分Phase 1bBotox/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)通过语言推断用户国家(jaJPzhUS),但语言 ≠ 地区。在美国的日本人、在中国的中国人、在澳洲的中国留学生,都无法被正确推断。

变更:

  • 语言(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.ts10 个地区配置常量、RegionCode 类型、cookie/header 名称
src/lib/price.tsformatCny()formatRegionPrice()calculateSavings() 工具函数
src/lib/region-server.tsServer Component 读取 region 的 getRegion() helper
src/lib/region-data.ts单条/批量查询 CountryPriceReference + ExchangeRate
src/contexts/RegionContext.tsxRegionProvider + useRegion() hook(客户端组件)
prisma/seed/seed-exchange-rates.ts10 条 CNY 汇率种子数据
prisma/seed/seed-country-prices.ts509 条国家价格种子数据(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.tsxregion-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.tsxformatCny() 替换内联格式化
src/app/[locale]/compare/page.tsxformatCny() 替换内联格式化
src/app/[locale]/hospitals/[slug]/page.tsxformatCny() 替换内联格式化
src/components/ui/HospitalListWithFilter.tsxformatCny() 替换内联格式化
messages/*.json(全部 8 个)新增 region 命名空间 + pricing/procedureDetail 新 keys

与原方案的差异:

变更项原方案实际实现原因
价格数据来源手动采集各国官方数据基于现有 priceUsComparison × 国家乘数自动生成快速填充数据验证全链路,后续逐步替换真实数据
价格列字段类型INT4INT4(跳过溢出行)KRW 等大数值货币在极高价手术时溢出,seed 自动跳过
汇率更新Cron Job 自动更新种子数据固定汇率初期够用,Admin 刷新端点作为后续优先项
Region 切换方式router.refresh() + Suspensecookie + window.location.reload()Region 切换不频繁,全刷新更简单可靠
配置文件位置src/config/regions.tssrc/lib/regions.ts与项目现有 src/lib/ 工具文件风格一致

10.3 Phase 1c 实施记录(2026-04-16)

新增文件:

文件用途
src/lib/deepl.tsDeepL 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.tsxvisitor 消息内联翻译、visitor 语言标识、agent 翻译状态
src/app/api/admin/chat/conversations/[id]/route.tsGET 返回 { messages, translations } 格式
src/app/api/admin/chat/conversations/[id]/messages/route.tsagent 消息发送后服务端触发翻译
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_accessGRANT 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 查询 403Supabase migration 创建的表不自动 GRANTGRANT 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
🇺🇸 USCMS Medicare Physician Fee Schedulecms.gov/medicare/payment
🇯🇵 JP厚生労働省 診療報酬mhlw.go.jp
🇰🇷 KR건강보험심사평가원 (HIRA)hira.or.kr
🇦🇺 AUMedicare Benefits Schedulembsonline.gov.au
🇬🇧 GBNHS Reference Costs / PHINphin.org.uk
🇸🇬 SGMOH Fee Benchmarksmoh.gov.sg
🇹🇭 TH各大私立医院公开价目bumrungrad.com
🇮🇳 INCGHS Rate Listcghs.gov.in
🇩🇪 DEGOÄ Gebührenordnungbundesaerztekammer.de
🇷🇺 RUОМС тарифыrosminzdrav.ru

WellChina 内部文档 · 基于 VitePress