Przeglądaj źródła

feat: complete localization and add GTM

chenyu 1 miesiąc temu
rodzic
commit
7ec5446aa0
35 zmienionych plików z 1932 dodań i 220 usunięć
  1. 11 0
      frontend/index.html
  2. 2 2
      frontend/public/js/main.js
  3. 9 3
      frontend/src/components/common/ImageLightbox.tsx
  4. 22 7
      frontend/src/components/common/LocaleSwitcher.tsx
  5. 49 0
      frontend/src/i18n/__tests__/translations.test.ts
  6. 2 16
      frontend/src/i18n/index.ts
  7. 1464 33
      frontend/src/i18n/locales/en/translation.json
  8. 53 7
      frontend/src/i18n/locales/ja/translation.json
  9. 50 8
      frontend/src/i18n/locales/zh/translation.json
  10. 17 6
      frontend/src/index.css
  11. 1 4
      frontend/src/layouts/DashboardLayout.tsx
  12. 1 3
      frontend/src/layouts/PublicLayout.tsx
  13. 6 2
      frontend/src/pages/customer/CustomerSimulationPage.tsx
  14. 7 4
      frontend/src/pages/customer/CustomerSimulationResultPage.tsx
  15. 8 4
      frontend/src/pages/customer/StylistProfilePage.tsx
  16. 7 12
      frontend/src/pages/dashboard/DashboardPage.tsx
  17. 40 34
      frontend/src/pages/dashboard/StylistProfileManagePage.tsx
  18. 8 4
      frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
  19. 3 3
      frontend/src/pages/dashboard/customers/CustomerFormPage.tsx
  20. 9 20
      frontend/src/pages/dashboard/customers/CustomerListPage.tsx
  21. 1 1
      frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx
  22. 1 1
      frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx
  23. 8 4
      frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
  24. 10 6
      frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx
  25. 1 1
      frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx
  26. 1 1
      frontend/src/pages/dashboard/templates/TemplateEditPage.tsx
  27. 4 4
      frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
  28. 18 13
      frontend/src/pages/dashboard/templates/TemplateListPage.tsx
  29. 1 1
      frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  30. 2 2
      frontend/src/pages/public/BetaApplyPage.tsx
  31. 1 1
      frontend/src/pages/public/LandingPage.tsx
  32. 1 1
      frontend/src/pages/public/LoginPage.tsx
  33. 47 0
      frontend/src/shared/__tests__/stylistProfileStore.test.ts
  34. 15 0
      frontend/src/shared/demoContent.ts
  35. 52 12
      frontend/src/shared/stylistProfileStore.ts

+ 11 - 0
frontend/index.html

@@ -1,6 +1,13 @@
 <!doctype html>
 <html lang="zh-CN">
   <head>
+    <!-- Google Tag Manager -->
+    <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
+    new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
+    j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
+    'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
+    })(window,document,'script','dataLayer','GTM-W3KZ8BDD');</script>
+    <!-- End Google Tag Manager -->
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
     <title>Aitori Stylist React Refactor</title>
@@ -13,6 +20,10 @@
     <link rel="stylesheet" href="/css/aitori.css" />
   </head>
   <body>
+    <!-- Google Tag Manager (noscript) -->
+    <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-W3KZ8BDD"
+    height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
+    <!-- End Google Tag Manager (noscript) -->
     <div id="root"></div>
     <script type="module" src="/src/main.tsx"></script>
   </body>

+ 2 - 2
frontend/public/js/main.js

@@ -2412,7 +2412,7 @@
             if (hasPos > -1) {
                 var res = _this.attr('href').substring(hasPos);
                 if (res != '' && res != '#' && $(res).length) {
-                    var refElement = $(_this.attr('href'));
+                    var refElement = $(res);
                     if (refElement.position().top <= (scrollPos + 60) && refElement.position().top + refElement.height() > (scrollPos + 60)) {
                         menuLinks.removeClass('active');
                         _this.addClass('active');
@@ -4026,4 +4026,4 @@ function initMap() {
             });
         }
     });
-}
+}

+ 9 - 3
frontend/src/components/common/ImageLightbox.tsx

@@ -1,4 +1,5 @@
 import { useEffect, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
 
 type ImageLightboxProps = {
   image: string | null
@@ -8,6 +9,11 @@ type ImageLightboxProps = {
 }
 
 export function ImageLightbox({ image, alt = '', onClose, action }: ImageLightboxProps) {
+  const { t } = useTranslation()
+  const translated = (key: string, fallback: string, values?: Record<string, string>) => {
+    const value = t(key, values)
+    return value === key ? fallback : value
+  }
   useEffect(() => {
     if (!image) return undefined
     const handleKeyDown = (event: KeyboardEvent) => {
@@ -25,10 +31,10 @@ export function ImageLightbox({ image, alt = '', onClose, action }: ImageLightbo
   if (!image) return null
 
   return (
-    <div className="aitori-image-lightbox" role="dialog" aria-modal="true" aria-label={alt || '图片预览'} onClick={onClose}>
-      <button type="button" aria-label="关闭图片预览" onClick={onClose}><i className="feather icon-feather-x"></i></button>
+    <div className="aitori-image-lightbox" role="dialog" aria-modal="true" aria-label={alt || translated('common.imagePreview', '图片预览')} onClick={onClose}>
+      <button type="button" aria-label={translated('common.closeImagePreview', '关闭图片预览')} onClick={onClose}><i className="feather icon-feather-x"></i></button>
       <div className="aitori-image-lightbox-content" onClick={(event) => event.stopPropagation()}>
-        <img src={image} alt={alt ? `${alt} 放大预览` : '图片放大预览'} />
+        <img src={image} alt={alt ? translated('common.enlargedImage', `${alt} 放大预览`, { name: alt }) : translated('common.enlargedImageFallback', '图片放大预览')} />
         {action ? <div className="aitori-image-lightbox-action">{action}</div> : null}
       </div>
     </div>

+ 22 - 7
frontend/src/components/common/LocaleSwitcher.tsx

@@ -1,17 +1,32 @@
-import React from 'react'
 import { Link, useLocation } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
 import type { Locale } from '../../shared/pageContext'
 
 export function LocaleSwitcher({ locale }: { locale: Locale }) {
   const location = useLocation()
+  const { t } = useTranslation()
   const locales: Locale[] = ['zh', 'ja', 'en']
-  const nextLocale = locales[(locales.indexOf(locale) + 1) % locales.length]
-  const localeLabel: Record<Locale, string> = { zh: '中文', ja: '日本語', en: 'English' }
-  const targetPath = location.pathname.replace(/^\/(zh|ja|en)/, `/${nextLocale}`)
+  const localeLabel: Record<Locale, string> = { zh: '中', ja: '日', en: 'EN' }
+  const fullLocaleLabel: Record<Locale, string> = { zh: '中文', ja: '日本語', en: 'English' }
 
   return (
-    <Link className="locale-switcher" to={targetPath || `/${nextLocale}`}>
-      {localeLabel[nextLocale]}
-    </Link>
+    <nav className="locale-switcher" aria-label={t('switchLocale')}>
+      {locales.map((item) => {
+        const pathname = location.pathname.replace(/^\/(zh|ja|en)/, `/${item}`) || `/${item}`
+        const target = `${pathname}${location.search}${location.hash}`
+        return (
+          <Link
+            key={item}
+            className={`locale-switcher-option${item === locale ? ' is-active' : ''}`}
+            to={target}
+            lang={item === 'zh' ? 'zh-CN' : item}
+            aria-label={fullLocaleLabel[item]}
+            aria-current={item === locale ? 'page' : undefined}
+          >
+            {localeLabel[item]}
+          </Link>
+        )
+      })}
+    </nav>
   )
 }

+ 49 - 0
frontend/src/i18n/__tests__/translations.test.ts

@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import zh from '../locales/zh/translation.json'
+import ja from '../locales/ja/translation.json'
+import en from '../locales/en/translation.json'
+
+type TranslationValue = string | number | boolean | null | TranslationValue[] | { [key: string]: TranslationValue }
+
+function flatten(value: TranslationValue, prefix = '', result = new Map<string, TranslationValue>()) {
+  if (Array.isArray(value)) {
+    value.forEach((item, index) => flatten(item, `${prefix}[${index}]`, result))
+  } else if (value && typeof value === 'object') {
+    Object.entries(value).forEach(([key, item]) => flatten(item, prefix ? `${prefix}.${key}` : key, result))
+  } else {
+    result.set(prefix, value)
+  }
+  return result
+}
+
+function placeholders(value: TranslationValue | undefined) {
+  return typeof value === 'string'
+    ? [...value.matchAll(/\{\{[^}]+\}\}/g)].map(([match]) => match).sort()
+    : []
+}
+
+describe('translation resources', () => {
+  const resources = { zh, ja, en } as const
+  const flattened = Object.fromEntries(
+    Object.entries(resources).map(([locale, value]) => [locale, flatten(value as TranslationValue)]),
+  ) as Record<keyof typeof resources, Map<string, TranslationValue>>
+
+  it.each(['ja', 'en'] as const)('%s contains every base translation entry', (locale) => {
+    const missing = [...flattened.zh.keys()].filter((key) => !flattened[locale].has(key))
+    expect(missing).toEqual([])
+  })
+
+  it.each(['ja', 'en'] as const)('%s preserves interpolation placeholders', (locale) => {
+    const mismatches = [...flattened.zh.entries()]
+      .filter(([key, value]) => placeholders(value).join('|') !== placeholders(flattened[locale].get(key)).join('|'))
+      .map(([key]) => key)
+    expect(mismatches).toEqual([])
+  })
+
+  it('does not contain untranslated CJK copy in the English resource', () => {
+    const untranslated = [...flattened.en.entries()]
+      .filter(([, value]) => typeof value === 'string' && /[\u3040-\u30ff\u3400-\u9fff]/.test(value))
+      .map(([key]) => key)
+    expect(untranslated).toEqual([])
+  })
+})

+ 2 - 16
frontend/src/i18n/index.ts

@@ -5,20 +5,6 @@ import zhTranslation from './locales/zh/translation.json'
 import jaTranslation from './locales/ja/translation.json'
 import enTranslation from './locales/en/translation.json'
 
-function mergeTranslation(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
-  const merged: Record<string, unknown> = { ...base }
-
-  for (const [key, value] of Object.entries(override)) {
-    const baseValue = merged[key]
-    merged[key] = value && baseValue && typeof value === 'object' && !Array.isArray(value)
-      && typeof baseValue === 'object' && !Array.isArray(baseValue)
-      ? mergeTranslation(baseValue as Record<string, unknown>, value as Record<string, unknown>)
-      : value
-  }
-
-  return merged
-}
-
 const resources = {
   zh: {
     translation: zhTranslation,
@@ -27,7 +13,7 @@ const resources = {
     translation: jaTranslation,
   },
   en: {
-    translation: mergeTranslation(zhTranslation, enTranslation),
+    translation: enTranslation,
   },
 }
 
@@ -36,7 +22,7 @@ i18n
   .use(initReactI18next)
   .init({
     resources,
-    fallbackLng: 'zh',
+    fallbackLng: false,
     supportedLngs: ['zh', 'ja', 'en'],
     defaultNS: 'translation',
     interpolation: {

Plik diff jest za duży
+ 1464 - 33
frontend/src/i18n/locales/en/translation.json


+ 53 - 7
frontend/src/i18n/locales/ja/translation.json

@@ -30,7 +30,33 @@
     "generated": "生成済み",
     "empty": "データがありません",
     "sending": "送信中...",
-    "sessionExpired": "セッションの有効期限が切れました。もう一度シミュレーションを開始してください"
+    "sessionExpired": "セッションの有効期限が切れました。もう一度シミュレーションを開始してください",
+    "imagePreview": "画像プレビュー",
+    "closeImagePreview": "画像プレビューを閉じる",
+    "enlargedImage": "{{name}}の拡大プレビュー",
+    "enlargedImageFallback": "画像の拡大プレビュー"
+  },
+  "systemTemplateNames": {
+    "homeStyling": "ホームスタイリング",
+    "after30Days": "ホームスタイリング 30日後",
+    "after60Days": "ホームスタイリング 60日後"
+  },
+  "profileManagePage": {
+    "title": "プロフィール管理",
+    "badge": "プロフィール",
+    "lead": "各スタイリストには公開プロフィールがあります。お客様はプロフィールや予約情報を確認し、AI ヘアシミュレーションへ進めます。",
+    "back": "ワークベンチに戻る",
+    "viewPublic": "公開プロフィールを見る",
+    "viewOwn": "自分のプロフィールを見る",
+    "copyCode": "お客様用シミュレーションコードをコピー",
+    "inviteLoadFailed": "取得できませんでした",
+    "preview": { "badge": "公開プレビュー", "photoAlt": "{{name}}のプロフィール写真", "published": "公開中", "experience": "経験", "rating": "評価", "reviews": "レビュー" },
+    "summary": { "publicUrl": "公開 URL", "inviteCode": "お客様用コード", "booking": "予約リンク", "configured": "設定済み", "notConfigured": "未設定", "templates": "公開テンプレート", "templateCount": "{{count}}件" },
+    "form": {
+      "badge": "情報を編集", "title": "公開プロフィール", "saved": "プロフィール情報を保存しました。", "photoPreview": "プロフィール写真のプレビュー", "changePhoto": "プロフィール写真を変更",
+      "fields": { "photo": "プロフィール写真", "displayName": "表示名", "slug": "公開プロフィールのパス", "years": "経験年数", "rating": "評価", "reviewCount": "レビュー数", "salonName": "サロン名", "lead": "短い紹介文", "bio": "詳しい紹介文", "bookingUrl": "予約リンク", "address": "住所", "hours": "営業時間", "specialties": "得意な提案" },
+      "submitTitle": "プロフィール情報を保存", "submitLead": "保存すると、現在の言語の公開プロフィールに最新情報が反映されます。"
+    }
   },
   "landing": {
     "badge": "美容師・美容室向け",
@@ -192,6 +218,7 @@
     "description": "Aitori Stylist のスタイリストワークベンチ。新規顧客、AI ヘアシミュレーション、テンプレート、当日予約、フォロー対象、最近の施術記録にすぐアクセスできます。",
     "badge": "ワークベンチ",
     "greeting": "おはようございます",
+    "greetings": { "morning": "おはようございます", "afternoon": "こんにちは", "evening": "こんばんは" },
     "greetingName": "小川さん",
     "summary": "今日の予約、AIヘアシミュレーション、フォロー対象をここからすぐ確認できます。",
     "dateLabel": "2026年6月1日 月曜日",
@@ -291,6 +318,14 @@
     "detail": "タスクを見る",
     "selectAll": "すべて選択",
     "deselectAll": "選択解除",
+    "bulkDelete": "まとめて削除",
+    "delete": "削除",
+    "confirmDelete": "テンプレート「{{name}}」を削除しますか?",
+    "confirmBulkDelete": "選択したテンプレート {{count}}件を削除しますか?",
+    "viewCover": "テンプレート画像を見る",
+    "selectTemplate": "テンプレート {{name}} を選択",
+    "deleteTemplate": "テンプレート {{name}} を削除",
+    "statusBadgeMap": { "pending": "待機中", "processing": "生成中", "failed": "生成失敗" },
     "pagination": { "prev": "前へ", "next": "次へ" }
   },
   "templateFormPage": {
@@ -323,21 +358,24 @@
       "title": "AIモデルを選択",
       "preset": "プリセットモデルを使う",
       "upload": "カスタムモデルをアップロード",
-      "uploadLabel": "モデル画像をアップロード"
+      "uploadLabel": "モデル画像をアップロード",
+      "customPreviewAlt": "カスタムモデルのプレビュー"
     },
     "outfit": {
       "badge": "衣装",
       "title": "服装を選択",
       "preset": "プリセット服装を使う",
       "upload": "カスタム服装をアップロード",
-      "uploadLabel": "服装画像をアップロード"
+      "uploadLabel": "服装画像をアップロード",
+      "customPreviewAlt": "カスタム衣装のプレビュー"
     },
     "background": {
       "badge": "背景",
       "title": "背景を選択",
       "preset": "プリセット背景を使う",
       "upload": "カスタム背景をアップロード",
-      "uploadLabel": "背景画像をアップロード"
+      "uploadLabel": "背景画像をアップロード",
+      "customPreviewAlt": "カスタム背景のプレビュー"
     },
     "submitTitle": "AIヘアテンプレートを生成",
     "submitLead": "送信後、参考画像と選択したモデル・服装・背景をもとにテンプレートを生成します。",
@@ -354,7 +392,10 @@
     },
     "validation": {
       "referenceRequired": "先に参考画像をアップロードしてください",
-      "assetsRequired": "モデル・服装・背景を選択してください"
+      "assetsRequired": "モデル・服装・背景を選択してください",
+      "customModelRequired": "カスタムモデル画像を選択してください",
+      "customOutfitRequired": "カスタム衣装画像を選択してください",
+      "customBackgroundRequired": "カスタム背景画像を選択してください"
     },
     "statusOptions": { "Draft": "下書き", "Published": "公開中" },
     "categoryOptions": { "Long": "ロング", "Medium": "ミディアム", "Short": "ショート", "Color": "カラー" }
@@ -676,7 +717,8 @@
       "titleEdit": "変更を保存",
       "leadNew": "登録後、顧客詳細から AI ヘアシミュレーションへ進めます。",
       "leadEdit": "保存後は顧客詳細へ戻り、更新内容を確認できます。"
-    }
+    },
+    "errors": { "loadFailed": "顧客情報を取得できませんでした" }
   },
   "customerDetailPage": {
     "title": "顧客詳細",
@@ -734,6 +776,7 @@
     "lead": "選択したテンプレートを顧客写真に適用した結果を表示します。各案を微調整したり、今回の施術記録へ保存できます。",
     "anonymousTitle": "匿名シミュレーション",
     "anonymousLead": "このタスクは顧客カルテに紐付いていません。",
+    "demoCustomerName": "山田 美咲",
     "counters": {
       "total": "テンプレート総数",
       "processing": "処理中",
@@ -745,6 +788,7 @@
       "system": "システムテンプレート"
     },
     "card": {
+      "loadingAlt": "生成中",
       "processing": "生成中です。しばらくしてからこの案の結果をご確認ください。",
       "success": "生成が完了しました。この案の仕上がりを確認できます。",
       "failed": "生成に失敗しました。戻って再試行してください。"
@@ -1050,6 +1094,7 @@
     "title": "Aitori Stylist | ヘアスタイルテンプレート", "description": "スタイリストがデザインしたヘアスタイルテンプレートを選び、ご自身の写真で AI ヘアシミュレーションを試せます。", "photoAlt": "スタイリストのプロフィール写真", "templateSuffix": "のヘアスタイルテンプレート", "heroTitle": "あなたの写真で、このスタイリストのヘアスタイルを試してみましょう。", "heroLead": "{{name}} がカウンセリングでよく提案するヘアスタイルを掲載しています。好きなスタイルを選んだ後、ご自身の写真をアップロードして AI で仕上がりイメージを確認できます。", "heroBody": "予約前に長さ、顔まわりのレイヤー、前髪、全体の雰囲気を確認するのに役立ちます。シミュレーション結果は来店時のカウンセリングの参考になります。", "try": "自分の写真で試す", "browse": "ヘアスタイルを見る", "stats": ["経験", "公開テンプレート", "評価"],
     "actions": [{"title":"ヘアスタイルを選ぶ","body":"このスタイリストが公開しているおすすめのスタイルを見られます。"},{"title":"写真をアップロードして試す","body":"あなたの写真で AI シミュレーションを作成します。"},{"title":"スタイリストについて","body":"得意なスタイルやカウンセリングの特徴を確認できます。"}],
     "templates": {"badge":"Style Templates","title":"試したいヘアスタイルを選ぶ","lead":"画像をクリックすると拡大できます。試したいスタイルを決めたら、AI ヘアシミュレーションへ進んで写真をアップロードしてください。","filters":["すべて","ロング","ミディアム","ショート"]},
+    "templateNames": { "natural-long": "ナチュラルロング", "soft-layer": "ソフトレイヤー", "transparent": "透明感ロング", "light-medium": "軽やかミディアム", "face-layer": "顔まわりレイヤー", "natural-collarbone": "ナチュラルロブ", "round-short": "丸みショート", "clean-short": "すっきりショート" },
     "stylist": {"badge":"Stylist","lead":"長さやスタイルで迷っている場合は、まずテンプレートで試してから、結果をカウンセリングにお持ちください。","info":["サロン","営業時間","得意な提案"],"specialty":"顔まわりのレイヤー、ナチュラルロング、すっきりしたショート、扱いやすいスタイリング"},
     "reviews": {"badge":"Voice","title":"お客様の声","items":[{"title":"来店前から安心できた","body":"シミュレーション画像を見てから美容師と相談できたので、希望のイメージを伝えやすかったです。"},{"title":"長さを比べやすい","body":"少し長い場合と短い場合の違いを先に確認でき、迷わずスタイルを決められました。"},{"title":"カウンセリングが具体的に","body":"結果を見ながら相談することで、変えたい部分をスタイリストにも伝えやすくなりました。"}]},
     "cta": {"badge":"AI Try-on","title":"スタイルを選んだら、あなたの写真で試してみましょう。","body":"シミュレーション結果は最終的な仕上がりを保証するものではなく、スタイリストと方向性を合わせるための参考です。","button":"AI ヘアシミュレーションを始める","lightbox":"このスタイルを自分の写真で試す"}
@@ -1371,7 +1416,8 @@
       "remember": "ログイン状態を保持",
       "forgot": "パスワードを忘れた方",
       "submit": "ログインする",
-      "note": "アカウント発行が必要な場合は、Aitori管理者へお問い合わせください。"
+      "note": "アカウント発行が必要な場合は、Aitori管理者へお問い合わせください。",
+      "error": "ログインに失敗しました。メールアドレスとパスワードをご確認ください。"
     }
   }
 }

+ 50 - 8
frontend/src/i18n/locales/zh/translation.json

@@ -30,7 +30,33 @@
     "generated": "已生成",
     "empty": "暂无数据",
     "sending": "发送中...",
-    "sessionExpired": "会话已过期,请重新发起模拟"
+    "sessionExpired": "会话已过期,请重新发起模拟",
+    "imagePreview": "图片预览",
+    "closeImagePreview": "关闭图片预览",
+    "enlargedImage": "{{name}} 放大预览",
+    "enlargedImageFallback": "图片放大预览"
+  },
+  "systemTemplateNames": {
+    "homeStyling": "居家造型",
+    "after30Days": "居家造型 30 天后",
+    "after60Days": "居家造型 60 天后"
+  },
+  "profileManagePage": {
+    "title": "个人主页管理",
+    "badge": "个人主页",
+    "lead": "每个美发师账号都有一个公开个人主页。顾客可以在这里查看资料、预约服务,并进入 AI 发型模拟。",
+    "back": "返回工作台",
+    "viewPublic": "查看公开主页",
+    "viewOwn": "查看自己的主页",
+    "copyCode": "复制顾客模拟代码",
+    "inviteLoadFailed": "获取失败",
+    "preview": { "badge": "公开预览", "photoAlt": "{{name}} 的主页照片", "published": "公开中", "experience": "经验", "rating": "评分", "reviews": "评价" },
+    "summary": { "publicUrl": "公开 URL", "inviteCode": "顾客模拟代码", "booking": "预约入口", "configured": "已设置", "notConfigured": "未设置", "templates": "公开模板", "templateCount": "{{count}} 件" },
+    "form": {
+      "badge": "编辑信息", "title": "公开资料", "saved": "个人主页信息已保存。", "photoPreview": "主页照片预览", "changePhoto": "更换主页照片",
+      "fields": { "photo": "主页照片", "displayName": "显示姓名", "slug": "公开主页路径", "years": "经验年数", "rating": "评分", "reviewCount": "评价数", "salonName": "沙龙名", "lead": "短介绍", "bio": "详细介绍", "bookingUrl": "预约链接", "address": "地址", "hours": "营业时间", "specialties": "擅长提案" },
+      "submitTitle": "保存个人主页信息", "submitLead": "保存后,当前语言的公开主页会使用最新资料;顾客可从主页进入预约和 AI 发型模拟。"
+    }
   },
   "landing": {
     "badge": "面向美发师与沙龙",
@@ -192,6 +218,7 @@
     "description": "Aitori Stylist 美发师工作台。可快速访问新建顾客、AI 发型模拟和发型模板管理。",
     "badge": "工作台",
     "greeting": "早上好",
+    "greetings": { "morning": "早上好", "afternoon": "下午好", "evening": "晚上好" },
     "greetingName": "小川先生",
     "summary": "可在这里快速进入顾客建档、AI发型模拟和发型模板管理。",
     "dateLabel": "2026年6月1日 星期一",
@@ -290,6 +317,14 @@
     "usedCount": "使用 {{count}}次",
     "selectAll": "全选",
     "deselectAll": "取消全选",
+    "bulkDelete": "批量删除",
+    "delete": "删除",
+    "confirmDelete": "确认删除模板「{{name}}」吗?",
+    "confirmBulkDelete": "确认删除已选中的 {{count}} 个模板吗?",
+    "viewCover": "查看模板封面",
+    "selectTemplate": "选择模板 {{name}}",
+    "deleteTemplate": "删除{{name}}",
+    "statusBadgeMap": { "pending": "排队中", "processing": "生成中", "failed": "生成失败" },
     "pagination": { "prev": "上一页", "next": "下一页" }
   },
   "templateFormPage": {
@@ -322,21 +357,24 @@
       "title": "选择AI模特",
       "preset": "使用预设模特",
       "upload": "上传自定义模特",
-      "uploadLabel": "上传模特图片"
+      "uploadLabel": "上传模特图片",
+      "customPreviewAlt": "自定义模特预览"
     },
     "outfit": {
       "badge": "服装",
       "title": "选择服装",
       "preset": "使用预设服装",
       "upload": "上传自定义服装",
-      "uploadLabel": "上传服装图片"
+      "uploadLabel": "上传服装图片",
+      "customPreviewAlt": "自定义服装预览"
     },
     "background": {
       "badge": "背景",
       "title": "选择背景",
       "preset": "使用预设背景",
       "upload": "上传自定义背景",
-      "uploadLabel": "上传背景图片"
+      "uploadLabel": "上传背景图片",
+      "customPreviewAlt": "自定义背景预览"
     },
     "submitTitle": "生成AI发型模板",
     "submitLead": "提交后,将根据参考图片以及所选模特、服装和背景生成发型模板。",
@@ -678,7 +716,8 @@
       "titleEdit": "保存更改",
       "leadNew": "注册后,可从顾客详情进入 AI 发型模拟。",
       "leadEdit": "保存后返回顾客详情页面,即可确认更新内容。"
-    }
+    },
+    "errors": { "loadFailed": "获取顾客信息失败" }
   },
   "customerDetailPage": {
     "title": "顾客详情",
@@ -736,6 +775,7 @@
     "lead": "展示顾客照片套用所选发型模板后的生成结果。可进入各方案微调,或保存到本次施术记录。",
     "anonymousTitle": "匿名模拟",
     "anonymousLead": "当前任务未绑定顾客档案。",
+    "demoCustomerName": "山田美咲",
     "counters": {
       "total": "总模板数",
       "processing": "处理中",
@@ -747,6 +787,7 @@
       "system": "系统模板"
     },
     "card": {
+      "loadingAlt": "生成中",
       "processing": "正在生成中,请稍候查看当前方案效果。",
       "success": "已完成生成,可查看当前方案效果。",
       "failed": "生成失败,请返回后重试。"
@@ -1052,6 +1093,7 @@
     "title": "Aitori Stylist | 发型模板", "description": "选择发型师设计的发型模板,并用自己的照片进行 AI 试发型。", "photoAlt": "发型师头像", "templateSuffix": "的发型模板", "heroTitle": "用你的照片,试试这位发型师设计的发型。", "heroLead": "这里收录了 {{name}} 常用于咨询的发型模板。选择喜欢的发型后,可以上传自己的照片,用 AI 先看看大致效果。", "heroBody": "适合在预约前确认长度、脸周层次、刘海和整体氛围。模拟结果可作为到店咨询时的参考。", "try": "用我的照片试发型", "browse": "先看发型模板", "stats": ["经验", "公开模板", "评分"],
     "actions": [{"title":"选择发型模板","body":"浏览这位发型师公开的推荐发型。"},{"title":"上传照片试发型","body":"用你的照片生成 AI 模拟效果。"},{"title":"了解发型师","body":"查看擅长风格和咨询特点。"}],
     "templates": {"badge":"Style Templates","title":"选择你想尝试的发型","lead":"点击图片可以放大查看。决定想试哪一款后,进入 AI 试发型流程,上传自己的照片进行模拟。","filters":["全部","长发","中长发","短发"]},
+    "templateNames": { "natural-long": "自然长发", "soft-layer": "柔和层次", "transparent": "通透感长发", "light-medium": "轻盈中长发", "face-layer": "脸周层次", "natural-collarbone": "自然锁骨发", "round-short": "圆润短发", "clean-short": "清爽短发" },
     "stylist": {"badge":"Stylist","lead":"如果你还不确定适合什么长度或风格,可以先用模板模拟,再把结果带到咨询里一起讨论。","info":["沙龙","营业时间","擅长"],"specialty":"脸周层次、自然长发、清爽短发、易打理造型"},
     "reviews": {"badge":"Voice","title":"顾客反馈","items":[{"title":"到店前就比较安心","body":"先看过模拟图,再和发型师讨论,比较容易说出自己想要的感觉。"},{"title":"更容易比较长度","body":"长一点、短一点的差别可以先看效果,决定发型时不会那么犹豫。"},{"title":"咨询更具体","body":"拿着模拟结果沟通,发型师也更容易理解我想改变的地方。"}]},
     "cta": {"badge":"AI Try-on","title":"选好发型后,用你的照片试试看。","body":"模拟结果不是最终承诺,而是帮助你和发型师更快对齐方向的参考。","button":"开始 AI 试发型","lightbox":"用我的照片试这款"}
@@ -1092,8 +1134,7 @@
         "示例主页",
         "常见问题",
         "咨询",
-        "登录",
-        "开发规格"
+        "登录"
       ],
       "languageItems": [
         {
@@ -1374,7 +1415,8 @@
       "remember": "保持登录状态",
       "forgot": "忘记密码?",
       "submit": "登录管理后台",
-      "note": "如需开通账号,请联系 Aitori 管理员。"
+      "note": "如需开通账号,请联系 Aitori 管理员。",
+      "error": "登录失败,请检查邮箱和密码后重试。"
     }
   }
 }

+ 17 - 6
frontend/src/index.css

@@ -204,29 +204,40 @@ textarea {
 
 .site-nav a.active,
 .site-nav a:hover,
-.locale-switcher:hover {
+.locale-switcher-option:hover,
+.locale-switcher-option.is-active {
   background: rgba(230, 93, 70, 0.12);
   color: #e65d46;
 }
 
 .locale-switcher {
-  width: auto;
-  min-width: 0;
   min-height: 32px;
   height: 32px;
-  padding: 0 14px;
+  padding: 2px;
   border-radius: 999px;
-  border: 1px solid var(--dark-gray);
+  border: 1px solid rgba(44, 46, 60, 0.35);
   background: #fff;
   display: inline-flex;
   align-items: center;
   justify-content: center;
+  gap: 1px;
+  white-space: nowrap;
+}
+
+.locale-switcher-option {
+  min-width: 30px;
+  height: 26px;
+  padding: 0 7px;
+  border-radius: 999px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
   color: var(--dark-gray);
   font-size: 13px;
   font-weight: 700;
   line-height: 1;
   text-decoration: none;
-  white-space: nowrap;
+  transition: color 0.2s ease, background-color 0.2s ease;
 }
 
 .hero-section,

+ 1 - 4
frontend/src/layouts/DashboardLayout.tsx

@@ -8,10 +8,7 @@ import { LocaleSwitcher } from '../components/common/LocaleSwitcher'
 
 function DashboardHeader() {
   const { locale, navigate, path, copy } = usePageContext()
-  const nav = copy.dashboardNav
-  const navCopy = locale === 'zh'
-    ? { home: '首页', customers: '顾客', templates: '发型模板', logout: '退出登录' }
-    : nav
+  const navCopy = copy.dashboardNav
 
   const handleLogout = async (event: React.MouseEvent) => {
     event.preventDefault()

+ 1 - 3
frontend/src/layouts/PublicLayout.tsx

@@ -12,9 +12,7 @@ function PublicHeader() {
   const customerSimulationPath = path('/customer-simulation')
   const isCustomerSimulation = location.pathname.startsWith(customerSimulationPath)
   const pageCopy = copy.publicLayout
-  const navCopy = locale === 'zh'
-    ? { profile: '美发师主页', templates: '发型参考', simulation: 'AI发型试试看', reviews: '评价' }
-    : pageCopy.nav
+  const navCopy = pageCopy.nav
 
   return (
     <header>

+ 6 - 2
frontend/src/pages/customer/CustomerSimulationPage.tsx

@@ -10,6 +10,7 @@ import {
   uploadMedia,
 } from '../../services/api/simulations'
 import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
+import { localizeSystemTemplateName } from '../../shared/demoContent'
 
 export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
 
@@ -50,7 +51,10 @@ export function CustomerSimulationPage() {
       setCodeChecked(true)
       setStylistName(data.stylist.name)
       setStylistTemplates(data.stylistTemplates)
-      setSystemTemplates(data.systemTemplates)
+      setSystemTemplates(data.systemTemplates.map((template) => ({
+        ...template,
+        templateName: localizeSystemTemplateName(template.templateName, copy.systemTemplateNames),
+      })))
       setSource('stylist')
       setSelectedTemplates([])
     } catch (err) {
@@ -91,7 +95,7 @@ export function CustomerSimulationPage() {
 
   return (
     <PublicLayout footer={false}>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
       <main className="stylist-workbench">
         <section className="stylist-workbench-top ipad-top-space-margin mb-2"><div className="container">
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={path('/profile')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link><button type="submit" form="customer-simulation-form" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? pageCopy.submitting : pageCopy.viewResult}</button></div>

+ 7 - 4
frontend/src/pages/customer/CustomerSimulationResultPage.tsx

@@ -7,6 +7,7 @@ import type { AiHairSimulationDetail } from '../../services/api/contracts'
 import { getPublicSimulationById } from '../../services/api/simulations'
 import { usePageContext } from '../../shared/pageContext'
 import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
+import { localizeSystemTemplateName } from '../../shared/demoContent'
 
 export function CustomerSimulationResultPage() {
   const { locale, path, copy } = usePageContext()
@@ -49,18 +50,20 @@ export function CustomerSimulationResultPage() {
     task.resultImages.map((image, index) => ({
       id: `${task.id}-${index}`,
       image,
-      title: task.templateName,
+      title: task.templateSource === 'SYSTEM'
+        ? localizeSystemTemplateName(task.templateName, copy.systemTemplateNames)
+        : task.templateName,
       source: task.templateSource,
     })),
-  ) ?? [], [detail])
+  ) ?? [], [copy.systemTemplateNames, detail])
 
   const createdAt = detail?.createdAt
-    ? new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(detail.createdAt))
+    ? new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : locale === 'en' ? 'en-US' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(detail.createdAt))
     : '--'
 
   return (
     <PublicLayout footer={false}>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
       <main className="stylist-workbench">
         <section className="stylist-workbench-top ipad-top-space-margin mb-2"><div className="container"><div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={path('/customer-simulation')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.reselect}</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user me-10px"></i>{pageCopy.profile}</Link></div></div></section>
         <section className="pt-0"><div className="container">

+ 8 - 4
frontend/src/pages/customer/StylistProfilePage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
@@ -12,10 +12,14 @@ type TemplateFilter = '*' | PublicHairstyleTemplate['category']
 export function StylistProfilePage() {
   const { locale, path, copy } = usePageContext()
   const pageCopy = copy.publicStylistProfileDemo
-  const [profile] = useState(getStoredStylistProfile)
+  const profile = getStoredStylistProfile(locale)
   const [filter, setFilter] = useState<TemplateFilter>('*')
   const [preview, setPreview] = useState<PublicHairstyleTemplate | null>(null)
-  const visibleTemplates = filter === '*' ? publicHairstyleTemplates : publicHairstyleTemplates.filter((item) => item.category === filter)
+  const localizedTemplates = useMemo(() => publicHairstyleTemplates.map((item) => ({
+    ...item,
+    name: pageCopy.templateNames[item.id] || item.name,
+  })), [pageCopy])
+  const visibleTemplates = filter === '*' ? localizedTemplates : localizedTemplates.filter((item) => item.category === filter)
 
   const structuredData = {
     '@context': 'https://schema.org', '@type': 'Person', name: profile.displayName,
@@ -24,7 +28,7 @@ export function StylistProfilePage() {
 
   return (
     <PublicLayout>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{pageCopy.title}</title><meta name="description" content={pageCopy.description} /><script type="application/ld+json">{JSON.stringify(structuredData)}</script></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{pageCopy.title}</title><meta name="description" content={pageCopy.description} /><script type="application/ld+json">{JSON.stringify(structuredData)}</script></Helmet>
       <section id="profile" className="stylist-profile-hero public-profile-hero cover-background ipad-top-space-margin">
         <div className="container"><div className="row align-items-center justify-content-center">
           <div className="col-lg-5 col-md-8 md-mb-40px"><div className="stylist-profile-photo public-profile-photo bg-white border-radius-8px box-shadow-quadruple-large p-15px"><img src={profile.photo} alt={`${profile.displayName} ${pageCopy.photoAlt}`} className="border-radius-6px w-100" /></div></div>

+ 7 - 12
frontend/src/pages/dashboard/DashboardPage.tsx

@@ -8,15 +8,10 @@ import { getCustomerDashboardSummary, type CustomerDashboardSummary } from '../.
 import { getTemplateDashboardSummary, type TemplateDashboardSummary } from '../../services/api/templates'
 import { getSimulationInviteCode } from '../../services/api/simulations'
 
-function greetingForHour(hour: number, locale: string): string {
-  if (locale === 'ja') {
-    if (hour < 12) return 'おはようございます'
-    if (hour < 18) return 'こんにちは'
-    return 'こんばんは'
-  }
-  if (hour < 12) return '早上好'
-  if (hour < 18) return '下午好'
-  return '晚上好'
+function greetingForHour(hour: number, greetings: { morning: string; afternoon: string; evening: string }): string {
+  if (hour < 12) return greetings.morning
+  if (hour < 18) return greetings.afternoon
+  return greetings.evening
 }
 
 export function DashboardPage() {
@@ -48,7 +43,7 @@ export function DashboardPage() {
 
   const now = useMemo(() => new Date(), [])
   const dateLabel = useMemo(
-    () => new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', {
+    () => new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : locale === 'en' ? 'en-US' : 'zh-CN', {
       year: 'numeric',
       month: 'long',
       day: 'numeric',
@@ -56,12 +51,12 @@ export function DashboardPage() {
     }).format(now),
     [locale, now],
   )
-  const greeting = greetingForHour(now.getHours(), locale)
+  const greeting = greetingForHour(now.getHours(), pageCopy.greetings)
 
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>

+ 40 - 34
frontend/src/pages/dashboard/StylistProfileManagePage.tsx

@@ -7,16 +7,22 @@ import { getStoredStylistProfile, saveStoredStylistProfile, type StylistProfileD
 import { getSimulationInviteCode } from '../../services/api/simulations'
 
 export function StylistProfileManagePage() {
-  const { locale, navigate, path } = usePageContext()
-  const [profile, setProfile] = useState<StylistProfileDraft>(getStoredStylistProfile)
+  const { locale, navigate, path, copy } = usePageContext()
+  const pageCopy = copy.profileManagePage
+  const [profile, setProfile] = useState<StylistProfileDraft>(() => getStoredStylistProfile(locale))
   const [saved, setSaved] = useState(false)
   const [inviteCode, setInviteCode] = useState('--------')
 
   useEffect(() => {
     getSimulationInviteCode()
       .then((data) => setInviteCode(data.inviteCode))
-      .catch(() => setInviteCode('获取失败'))
-  }, [])
+      .catch(() => setInviteCode(pageCopy.inviteLoadFailed))
+  }, [pageCopy.inviteLoadFailed])
+
+  useEffect(() => {
+    setProfile(getStoredStylistProfile(locale))
+    setSaved(false)
+  }, [locale])
   const patch = (part: Partial<StylistProfileDraft>) => {
     setSaved(false)
     setProfile((current) => ({ ...current, ...part }))
@@ -31,26 +37,26 @@ export function StylistProfileManagePage() {
 
   const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
-    saveStoredStylistProfile(profile)
+    saveStoredStylistProfile(locale, profile)
     setSaved(true)
   }
 
   return (
     <DashboardLayout>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>Aitori Stylist | 个人主页管理</title></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
       <section className="stylist-workbench-top ipad-top-space-margin mb-2">
         <div className="container">
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
-            <Link to={path('/dashboard')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>返回工作台</Link>
+            <Link to={path('/dashboard')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link>
             <div className="d-flex flex-wrap gap-2">
-              <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-external-link me-8px"></i>查看公开主页</Link>
-              <button type="submit" form="stylist-profile-form" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>保存</button>
+              <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-external-link me-8px"></i>{pageCopy.viewPublic}</Link>
+              <button type="submit" form="stylist-profile-form" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{copy.common.save}</button>
             </div>
           </div>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">个人主页</span>
-            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">个人主页管理</h1>
-            <p className="fs-18 lh-30 mb-0">每个美发师账号都有一个公开个人主页。顾客可以从这里查看资料、预约剪发,并进入 AI 发型模拟。</p>
+            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
+            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
+            <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
           </div>
         </div>
       </section>
@@ -59,35 +65,35 @@ export function StylistProfileManagePage() {
         <div className="container">
           <div className="stylist-profile-manage-layout">
             <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-              <span className="fs-14 fw-700 text-base-color text-uppercase">公开预览</span>
+              <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.preview.badge}</span>
               <div className="stylist-profile-manage-card mt-20px">
-                <img src={profile.photo} alt={`${profile.displayName} 的主页照片`} />
-                <div><span className="template-status-badge">公开中</span><h2>{profile.displayName}</h2><p>{profile.lead}</p><div className="stylist-profile-manage-stats"><span><strong>{profile.years}</strong>经验</span><span><strong>{profile.rating}</strong>评分</span><span><strong>{profile.reviewCount}</strong>评价</span></div></div>
+                <img src={profile.photo} alt={pageCopy.preview.photoAlt.replace('{{name}}', profile.displayName)} />
+                <div><span className="template-status-badge">{pageCopy.preview.published}</span><h2>{profile.displayName}</h2><p>{profile.lead}</p><div className="stylist-profile-manage-stats"><span><strong>{profile.years}</strong>{pageCopy.preview.experience}</span><span><strong>{profile.rating}</strong>{pageCopy.preview.rating}</span><span><strong>{profile.reviewCount}</strong>{pageCopy.preview.reviews}</span></div></div>
               </div>
-              <div className="template-detail-summary mt-25px"><div><span>公开 URL</span><strong>/{locale}/profile</strong></div><div><span>顾客模拟代码</span><strong>{inviteCode}</strong></div><div><span>预约入口</span><strong>{profile.bookingUrl ? '已设置' : '未设置'}</strong></div><div><span>公开模板</span><strong>8 件</strong></div></div>
-              <button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 w-100 mt-15px" disabled={inviteCode.length !== 8} onClick={() => void navigator.clipboard.writeText(inviteCode)}>复制顾客模拟代码</button>
-              <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100 mt-25px">查看自己的主页</Link>
+              <div className="template-detail-summary mt-25px"><div><span>{pageCopy.summary.publicUrl}</span><strong>/{locale}/profile</strong></div><div><span>{pageCopy.summary.inviteCode}</span><strong>{inviteCode}</strong></div><div><span>{pageCopy.summary.booking}</span><strong>{profile.bookingUrl ? pageCopy.summary.configured : pageCopy.summary.notConfigured}</strong></div><div><span>{pageCopy.summary.templates}</span><strong>{pageCopy.summary.templateCount.replace('{{count}}', '8')}</strong></div></div>
+              <button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 w-100 mt-15px" disabled={inviteCode.length !== 8} onClick={() => void navigator.clipboard.writeText(inviteCode)}>{pageCopy.copyCode}</button>
+              <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100 mt-25px">{pageCopy.viewOwn}</Link>
             </aside>
 
             <form id="stylist-profile-form" className="customer-edit-form workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" onSubmit={handleSubmit}>
-              <span className="fs-14 fw-700 text-base-color text-uppercase">编辑信息</span><h2 className="fw-700 text-dark-gray fs-26 mb-25px mt-10px">公开资料</h2>
-              {saved ? <div className="alert alert-success" role="status">个人主页信息已保存。</div> : null}
+              <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.form.badge}</span><h2 className="fw-700 text-dark-gray fs-26 mb-25px mt-10px">{pageCopy.form.title}</h2>
+              {saved ? <div className="alert alert-success" role="status">{pageCopy.form.saved}</div> : null}
               <div className="row g-3">
-                <div className="col-12"><label htmlFor="profile-photo-upload" className="customer-edit-label">主页照片</label><label htmlFor="profile-photo-upload" className="customer-photo-upload"><input id="profile-photo-upload" type="file" accept="image/*" onChange={(event) => handlePhoto(event.target.files?.[0])} /><span className="customer-photo-preview"><img src={profile.photo} alt="主页照片预览" /></span><span>更换主页照片</span></label></div>
-                <ProfileInput id="profile-display-name" label="显示姓名" value={profile.displayName} onChange={(displayName) => patch({ displayName })} />
-                <ProfileInput id="profile-slug" label="公开主页路径" value={profile.slug} onChange={(slug) => patch({ slug })} />
-                <ProfileInput id="profile-years" label="经验年数" value={profile.years} col="col-md-4" onChange={(years) => patch({ years })} />
-                <ProfileInput id="profile-rating" label="评分" value={profile.rating} col="col-md-4" onChange={(rating) => patch({ rating })} />
-                <ProfileInput id="profile-review-count" label="评价数" value={profile.reviewCount} col="col-md-4" onChange={(reviewCount) => patch({ reviewCount })} />
-                <ProfileInput id="profile-salon" label="沙龙名" value={profile.salonName} col="col-12" onChange={(salonName) => patch({ salonName })} />
-                <ProfileTextarea id="profile-lead" label="短介绍" value={profile.lead} rows={3} onChange={(lead) => patch({ lead })} />
-                <ProfileTextarea id="profile-bio" label="详细介绍" value={profile.bio} rows={5} onChange={(bio) => patch({ bio })} />
-                <ProfileInput id="profile-booking-url" label="预约链接" value={profile.bookingUrl} type="url" col="col-12" onChange={(bookingUrl) => patch({ bookingUrl })} />
-                <ProfileTextarea id="profile-address" label="地址" value={profile.address} rows={3} onChange={(address) => patch({ address })} />
-                <ProfileTextarea id="profile-hours" label="营业时间" value={profile.hours} rows={4} col="col-md-6" onChange={(hours) => patch({ hours })} />
-                <ProfileTextarea id="profile-specialties" label="擅长提案" value={profile.specialties} rows={4} col="col-md-6" onChange={(specialties) => patch({ specialties })} />
+                <div className="col-12"><label htmlFor="profile-photo-upload" className="customer-edit-label">{pageCopy.form.fields.photo}</label><label htmlFor="profile-photo-upload" className="customer-photo-upload"><input id="profile-photo-upload" type="file" accept="image/*" onChange={(event) => handlePhoto(event.target.files?.[0])} /><span className="customer-photo-preview"><img src={profile.photo} alt={pageCopy.form.photoPreview} /></span><span>{pageCopy.form.changePhoto}</span></label></div>
+                <ProfileInput id="profile-display-name" label={pageCopy.form.fields.displayName} value={profile.displayName} onChange={(displayName) => patch({ displayName })} />
+                <ProfileInput id="profile-slug" label={pageCopy.form.fields.slug} value={profile.slug} onChange={(slug) => patch({ slug })} />
+                <ProfileInput id="profile-years" label={pageCopy.form.fields.years} value={profile.years} col="col-md-4" onChange={(years) => patch({ years })} />
+                <ProfileInput id="profile-rating" label={pageCopy.form.fields.rating} value={profile.rating} col="col-md-4" onChange={(rating) => patch({ rating })} />
+                <ProfileInput id="profile-review-count" label={pageCopy.form.fields.reviewCount} value={profile.reviewCount} col="col-md-4" onChange={(reviewCount) => patch({ reviewCount })} />
+                <ProfileInput id="profile-salon" label={pageCopy.form.fields.salonName} value={profile.salonName} col="col-12" onChange={(salonName) => patch({ salonName })} />
+                <ProfileTextarea id="profile-lead" label={pageCopy.form.fields.lead} value={profile.lead} rows={3} onChange={(lead) => patch({ lead })} />
+                <ProfileTextarea id="profile-bio" label={pageCopy.form.fields.bio} value={profile.bio} rows={5} onChange={(bio) => patch({ bio })} />
+                <ProfileInput id="profile-booking-url" label={pageCopy.form.fields.bookingUrl} value={profile.bookingUrl} type="url" col="col-12" onChange={(bookingUrl) => patch({ bookingUrl })} />
+                <ProfileTextarea id="profile-address" label={pageCopy.form.fields.address} value={profile.address} rows={3} onChange={(address) => patch({ address })} />
+                <ProfileTextarea id="profile-hours" label={pageCopy.form.fields.hours} value={profile.hours} rows={4} col="col-md-6" onChange={(hours) => patch({ hours })} />
+                <ProfileTextarea id="profile-specialties" label={pageCopy.form.fields.specialties} value={profile.specialties} rows={4} col="col-md-6" onChange={(specialties) => patch({ specialties })} />
               </div>
-              <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>保存个人主页信息</strong><span>保存后公开主页会使用最新资料,顾客可从主页进入预约和 AI 发型模拟。</span></div><div className="d-flex flex-wrap gap-2"><button type="button" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700" onClick={() => navigate(path('/dashboard'))}>取消</button><button type="submit" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>保存</button></div></div>
+              <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>{pageCopy.form.submitTitle}</strong><span>{pageCopy.form.submitLead}</span></div><div className="d-flex flex-wrap gap-2"><button type="button" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700" onClick={() => navigate(path('/dashboard'))}>{copy.common.cancel}</button><button type="submit" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{copy.common.save}</button></div></div>
             </form>
           </div>
         </div>

+ 8 - 4
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -6,6 +6,7 @@ import type { CustomerSimulationGalleryItem } from '../../../services/api/contra
 import { listCustomerSimulationGallery } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
+import { localizeSystemTemplateName } from '../../../shared/demoContent'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 function getDisplayValue(value?: string) {
@@ -20,7 +21,7 @@ function formatDisplayDateTime(value?: string, locale?: string) {
     return value
   }
 
-  return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', {
+  return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : locale === 'en' ? 'en-US' : 'zh-CN', {
     year: 'numeric',
     month: '2-digit',
     day: '2-digit',
@@ -60,7 +61,7 @@ export function CustomerDetailPage() {
       .map(([simulationId, items]) => ({
         id: simulationId,
         createdAt: items[0]?.createdAt,
-        title: items.some((item) => /30|60|居家/.test(item.templateName)) ? pageCopy.history.aftercareSimulation : pageCopy.history.templateSimulation,
+        title: items.some((item) => /30|60|居家|ホーム|home/i.test(item.templateName)) ? pageCopy.history.aftercareSimulation : pageCopy.history.templateSimulation,
         items,
       }))
       .sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
@@ -85,7 +86,7 @@ export function CustomerDetailPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>
@@ -158,7 +159,10 @@ export function CustomerDetailPage() {
                     <section key={batch.id} className="customer-simulation-batch">
                       <div className="customer-simulation-batch-heading"><div><span>{formatDisplayDateTime(batch.createdAt, locale)}</span><strong>{batch.title}</strong></div><Link to={path(`/simulation/result/${batch.id}`)}>{pageCopy.history.action}</Link></div>
                       <div className="customer-photo-grid customer-simulation-history">
-                        {batch.items.map((item) => <Link key={`${item.taskId}-${item.imageUrl}`} to={path(`/simulation/result/${batch.id}`)} className="customer-photo-card customer-photo-card-link"><img src={item.imageUrl} alt={item.templateName} /><div><span>{taskStatusLabel(item.taskStatus)}</span><strong>{item.templateName}</strong></div></Link>)}
+                        {batch.items.map((item) => {
+                          const templateName = localizeSystemTemplateName(item.templateName, copy.systemTemplateNames)
+                          return <Link key={`${item.taskId}-${item.imageUrl}`} to={path(`/simulation/result/${batch.id}`)} className="customer-photo-card customer-photo-card-link"><img src={item.imageUrl} alt={templateName} /><div><span>{taskStatusLabel(item.taskStatus)}</span><strong>{templateName}</strong></div></Link>
+                        })}
                       </div>
                     </section>
                   ))}

+ 3 - 3
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -52,8 +52,8 @@ export function CustomerFormPage() {
         setForm(customerToFormValues(customer))
         setExistingPhoto(customer.avatar || DEFAULT_USER_AVATAR_URL)
       })
-      .catch(() => setError(locale === 'ja' ? '顧客情報の取得に失敗しました' : '获取顾客信息失败'))
-  }, [customerId, locale])
+      .catch(() => setError(pageCopy.errors?.loadFailed || (locale === 'ja' ? '顧客情報を取得できませんでした' : locale === 'en' ? 'Unable to load customer information' : '获取顾客信息失败')))
+  }, [customerId, locale, pageCopy.errors?.loadFailed])
 
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
@@ -85,7 +85,7 @@ export function CustomerFormPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${modeCopy.title}`}</title>
         <meta name="description" content={modeCopy.description} />
       </Helmet>

+ 9 - 20
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -18,18 +18,7 @@ const emptySummary: CustomerDashboardSummary = {
   weeklyRecords: 0,
 }
 
-const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
-  '全部': undefined,
-  '短发': 'SHORT',
-  '中长发': 'MEDIUM',
-  '长发': 'LONG',
-  '未确认': 'UNCONFIRM',
-  '全て': undefined,
-  'ショート': 'SHORT',
-  'ミディアム': 'MEDIUM',
-  'ロング': 'LONG',
-  '未確認': 'UNCONFIRM',
-}
+const HAIR_LENGTH_VALUES = ['', 'SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM'] as const
 
 function getHairLengthLabel(length: string | undefined, pageCopy: CustomerListPageCopy) {
   if (length === 'SHORT') return pageCopy.hairLengthMap.short
@@ -58,7 +47,7 @@ export function CustomerListPage() {
   const [page, setPage] = useState(1)
   const [pageSize] = useState(20)
   const [total, setTotal] = useState(0)
-  const [styleFilter, setStyleFilter] = useState(copy.customerListPage.filters.styleOptions[0])
+  const [styleFilter, setStyleFilter] = useState('')
   const pageCopy = copy.customerListPage
 
   useEffect(() => {
@@ -67,7 +56,7 @@ export function CustomerListPage() {
       page,
       pageSize,
       q: query.trim() || undefined,
-      hairLength: HAIR_LENGTH_API_MAP[styleFilter],
+      hairLength: styleFilter || undefined,
     })
       .then((result) => {
         setItems(result.items)
@@ -101,7 +90,7 @@ export function CustomerListPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>
@@ -132,7 +121,7 @@ export function CustomerListPage() {
               <div className="col-lg-4 col-md-4">
                 <label htmlFor="style-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.styleLabel}</label>
                 <select id="style-filter" className="form-select customer-filter-control" value={styleFilter} onChange={(event) => setStyleFilter(event.target.value)}>
-                  {pageCopy.filters.styleOptions.map((option: string) => <option key={option} value={option}>{option}</option>)}
+                  {pageCopy.filters.styleOptions.map((option: string, index: number) => <option key={option} value={HAIR_LENGTH_VALUES[index]}>{option}</option>)}
                 </select>
               </div>
             </div>
@@ -186,11 +175,11 @@ export function CustomerListPage() {
               <button
                 type="button"
                 className={`customer-pager-link${page <= 1 ? ' is-disabled' : ''}`}
-                aria-label={pageCopy.pagination?.prev || (locale === 'ja' ? '前へ' : '上一页')}
+                aria-label={pageCopy.pagination.prev}
                 disabled={page <= 1}
                 onClick={() => setPage((current) => Math.max(1, current - 1))}
               >
-                {pageCopy.pagination?.prev || (locale === 'ja' ? '前へ' : '上一页')}
+                {pageCopy.pagination.prev}
               </button>
               {visiblePages.map((pageNumber) => (
                 <button
@@ -206,11 +195,11 @@ export function CustomerListPage() {
               <button
                 type="button"
                 className={`customer-pager-link${page >= totalPages ? ' is-disabled' : ''}`}
-                aria-label={pageCopy.pagination?.next || (locale === 'ja' ? '次へ' : '下一页')}
+                aria-label={pageCopy.pagination.next}
                 disabled={page >= totalPages}
                 onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
               >
-                {pageCopy.pagination?.next || (locale === 'ja' ? '次へ' : '下一页')}
+                {pageCopy.pagination.next}
               </button>
             </div>
           </div>

+ 1 - 1
frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx

@@ -10,7 +10,7 @@ export function AftercareResultPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>

+ 1 - 1
frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx

@@ -10,7 +10,7 @@ export function SimulationEditPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>

+ 8 - 4
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx

@@ -5,7 +5,7 @@ import type { AiHairSimulationDetail } from '../../../services/api/contracts'
 import { getSimulationById } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL, LOADING_ANIMATION_URL } from '../../../shared/uiAssets'
-import { demoSimulationResults } from '../../../shared/demoContent'
+import { demoSimulationResults, localizeSystemTemplateName } from '../../../shared/demoContent'
 import { ImageLightbox } from '../../../components/common/ImageLightbox'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
@@ -81,10 +81,11 @@ export function SimulationResultPage() {
   }, [pageCopy.loadFailed, simulationId])
 
   const backTarget = detail?.customer ? path(`/customers/${detail.customer.id}`) : path('/simulation')
+  const customerName = simulationId === '10' ? pageCopy.demoCustomerName || detail?.customer?.name : detail?.customer?.name
 
   return (
     <DashboardLayout>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
       <section className="stylist-workbench-top ipad-top-space-margin mb-2"><div className="container"><div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={backTarget} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link></div></div></section>
       <section className="pt-0"><div className="container">
         {error ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-3"><p role="alert" className="text-red mb-0">{error}</p></div> : null}
@@ -93,13 +94,16 @@ export function SimulationResultPage() {
           <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
             <h1 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.original.title}</h1>
             <button type="button" className="simulation-original-card aitori-image-button" onClick={() => setPreview({ image: detail.originImage || DEFAULT_USER_AVATAR_URL, alt: pageCopy.original.title })}><img className="simulation-result-image" src={detail.originImage || DEFAULT_USER_AVATAR_URL} alt={pageCopy.original.title} /></button>
-            <div className="template-detail-summary mt-20px"><div><span>{pageCopy.original.customer}</span><strong>{detail.customer?.name || pageCopy.anonymousTitle}</strong></div><div><span>{pageCopy.original.generatedAt}</span><strong>{formatDisplayDate(detail.createdAt)}</strong></div><div><span>{pageCopy.original.count}</span><strong>{pageCopy.original.countValue.replace('{{count}}', String(detail.totalTaskCount))}</strong></div></div>
+            <div className="template-detail-summary mt-20px"><div><span>{pageCopy.original.customer}</span><strong>{customerName || pageCopy.anonymousTitle}</strong></div><div><span>{pageCopy.original.generatedAt}</span><strong>{formatDisplayDate(detail.createdAt)}</strong></div><div><span>{pageCopy.original.count}</span><strong>{pageCopy.original.countValue.replace('{{count}}', String(detail.totalTaskCount))}</strong></div></div>
           </aside>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
             <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px"><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title}</h2><span className="simulation-count-badge">{pageCopy.results.count.replace('{{count}}', String(detail.totalTaskCount))}</span></div>
             <div className="simulation-result-grid">{detail.tasks.map((task) => {
               const image = task.resultImages[0] || task.templateCoverUrl || '/images/aitori-simulation-result.png'
-              return <article key={task.id} className="simulation-result-card"><button type="button" className="aitori-image-button" disabled={task.taskStatus === 'PROCESSING'} onClick={() => setPreview({ image, alt: task.templateName })}><img className="simulation-result-image" src={image} alt={task.templateName} /></button><div className="simulation-result-body"><h3>{task.templateName}</h3><span className="template-status-badge">{task.templateSource === 'SYSTEM' ? pageCopy.source.system : pageCopy.source.stylist}</span>{task.taskStatus === 'PROCESSING' ? <img src={LOADING_ANIMATION_URL} alt={pageCopy.card.processing} className="simulation-result-card-loading" /> : null}<p>{task.taskStatus === 'FAILED' ? task.errorMsg || pageCopy.card.failed : task.taskStatus === 'PROCESSING' ? pageCopy.card.processing : pageCopy.card.success}</p></div></article>
+              const templateName = task.templateSource === 'SYSTEM'
+                ? localizeSystemTemplateName(task.templateName, copy.systemTemplateNames)
+                : task.templateName
+              return <article key={task.id} className="simulation-result-card"><button type="button" className="aitori-image-button" disabled={task.taskStatus === 'PROCESSING'} onClick={() => setPreview({ image, alt: templateName })}><img className="simulation-result-image" src={image} alt={templateName} /></button><div className="simulation-result-body"><h3>{templateName}</h3><span className="template-status-badge">{task.templateSource === 'SYSTEM' ? pageCopy.source.system : pageCopy.source.stylist}</span>{task.taskStatus === 'PROCESSING' ? <img src={LOADING_ANIMATION_URL} alt={pageCopy.card.loadingAlt || '生成中'} className="simulation-result-card-loading" /> : null}<p>{task.taskStatus === 'FAILED' ? task.errorMsg || pageCopy.card.failed : task.taskStatus === 'PROCESSING' ? pageCopy.card.processing : pageCopy.card.success}</p></div></article>
             })}</div>
           </div>
         </div> : null}

+ 10 - 6
frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx

@@ -16,6 +16,7 @@ import {
   usePreviewUrl,
 } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
+import { localizeSystemTemplateName } from '../../../shared/demoContent'
 
 export function StylistSimulationPage() {
   const [searchParams] = useSearchParams()
@@ -58,8 +59,11 @@ export function StylistSimulationPage() {
       .then((result) => {
         if (templateSource === 'SYSTEM') {
           const images = ['/images/mvp-simulation-result-10/after-30-days.jpg', '/images/mvp-simulation-result-10/home-styling.jpg']
-          const names = ['居家造型30天后造型', '居家造型']
-          setTemplates(result.items.slice(0, 2).map((item, index) => ({ ...item, coverUrl: images[index], templateName: names[index] })))
+          setTemplates(result.items.slice(0, 2).map((item, index) => ({
+            ...item,
+            coverUrl: images[index],
+            templateName: localizeSystemTemplateName(item.templateName, copy.systemTemplateNames),
+          })))
         } else {
           setTemplates(result.items)
         }
@@ -67,7 +71,7 @@ export function StylistSimulationPage() {
       .catch(() => {
         setTemplates([])
       })
-  }, [templateSource])
+  }, [copy.systemTemplateNames, templateSource])
 
   const toggleTemplate = (templateId: string) => {
     setSelectedTemplates((current) => {
@@ -150,7 +154,7 @@ export function StylistSimulationPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>
@@ -243,7 +247,7 @@ export function StylistSimulationPage() {
                   </div>
                 </div>
               ) : (
-                <div className="simulation-save-warning mt-25px" role="note"><i className="feather icon-feather-alert-circle"></i><span>不绑定顾客时,可以现场生成,但结果不会保存到顾客档案。</span></div>
+                <div className="simulation-save-warning mt-25px" role="note"><i className="feather icon-feather-alert-circle"></i><span>{pageCopy.guestWarning}</span></div>
               )}
             </div>
 
@@ -299,7 +303,7 @@ export function StylistSimulationPage() {
                   <span><i className="feather icon-feather-layers"></i>{pageCopy.systemTemplates}</span>
                 </label>
               </div>
-              <div className="d-flex align-items-center justify-content-end flex-wrap gap-2 mb-25px"><span className="simulation-count-badge">已选 <strong>{selectedTemplates.length}</strong> 个模板</span></div>
+              <div className="d-flex align-items-center justify-content-end flex-wrap gap-2 mb-25px"><span className="simulation-count-badge">{pageCopy.selectedCount.replace('{{count}}', String(selectedTemplates.length))}</span></div>
               <div className={`simulation-template-grid mb-30px${templateSource === 'SYSTEM' ? ' simulation-system-template-grid' : ''}`}>
                 {templates.length === 0 ? (
                   <p className="text-medium-gray mb-0">{pageCopy.emptyTemplates}</p>

+ 1 - 1
frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx

@@ -156,7 +156,7 @@ export function TemplateDetailPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>

+ 1 - 1
frontend/src/pages/dashboard/templates/TemplateEditPage.tsx

@@ -188,7 +188,7 @@ export function TemplateEditPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>

+ 4 - 4
frontend/src/pages/dashboard/templates/TemplateFormPage.tsx

@@ -240,7 +240,7 @@ export function TemplateFormPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>
@@ -329,7 +329,7 @@ export function TemplateFormPage() {
                 <div className="template-upload-inline">
                   <label htmlFor="template-model-upload" className="customer-edit-label">{pageCopy.model.uploadLabel || '上传模特图片'}</label>
                   <input id="template-model-upload" aria-label={pageCopy.model.uploadLabel || '上传模特图片'} type="file" accept="image/*" className="form-control customer-edit-control" onChange={(event) => setCustomModelFile(event.target.files?.[0] || null)} />
-                  {customModelPreview ? <img src={customModelPreview} alt="自定义模特预览" className="template-upload-inline-preview" /> : null}
+                  {customModelPreview ? <img src={customModelPreview} alt={pageCopy.model.customPreviewAlt} className="template-upload-inline-preview" /> : null}
                 </div>
               )}
             </div>
@@ -362,7 +362,7 @@ export function TemplateFormPage() {
                     <div className="template-upload-inline">
                       <label htmlFor="template-outfit-upload" className="customer-edit-label">{pageCopy.outfit.uploadLabel || '上传服装图片'}</label>
                       <input id="template-outfit-upload" aria-label={pageCopy.outfit.uploadLabel || '上传服装图片'} type="file" accept="image/*" className="form-control customer-edit-control" onChange={(event) => setCustomOutfitFile(event.target.files?.[0] || null)} />
-                      {customOutfitPreview ? <img src={customOutfitPreview} alt="自定义服装预览" className="template-upload-inline-preview" /> : null}
+                      {customOutfitPreview ? <img src={customOutfitPreview} alt={pageCopy.outfit.customPreviewAlt} className="template-upload-inline-preview" /> : null}
                     </div>
                   )}
                 </div>
@@ -395,7 +395,7 @@ export function TemplateFormPage() {
                     <div className="template-upload-inline">
                       <label htmlFor="template-background-upload" className="customer-edit-label">{pageCopy.background.uploadLabel || '上传背景图片'}</label>
                       <input id="template-background-upload" aria-label={pageCopy.background.uploadLabel || '上传背景图片'} type="file" accept="image/*" className="form-control customer-edit-control" onChange={(event) => setCustomBackgroundFile(event.target.files?.[0] || null)} />
-                      {customBackgroundPreview ? <img src={customBackgroundPreview} alt="自定义背景预览" className="template-upload-inline-preview" /> : null}
+                      {customBackgroundPreview ? <img src={customBackgroundPreview} alt={pageCopy.background.customPreviewAlt} className="template-upload-inline-preview" /> : null}
                     </div>
                   )}
                 </div>

+ 18 - 13
frontend/src/pages/dashboard/templates/TemplateListPage.tsx

@@ -35,6 +35,11 @@ export function TemplateListPage() {
   const [total, setTotal] = useState(0)
   const [loading, setLoading] = useState(false)
   const pageCopy = copy.templateListPage
+  const fallbackCopy = locale === 'ja'
+    ? { bulkDelete: 'まとめて削除', delete: '削除', pending: '待機中', processing: '生成中', failed: '生成失敗', viewCover: 'テンプレート画像を見る', select: 'テンプレート {{name}} を選択', deleteTemplate: 'テンプレート {{name}} を削除', confirmDelete: 'テンプレート「{{name}}」を削除しますか?', confirmBulkDelete: '選択したテンプレート {{count}}件を削除しますか?' }
+    : locale === 'en'
+      ? { bulkDelete: 'Delete selected', delete: 'Delete', pending: 'Queued', processing: 'Generating', failed: 'Generation failed', viewCover: 'View template cover', select: 'Select template {{name}}', deleteTemplate: 'Delete template {{name}}', confirmDelete: 'Delete template “{{name}}”?', confirmBulkDelete: 'Delete the {{count}} selected templates?' }
+      : { bulkDelete: '批量删除', delete: '删除', pending: '排队中', processing: '生成中', failed: '生成失败', viewCover: '查看模板封面', select: '选择模板 {{name}}', deleteTemplate: '删除{{name}}', confirmDelete: '确认删除模板「{{name}}」吗?', confirmBulkDelete: '确认删除已选中的 {{count}} 个模板吗?' }
 
   useEffect(() => {
     getTemplateDashboardSummary().then(setSummary).catch(() => setSummary(initialSummary))
@@ -102,7 +107,7 @@ export function TemplateListPage() {
 
   const handleDelete = async (templateId: string) => {
     const template = items.find((item) => item.id === templateId)
-    if (!window.confirm(`确认删除模板「${template?.name || ''}」吗?`)) return
+    if (!window.confirm((pageCopy.confirmDelete || fallbackCopy.confirmDelete).replace('{{name}}', template?.name || ''))) return
 
     await deleteTemplate(templateId)
     setItems((current) => current.filter((item) => item.id !== templateId))
@@ -112,7 +117,7 @@ export function TemplateListPage() {
 
   const handleBulkDelete = async () => {
     if (selectedIds.length === 0) return
-    if (!window.confirm(`确认删除已选中的 ${selectedIds.length} 个模板吗?`)) return
+    if (!window.confirm((pageCopy.confirmBulkDelete || fallbackCopy.confirmBulkDelete).replace('{{count}}', String(selectedIds.length)))) return
 
     const deletingIds = [...selectedIds]
     await deleteTemplates(deletingIds)
@@ -123,17 +128,17 @@ export function TemplateListPage() {
 
   const getGenerationBadge = (template: TemplateEntity) => {
     if (template.makeTaskStatus === 'WAIT' || template.generationStatus === 'pending') {
-      return pageCopy.statusBadgeMap?.pending || '排队中'
+      return pageCopy.statusBadgeMap?.pending || fallbackCopy.pending
     }
     if (
       template.makeTaskStatus === 'PROCESSING' ||
       template.makeTaskStatus === 'STEP1_SUCCESS' ||
       template.generationStatus === 'processing'
     ) {
-      return pageCopy.statusBadgeMap?.processing || '生成中'
+      return pageCopy.statusBadgeMap?.processing || fallbackCopy.processing
     }
     if (template.makeTaskStatus === 'FAILED' || template.generationStatus === 'failed') {
-      return pageCopy.statusBadgeMap?.failed || '生成失败'
+      return pageCopy.statusBadgeMap?.failed || fallbackCopy.failed
     }
     return null
   }
@@ -167,7 +172,7 @@ export function TemplateListPage() {
   return (
     <DashboardLayout>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>
@@ -265,8 +270,8 @@ export function TemplateListPage() {
                   className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
                 >
                   {allCurrentPageSelected
-                    ? pageCopy.deselectAll || '取消全选'
-                    : pageCopy.selectAll || '全选'}
+                    ? pageCopy.deselectAll
+                    : pageCopy.selectAll}
                 </button>
                 <button
                   type="button"
@@ -274,7 +279,7 @@ export function TemplateListPage() {
                   disabled={selectedIds.length === 0}
                   className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
                 >
-                  {pageCopy.bulkDelete || '批量删除'}
+                  {pageCopy.bulkDelete || fallbackCopy.bulkDelete}
                 </button>
                 <a href="#" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.sortRecent}</a>
               </div>
@@ -288,7 +293,7 @@ export function TemplateListPage() {
                   <>
                     <div className="template-card-image">
                       {target ? (
-                        <Link to={target} className="template-card-image-link" aria-label="查看模板封面">
+                        <Link to={target} className="template-card-image-link" aria-label={pageCopy.viewCover || fallbackCopy.viewCover}>
                           <img src={getCardImage(template)} alt={template.name} />
                         </Link>
                       ) : (
@@ -300,7 +305,7 @@ export function TemplateListPage() {
                         <input
                           type="checkbox"
                           checked={selectedIds.includes(template.id)}
-                          aria-label={`${pageCopy.select || '选择'}${template.name}`}
+                          aria-label={(pageCopy.selectTemplate || fallbackCopy.select).replace('{{name}}', template.name)}
                           onChange={() => toggleSelect(template.id)}
                           className="template-card-checkbox"
                         />
@@ -332,10 +337,10 @@ export function TemplateListPage() {
                           <button
                             type="button"
                             className="template-card-delete-action"
-                            aria-label={`${pageCopy.delete || '删除'}${template.name}`}
+                            aria-label={(pageCopy.deleteTemplate || fallbackCopy.deleteTemplate).replace('{{name}}', template.name)}
                             onClick={() => void handleDelete(template.id)}
                           >
-                            {pageCopy.delete || '删除'}
+                            {pageCopy.delete || fallbackCopy.delete}
                           </button>
                         </div>
                       </div>

+ 1 - 1
frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx

@@ -157,7 +157,7 @@ export function TemplateMakeTaskPage() {
 
   return (
     <DashboardLayout>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
       <section className={`stylist-workbench-top ipad-top-space-margin mb-2 template-task-demo ${complete ? 'is-complete' : 'is-generating'}`}>
         <div className="container">
           <div className="template-task-page-head mb-4">

+ 2 - 2
frontend/src/pages/public/BetaApplyPage.tsx

@@ -47,7 +47,7 @@ export function BetaApplyPage() {
   return (
     <>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{pageCopy.title}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>
@@ -56,7 +56,7 @@ export function BetaApplyPage() {
           <div className="container-fluid">
             <div className="col-auto me-auto"><Link className="navbar-brand" to={path('')}><AppLogo /></Link></div>
             <div className="col-auto menu-order position-static">
-              <button className="navbar-toggler float-start" type="button" data-bs-toggle="collapse" data-bs-target="#betaNavbar" aria-controls="betaNavbar" aria-label={locale === 'ja' ? 'メニューを開く' : '打开导航'}>
+              <button className="navbar-toggler float-start" type="button" data-bs-toggle="collapse" data-bs-target="#betaNavbar" aria-controls="betaNavbar" aria-label={locale === 'ja' ? 'メニューを開く' : locale === 'en' ? 'Open menu' : '打开导航'}>
                 <span className="navbar-toggler-line"></span><span className="navbar-toggler-line"></span><span className="navbar-toggler-line"></span><span className="navbar-toggler-line"></span>
               </button>
               <div className="collapse navbar-collapse" id="betaNavbar">

+ 1 - 1
frontend/src/pages/public/LandingPage.tsx

@@ -43,7 +43,7 @@ export function LandingPage() {
   return (
     <>
       <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
         <title>{pageCopy.title}</title>
         <meta name="description" content={pageCopy.description} />
       </Helmet>

+ 1 - 1
frontend/src/pages/public/LoginPage.tsx

@@ -25,7 +25,7 @@ export function LoginPage() {
       await stylistLogin({ email, password, remember })
       navigate(path('/dashboard'))
     } catch (err) {
-      setError(err instanceof Error ? err.message : '登录失败')
+      setError(err instanceof Error ? err.message : page.login.error)
     } finally {
       setSubmitting(false)
     }

+ 47 - 0
frontend/src/shared/__tests__/stylistProfileStore.test.ts

@@ -0,0 +1,47 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { localizeSystemTemplateName } from '../demoContent'
+import {
+  defaultStylistProfiles,
+  getStoredStylistProfile,
+  saveStoredStylistProfile,
+} from '../stylistProfileStore'
+
+describe('localized stylist profile storage', () => {
+  beforeEach(() => window.localStorage.clear())
+
+  it('provides localized default profile content', () => {
+    expect(getStoredStylistProfile('zh').lead).toContain('顾客')
+    expect(getStoredStylistProfile('ja').lead).toContain('スタイリスト')
+    expect(getStoredStylistProfile('en').lead).toContain('Customers')
+  })
+
+  it('stores each language independently', () => {
+    saveStoredStylistProfile('zh', { ...defaultStylistProfiles.zh, lead: '中文介绍' })
+    saveStoredStylistProfile('en', { ...defaultStylistProfiles.en, lead: 'English introduction' })
+
+    expect(getStoredStylistProfile('zh').lead).toBe('中文介绍')
+    expect(getStoredStylistProfile('en').lead).toBe('English introduction')
+    expect(getStoredStylistProfile('ja').lead).toBe(defaultStylistProfiles.ja.lead)
+  })
+})
+
+describe('system template localization', () => {
+  const names = {
+    homeStyling: 'Home styling',
+    after30Days: 'After 30 days',
+    after60Days: 'After 60 days',
+  }
+
+  it.each([
+    ['居家造型', 'Home styling'],
+    ['ホームスタイリング', 'Home styling'],
+    ['after-30-days', 'After 30 days'],
+    ['居家造型60天后造型', 'After 60 days'],
+  ])('maps %s to its localized system name', (source, expected) => {
+    expect(localizeSystemTemplateName(source, names)).toBe(expected)
+  })
+
+  it('leaves custom template names unchanged', () => {
+    expect(localizeSystemTemplateName('Layer Bob', names)).toBe('Layer Bob')
+  })
+})

+ 15 - 0
frontend/src/shared/demoContent.ts

@@ -45,3 +45,18 @@ export const demoSimulationResults = [
     customerNote: '用于判断下次维护时间,以及是否需要预约调整。',
   },
 ]
+
+export type SystemTemplateNames = {
+  homeStyling: string
+  after30Days: string
+  after60Days: string
+}
+
+export function localizeSystemTemplateName(templateName: string, names?: SystemTemplateNames) {
+  if (!names) return templateName
+  const normalized = templateName.toLowerCase()
+  if (/60|after-?60/.test(normalized)) return names.after60Days
+  if (/30|after-?30/.test(normalized)) return names.after30Days
+  if (/居家|ホーム|home/.test(normalized)) return names.homeStyling
+  return templateName
+}

+ 52 - 12
frontend/src/shared/stylistProfileStore.ts

@@ -1,4 +1,5 @@
 import { DEMO_STYLIST_PHOTO } from './demoContent'
+import type { Locale } from './pageContext'
 
 export type StylistProfileDraft = {
   photo: string
@@ -16,34 +17,73 @@ export type StylistProfileDraft = {
   bio: string
 }
 
-export const defaultStylistProfile: StylistProfileDraft = {
+const sharedDefaultProfile = {
   photo: DEMO_STYLIST_PHOTO,
   displayName: 'Demo Stylist',
   slug: 'demo-stylist',
-  years: '8年',
+  years: '8',
   rating: '5.0',
   reviewCount: '24',
   salonName: 'Aitori Demo Salon',
   bookingUrl: 'https://example.com/booking',
-  address: '东京都示例区 1-2-3\nAitori Demo Salon',
-  hours: '平日 10:00 - 21:00\n周末 10:00 - 19:00',
-  specialties: '擅长根据脸型、发质和日常打理习惯,提供自然、容易沟通的发型提案。',
-  lead: 'Aitori Stylist 的 demo 美发师主页。顾客可以查看资料、预约服务,并体验 AI 发型模拟。',
-  bio: '这是用于产品演示的美发师资料。可在后台维护姓名、介绍、预约链接、营业信息和公开模板,用于展示顾客端个人主页体验。',
 }
 
+export const defaultStylistProfiles: Record<Locale, StylistProfileDraft> = {
+  zh: {
+    ...sharedDefaultProfile,
+    address: '东京都示例区 1-2-3\nAitori Demo Salon',
+    hours: '工作日 10:00 - 21:00\n周末 10:00 - 19:00',
+    specialties: '擅长根据脸型、发质和日常打理习惯,提供自然、易于沟通的发型建议。',
+    lead: 'Aitori Stylist 的示例美发师主页。顾客可以查看资料、预约服务,并体验 AI 发型模拟。',
+    bio: '这是用于产品演示的美发师资料。可在后台维护姓名、介绍、预约链接、营业信息和公开模板。',
+  },
+  ja: {
+    ...sharedDefaultProfile,
+    address: '東京都サンプル区 1-2-3\nAitori Demo Salon',
+    hours: '平日 10:00 - 21:00\n週末 10:00 - 19:00',
+    specialties: '顔立ち、髪質、普段のお手入れに合わせて、自然で相談しやすいヘアスタイルをご提案します。',
+    lead: 'Aitori Stylist のデモスタイリストページです。プロフィールや予約情報を確認し、AI ヘアシミュレーションを体験できます。',
+    bio: '製品デモ用のスタイリストプロフィールです。管理画面から氏名、紹介文、予約リンク、営業時間、公開テンプレートを管理できます。',
+  },
+  en: {
+    ...sharedDefaultProfile,
+    address: '1-2-3 Sample Ward, Tokyo\nAitori Demo Salon',
+    hours: 'Weekdays 10:00–21:00\nWeekends 10:00–19:00',
+    specialties: 'Natural, practical hairstyle recommendations tailored to face shape, hair texture, and everyday styling habits.',
+    lead: 'A demo stylist profile for Aitori Stylist. Customers can view the profile, book a service, and try AI hairstyle simulation.',
+    bio: 'This demo profile shows how stylists can manage their name, introduction, booking link, business hours, and published templates.',
+  },
+}
+
+export const defaultStylistProfile = defaultStylistProfiles.zh
+
 const STORAGE_KEY = 'aitori-stylist-profile'
 
-export function getStoredStylistProfile(): StylistProfileDraft {
+type StoredProfiles = Partial<Record<Locale, StylistProfileDraft>>
+
+export function getStoredStylistProfile(locale: Locale = 'zh'): StylistProfileDraft {
   try {
     const stored = window.localStorage.getItem(STORAGE_KEY)
-    return stored ? { ...defaultStylistProfile, ...JSON.parse(stored) } : defaultStylistProfile
+    if (!stored) return defaultStylistProfiles[locale]
+    const parsed = JSON.parse(stored) as StoredProfiles | StylistProfileDraft
+    const localized = 'displayName' in parsed ? (locale === 'zh' ? parsed : undefined) : parsed[locale]
+    return localized ? { ...defaultStylistProfiles[locale], ...localized } : defaultStylistProfiles[locale]
   } catch {
-    return defaultStylistProfile
+    return defaultStylistProfiles[locale]
   }
 }
 
-export function saveStoredStylistProfile(profile: StylistProfileDraft) {
+export function saveStoredStylistProfile(locale: Locale, profile: StylistProfileDraft) {
   const persistentProfile = profile.photo.startsWith('data:') ? profile : { ...profile, photo: DEMO_STYLIST_PHOTO }
-  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persistentProfile))
+  let profiles: StoredProfiles = {}
+  try {
+    const stored = window.localStorage.getItem(STORAGE_KEY)
+    if (stored) {
+      const parsed = JSON.parse(stored) as StoredProfiles | StylistProfileDraft
+      profiles = 'displayName' in parsed ? { zh: parsed } : parsed
+    }
+  } catch {
+    profiles = {}
+  }
+  window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...profiles, [locale]: persistentProfile }))
 }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików