Răsfoiți Sursa

feat: polish simulation detail and customer gallery ui

ChenYu 4 luni în urmă
părinte
comite
6a0c25687d

+ 132 - 0
frontend/public/css/aitori.css

@@ -617,6 +617,19 @@ button.customer-pager-link {
     color: var(--dark-gray);
     font-size: 15px;
 }
+.customer-photo-empty {
+    grid-column: 1 / -1;
+    min-height: 220px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 1px dashed rgba(44, 46, 60, 0.14);
+    border-radius: 8px;
+    background-color: #fbfbfc;
+    color: var(--medium-gray);
+    font-size: 15px;
+    font-weight: 700;
+}
 .customer-detail-cta {
     display: flex;
     align-items: center;
@@ -1052,6 +1065,41 @@ textarea.customer-edit-control {
     gap: 24px;
     align-items: start;
 }
+.simulation-section-badge {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 28px;
+    padding: 0 12px;
+    border-radius: 999px;
+    background-color: rgba(184, 125, 242, 0.12);
+    color: #b87df2;
+    font-size: 12px;
+    font-weight: 800;
+    letter-spacing: 0.02em;
+}
+.simulation-section-badge-text {
+    display: inline-block;
+    min-height: 0;
+    padding: 0;
+    border-radius: 0;
+    background-color: transparent;
+    color: var(--medium-gray);
+    font-size: 14px;
+    font-weight: 700;
+    letter-spacing: 0;
+}
+.simulation-section-badge-accent {
+    color: #b87df2;
+}
+.simulation-section-title {
+    margin: 14px 0 20px;
+    color: var(--dark-gray);
+    font-size: 24px;
+    font-weight: 800;
+    line-height: 1.2;
+    letter-spacing: -0.02em;
+}
 .simulation-original-card {
     overflow: hidden;
     border-radius: 8px;
@@ -1063,11 +1111,68 @@ textarea.customer-edit-control {
     object-fit: cover;
     display: block;
 }
+.simulation-result-sidebar-body {
+    display: grid;
+    gap: 20px;
+    margin-top: 20px;
+}
+.simulation-result-meta-list {
+    display: grid;
+    gap: 0;
+}
+.simulation-result-meta-list div {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+    min-height: 54px;
+    border-bottom: 1px solid rgba(44, 46, 60, 0.08);
+}
+.simulation-result-meta-list div:first-child {
+    border-top: 1px solid rgba(44, 46, 60, 0.08);
+}
+.simulation-result-meta-list strong {
+    color: var(--dark-gray);
+    font-size: 16px;
+    font-weight: 800;
+}
+.simulation-result-meta-list span {
+    color: var(--medium-gray);
+    font-size: 14px;
+    font-weight: 700;
+}
 .simulation-result-grid {
     display: grid;
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 20px;
 }
+.simulation-results-header {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 16px;
+    min-height: 92px;
+    margin-bottom: 24px;
+}
+.simulation-results-header > div {
+    width: 100%;
+}
+.simulation-results-header-static {
+    justify-content: flex-start;
+}
+.simulation-results-count {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 32px;
+    padding: 0 14px;
+    border-radius: 999px;
+    background-color: rgba(184, 125, 242, 0.12);
+    color: #b87df2;
+    font-size: 13px;
+    font-weight: 800;
+    white-space: nowrap;
+}
 .simulation-result-card {
     position: relative;
     overflow: hidden;
@@ -1108,6 +1213,29 @@ textarea.customer-edit-control {
 .simulation-result-body {
     padding: 18px;
 }
+.simulation-result-card-title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+}
+.simulation-result-card-title-row h3 {
+    margin: 0;
+}
+.simulation-result-card-tag {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 28px;
+    padding: 0 12px;
+    border-radius: 999px;
+    background-color: rgba(245, 126, 72, 0.12);
+    color: var(--base-color);
+    font-size: 12px;
+    font-weight: 800;
+    letter-spacing: 0.02em;
+    white-space: nowrap;
+}
 .simulation-result-body h3 {
     margin: 12px 0 8px;
     color: var(--dark-gray);
@@ -1671,6 +1799,10 @@ footer ul li {
     .simulation-tune-grid {
         grid-template-columns: repeat(2, minmax(0, 1fr));
     }
+    .simulation-results-header {
+        flex-direction: column;
+        align-items: flex-start;
+    }
     .simulation-preset-grid {
         grid-template-columns: repeat(2, minmax(0, 1fr));
     }

+ 64 - 10
frontend/src/i18n/locales/ja/translation.json

@@ -387,11 +387,16 @@
   },
   "simulationEditPage": {
     "title": "AIヘアシミュレーション微調整",
-    "description": "Aitori Stylist の AI ヘアシミュレーション微調整ページ。選択した結果に対してカラー、長さ、ボリューム、前髪を再調整できます。",
-    "back": "結果へ戻る",
+    "description": "Aitori Stylist の AI ヘアシミュレーション微調整ページ。現在のバージョンでは未開発シェルのみを残し、実際の微調整フローは後続 Case で実装します。",
+    "back": "AIヘアシミュレーションへ戻る",
     "generate": "生成",
     "badge": "微調整",
-    "lead": "選択した AI ヘアシミュレーション結果をもとに、カラー、長さ、ボリューム、前髪を調整します。必要に応じて写真の差し替えも行えます。",
+    "lead": "微調整機能は現在のバージョンでは未開発です。今回は AI ヘアシミュレーションの主フローのみを提供し、後続で独立した微調整機能を追加します。",
+    "state": {
+      "title": "現在のバージョンでは未開発です",
+      "description": "AI ヘアシミュレーションの主フローへ戻って、作成と結果確認を続けてください。微調整機能は後続 Case で別途実装します。",
+      "action": "AIヘアシミュレーションへ戻る"
+    },
     "current": {
       "badge": "現在の結果",
       "title": "選択中の結果",
@@ -582,6 +587,7 @@
     "photos": {
       "badge": "写真とシミュレーション",
       "title": "過去写真とAIヘアシミュレーション",
+      "empty": "AIヘアシミュレーション結果はまだありません",
       "items": [
         { "label": "来店前", "title": "来店時記録", "alt": "来店時の写真" },
         { "label": "AIヘアシミュレーション", "title": "AIヘアシミュレーション結果", "alt": "AIヘアシミュレーション結果" },
@@ -602,6 +608,23 @@
     "regenerate": "再生成",
     "badge": "生成結果",
     "lead": "選択したテンプレートを顧客写真に適用した結果を表示します。各案を微調整したり、今回の施術記録へ保存できます。",
+    "anonymousTitle": "匿名シミュレーション",
+    "anonymousLead": "このタスクは顧客カルテに紐付いていません。",
+    "counters": {
+      "total": "テンプレート総数",
+      "processing": "処理中",
+      "success": "成功",
+      "failed": "失敗"
+    },
+    "source": {
+      "stylist": "マイテンプレート",
+      "system": "システムテンプレート"
+    },
+    "card": {
+      "processing": "生成中です。しばらくしてからこの案の結果をご確認ください。",
+      "success": "生成が完了しました。この案の仕上がりを確認できます。",
+      "failed": "生成に失敗しました。戻って再試行してください。"
+    },
     "state": {
       "pending": {
         "badge": "タスク状態",
@@ -625,16 +648,16 @@
       }
     },
     "original": {
-      "badge": "元画像",
-      "title": "元画像",
+      "badge": "顧客写真",
+      "title": "今回の仕上がり",
       "customer": "顧客",
       "generatedAt": "生成日時",
       "count": "生成数",
       "countValue": "{{count}}案"
     },
     "results": {
-      "badge": "生成結果",
-      "title": "生成結果 {{count}}案",
+      "badge": "未来変化",
+      "title": "施術後変化予測結果",
       "count": "{{count}}案",
       "templatePrefix": "テンプレート",
       "adjust": "微調整",
@@ -643,12 +666,17 @@
   },
   "aftercareResultPage": {
     "title": "施術後変化予測結果",
-    "description": "Aitori Stylist の施術後変化予測結果ページ。自宅での状態、30日後、60日後の変化を確認し、次回来店提案を保存できます。",
-    "back": "条件を修正",
+    "description": "Aitori Stylist の施術後変化予測結果ページ。現在のバージョンでは未開発シェルのみを残し、実際の aftercare フローは後続 Case で実装します。",
+    "back": "AIヘアシミュレーションへ戻る",
     "regenerate": "再生成",
     "badge": "施術後変化予測",
     "heading": "次回来店提案",
-    "lead": "施術後の変化予測、次回来店の推奨日、提案メニューを確認します。必要に応じて予約状況や日付を調整できます。",
+    "lead": "aftercare 機能は現在のバージョンでは未開発です。今回は AI ヘアシミュレーションの主フローのみを提供し、後続で術後変化予測と提案機能を追加します。",
+    "state": {
+      "title": "現在のバージョンでは未開発です",
+      "description": "AI ヘアシミュレーションの主フローへ戻って操作を続けてください。aftercare 機能は後続 Case で別途実装します。",
+      "action": "AIヘアシミュレーションへ戻る"
+    },
     "original": {
       "badge": "顧客写真",
       "title": "今回の仕上がり",
@@ -684,6 +712,32 @@
       "back": "戻る"
     }
   },
+  "simulationCreatePage": {
+    "title": "AIヘアシミュレーション",
+    "description": "Aitori Stylist の AI ヘアシミュレーションページ。顧客紐付け、元画像アップロード、テンプレート選択でタスクを作成します。",
+    "badge": "AIヘアシミュレーション",
+    "lead": "顧客の元画像をアップロードし、テンプレートを選択して AI ヘアシミュレーションを開始します。",
+    "back": "ホームへ戻る",
+    "submit": "シミュレーション開始",
+    "bindCustomer": "顧客を紐付ける",
+    "noCustomer": "顧客を紐付けない",
+    "customerLabel": "顧客",
+    "customerPlaceholder": "顧客を選択してください",
+    "upload": "顧客写真をアップロード",
+    "uploadHint": "JPG / PNG に対応",
+    "remark": "シミュレーションメモ",
+    "remarkPlaceholder": "今回の要望を任意で入力",
+    "myTemplates": "マイテンプレート",
+    "systemTemplates": "システムテンプレート",
+    "selectedCount": "選択済みテンプレート {{count}} 件",
+    "emptyTemplates": "テンプレートがありません",
+    "errors": {
+      "originRequired": "先に顧客写真をアップロードしてください",
+      "templateRequired": "テンプレートを1件以上選択してください",
+      "customerRequired": "顧客を選択してください",
+      "submitFailed": "シミュレーション開始に失敗しました。時間をおいて再試行してください"
+    }
+  },
   "stylistSimulationPage": {
     "title": "AIヘアシミュレーション",
     "description": "Aitori Stylist の AI ヘアシミュレーションページ。顧客情報、写真、テンプレート、または施術後変化予測を選んで結果を生成します。",

+ 64 - 10
frontend/src/i18n/locales/zh/translation.json

@@ -387,11 +387,16 @@
   },
   "simulationEditPage": {
     "title": "AI发型模拟微调",
-    "description": "Aitori Stylist AI发型模拟微调页面。美发师可基于已选结果调整发色、长度、发量和刘海,也可重新上传顾客照片后再次生成。",
-    "back": "返回结果",
+    "description": "Aitori Stylist AI发型模拟微调页面。当前版本仅保留未开发壳页面,后续 Case 再接入真实微调流程。",
+    "back": "返回AI发型模拟",
     "generate": "生成",
     "badge": "微调",
-    "lead": "基于已选择的 AI 发型模拟结果,调整发色、长度、发量和刘海。必要时也可重新上传顾客照片后再次生成。",
+    "lead": "微调功能当前版本暂未开发。本次仅交付 AI 发型模拟主流程,后续再补充独立微调能力。",
+    "state": {
+      "title": "当前版本暂未开发",
+      "description": "请返回 AI 发型模拟主流程继续创建和查看任务结果。微调能力将在后续 Case 中单独实现。",
+      "action": "返回AI发型模拟"
+    },
     "current": {
       "badge": "当前结果",
       "title": "当前选中结果",
@@ -582,6 +587,7 @@
     "photos": {
       "badge": "照片与模拟",
       "title": "过往照片和AI发型模拟",
+      "empty": "暂无 AI 发型模拟结果",
       "items": [
         { "label": "到店前", "title": "到店时记录", "alt": "到店时的照片" },
         { "label": "AI发型模拟", "title": "AI发型模拟结果", "alt": "AI发型模拟结果" },
@@ -602,6 +608,23 @@
     "regenerate": "重新生成",
     "badge": "生成结果",
     "lead": "展示顾客照片套用所选发型模板后的生成结果。可进入各方案微调,或保存到本次施术记录。",
+    "anonymousTitle": "匿名模拟",
+    "anonymousLead": "当前任务未绑定顾客档案。",
+    "counters": {
+      "total": "总模板数",
+      "processing": "处理中",
+      "success": "成功",
+      "failed": "失败"
+    },
+    "source": {
+      "stylist": "我的模板",
+      "system": "系统模板"
+    },
+    "card": {
+      "processing": "正在生成中,请稍候查看当前方案效果。",
+      "success": "已完成生成,可查看当前方案效果。",
+      "failed": "生成失败,请返回后重试。"
+    },
     "state": {
       "pending": {
         "badge": "任务状态",
@@ -625,16 +648,16 @@
       }
     },
     "original": {
-      "badge": "原图",
-      "title": "原图",
+      "badge": "顾客照片",
+      "title": "本次完成效果",
       "customer": "顾客",
       "generatedAt": "生成时间",
       "count": "生成数量",
       "countValue": "{{count}}个方案"
     },
     "results": {
-      "badge": "生成结果",
-      "title": "生成结果 {{count}}个方案",
+      "badge": "未来变化",
+      "title": "剪发后变化预测结果",
       "count": "{{count}}个方案",
       "templatePrefix": "模板",
       "adjust": "微调",
@@ -643,12 +666,17 @@
   },
   "aftercareResultPage": {
     "title": "剪发后变化预测结果",
-    "description": "Aitori Stylist剪发后变化预测结果页面。确认顾客居家造型、30天后和60天后的发型变化,并保存下次到店建议、推荐施术和预约状态。",
-    "back": "修改条件",
+    "description": "Aitori Stylist 剪发后变化预测结果页面。当前版本仅保留未开发壳页面,后续 Case 再接入真实 aftercare 流程。",
+    "back": "返回AI发型模拟",
     "regenerate": "重新生成",
     "badge": "剪发后变化预测",
     "heading": "下次到店建议",
-    "lead": "确认剪发后的变化预测、下次到店推荐日期和建议项目。可根据顾客日程手动调整预约状态和建议日期。",
+    "lead": "aftercare 功能当前版本暂未开发。本次仅交付 AI 发型模拟主流程,后续再补充术后变化预测与建议。",
+    "state": {
+      "title": "当前版本暂未开发",
+      "description": "请返回 AI 发型模拟主流程继续操作。aftercare 能力将在后续 Case 中单独实现。",
+      "action": "返回AI发型模拟"
+    },
     "original": {
       "badge": "顾客照片",
       "title": "本次完成效果",
@@ -684,6 +712,32 @@
       "back": "返回"
     }
   },
+  "simulationCreatePage": {
+    "title": "AI发型模拟",
+    "description": "Aitori Stylist AI发型模拟页面。可选绑定顾客、上传原图并选择发型模板发起模拟任务。",
+    "badge": "AI发型模拟",
+    "lead": "上传顾客原图并选择模板后,即可创建新的 AI 发型模拟任务。",
+    "back": "返回首页",
+    "submit": "发起模拟",
+    "bindCustomer": "绑定顾客",
+    "noCustomer": "不绑定顾客",
+    "customerLabel": "顾客",
+    "customerPlaceholder": "请选择顾客",
+    "upload": "上传顾客照片",
+    "uploadHint": "支持 JPG / PNG",
+    "remark": "模拟备注",
+    "remarkPlaceholder": "可选填写本次模拟偏好",
+    "myTemplates": "我的模板",
+    "systemTemplates": "系统模板",
+    "selectedCount": "已选 {{count}} 个模板",
+    "emptyTemplates": "暂无模板",
+    "errors": {
+      "originRequired": "请先上传顾客照片",
+      "templateRequired": "请至少选择一个模板",
+      "customerRequired": "请选择顾客",
+      "submitFailed": "发起模拟失败,请稍后重试"
+    }
+  },
   "stylistSimulationPage": {
     "title": "AI发型模拟",
     "description": "Aitori Stylist AI发型模拟页面。选择顾客信息、上传顾客照片、选择发型模板或剪发后变化预测,即可生成模拟结果。",

+ 53 - 17
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { getCustomerById, type CustomerDetailEntity } from '../../../services/api/customers'
+import type { CustomerSimulationGalleryItem } from '../../../services/api/contracts'
+import { listCustomerSimulationGallery } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
@@ -9,21 +11,45 @@ function getDisplayValue(value?: string) {
   return value?.trim() ? value : '—'
 }
 
-type PhotoPlaceholderItem = {
-  label: string
-  title: string
-  alt: string
+function formatDisplayDateTime(value?: string, locale?: string) {
+  if (!value) return '—'
+
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) {
+    return value
+  }
+
+  return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+  }).format(date)
 }
 
 export function CustomerDetailPage() {
   const { locale, path, copy } = usePageContext()
   const { customerId } = useParams()
   const [customer, setCustomer] = useState<CustomerDetailEntity | null>(null)
+  const [galleryItems, setGalleryItems] = useState<CustomerSimulationGalleryItem[]>([])
   const pageCopy = copy.customerDetailPage
 
   useEffect(() => {
     if (!customerId) return
-    getCustomerById(customerId).then(setCustomer).catch(() => setCustomer(null))
+
+    Promise.all([
+      getCustomerById(customerId),
+      listCustomerSimulationGallery(customerId),
+    ])
+      .then(([customerData, galleryData]) => {
+        setCustomer(customerData)
+        setGalleryItems(galleryData.items)
+      })
+      .catch(() => {
+        setCustomer(null)
+        setGalleryItems([])
+      })
   }, [customerId])
 
   if (!customer) {
@@ -40,7 +66,6 @@ export function CustomerDetailPage() {
     getDisplayValue(customer.notice) === '—' ? pageCopy.basic.fallbacks[3] : getDisplayValue(customer.notice),
   ]
   const allergyValue = getDisplayValue(customer.allergy)
-  const photoPlaceholders = (pageCopy.photos.items ?? []) as PhotoPlaceholderItem[]
   const followContentValue = customer.followContent?.trim() ? customer.followContent : (pageCopy.states.empty || '未填写')
 
   return (
@@ -61,9 +86,9 @@ export function CustomerDetailPage() {
               <Link to={path(`/customers/${customer.id}/edit`)} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
                 <i className="feather icon-feather-edit-3 me-8px"></i>{pageCopy.edit}
               </Link>
-              <button type="button" disabled className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn">
+              <Link to={path(`/simulation?customerId=${customer.id}`)} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn">
                 <i className="feather icon-feather-camera"></i>{pageCopy.simulate}
-              </button>
+              </Link>
             </div>
           </div>
 
@@ -154,19 +179,30 @@ export function CustomerDetailPage() {
                 <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.photos.badge}</span>
                 <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.photos.title}</h2>
               </div>
-              <button type="button" disabled className="btn btn-small btn-rounded btn-base-color fw-700">
+              <Link to={path(`/simulation?customerId=${customer.id}`)} className="btn btn-small btn-rounded btn-base-color fw-700">
                 <i className="feather icon-feather-camera me-6px"></i>{pageCopy.simulate}
-              </button>
+              </Link>
             </div>
             <div className="customer-photo-grid">
-              {photoPlaceholders.map((item, index) => (
-                <div key={`${item.label}-${index}`} className="customer-photo-card">
-                  <div className="customer-photo-placeholder">
-                    <span>{pageCopy.states.undeveloped}</span>
-                  </div>
-                  <div><span>{item.label}</span><strong>{item.title}</strong></div>
+              {galleryItems.length ? (
+                galleryItems.map((item) => (
+                  <Link
+                    key={`${item.simulationId}-${item.taskId}-${item.imageUrl}`}
+                    to={path(`/simulation/result/${item.simulationId}`)}
+                    className="customer-photo-card customer-photo-card-link"
+                  >
+                    <img src={item.imageUrl} alt={item.templateName} />
+                    <div>
+                      <span>{formatDisplayDateTime(item.createdAt, locale)}</span>
+                      <strong>{item.templateName}</strong>
+                    </div>
+                  </Link>
+                ))
+              ) : (
+                <div className="customer-photo-empty">
+                  <span>{pageCopy.photos.empty || '暂无 AI 发型模拟结果'}</span>
                 </div>
-              ))}
+              )}
             </div>
           </div>
         </div>

+ 84 - 1
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -5,11 +5,16 @@ import { vi, describe, it, expect } from 'vitest'
 import { CustomerDetailPage } from '../CustomerDetailPage'
 
 const getCustomerById = vi.fn()
+const listCustomerSimulationGallery = vi.fn()
 
 vi.mock('../../../../services/api/customers', () => ({
   getCustomerById: (...args: unknown[]) => getCustomerById(...args),
 }))
 
+vi.mock('../../../../services/api/simulations', () => ({
+  listCustomerSimulationGallery: (...args: unknown[]) => listCustomerSimulationGallery(...args),
+}))
+
 vi.mock('../../../../shared/pageContext', async () => {
   const actual = await vi.importActual<typeof import('../../../../shared/pageContext')>('../../../../shared/pageContext')
   return {
@@ -40,6 +45,7 @@ vi.mock('../../../../shared/pageContext', async () => {
           photos: {
             badge: '照片与模拟',
             title: '过往照片和AI发型模拟',
+            empty: '暂无 AI 发型模拟结果',
             items: [
               { label: '到店前', title: '到店时记录', alt: '到店时的照片' },
               { label: 'AI发型模拟', title: 'AI发型模拟结果', alt: 'AI发型模拟结果' },
@@ -84,6 +90,9 @@ describe('CustomerDetailPage', () => {
       avatar: 'https://example.com/avatar.png',
       visitFrequencyLabel: '约 2 个月',
     })
+    listCustomerSimulationGallery.mockResolvedValue({
+      items: [],
+    })
 
     render(
       <HelmetProvider>
@@ -106,7 +115,8 @@ describe('CustomerDetailPage', () => {
     expect(nextVisitCard).not.toBeNull()
     expect(within(nextVisitCard as HTMLElement).queryByText('当前版本暂未开发')).not.toBeInTheDocument()
     expect(screen.getByRole('button', { name: '发送提醒(未开发)' })).toBeDisabled()
-    expect(screen.getAllByRole('button', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
+    expect(screen.getAllByRole('link', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
+    expect(screen.getByText('暂无 AI 发型模拟结果')).toBeInTheDocument()
   })
 
   it('renders 未填写 when followContent is empty', async () => {
@@ -119,6 +129,9 @@ describe('CustomerDetailPage', () => {
       followContent: '',
       visitFrequencyLabel: '未记录',
     })
+    listCustomerSimulationGallery.mockResolvedValue({
+      items: [],
+    })
 
     render(
       <HelmetProvider>
@@ -136,4 +149,74 @@ describe('CustomerDetailPage', () => {
 
     expect(screen.getByText('未填写')).toBeInTheDocument()
   })
+
+  it('navigates to simulation create page with customerId', async () => {
+    getCustomerById.mockResolvedValue({
+      id: '5',
+      name: 'Mika',
+      phone: '090-1111-2222',
+      nextFollowDate: '2026-06-30',
+      recommendVisitDate: '2026-07-10',
+      followContent: 'follow',
+      visitFrequencyLabel: '未记录',
+    })
+    listCustomerSimulationGallery.mockResolvedValue({
+      items: [],
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/5']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('Mika')).toBeInTheDocument()
+    })
+
+    expect(
+      screen.getAllByRole('link', { name: 'AI发型模拟' })[0],
+    ).toHaveAttribute('href', '/zh/simulation?customerId=5')
+  })
+
+  it('renders gallery cards and links each image card to simulation detail', async () => {
+    getCustomerById.mockResolvedValue({
+      id: '5',
+      name: 'Mika',
+      phone: '090-1111-2222',
+      nextFollowDate: '2026-06-30',
+      recommendVisitDate: '2026-07-10',
+      followContent: 'follow',
+      visitFrequencyLabel: '未记录',
+    })
+    listCustomerSimulationGallery.mockResolvedValue({
+      items: [
+        {
+          simulationId: '91',
+          taskId: 'task-11',
+          imageUrl: 'https://cdn.example.com/result-1.png',
+          templateName: 'Layer Bob',
+          createdAt: '2026-06-28T09:00:00.000Z',
+          taskStatus: 'SUCCESSES',
+        },
+      ],
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/5']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const card = await screen.findByRole('link', { name: /Layer Bob/i })
+    expect(card).toHaveAttribute('href', '/zh/simulation/result/91')
+  })
 })