Ver Fonte

feat: complete customer detail and form flow

ChenYu há 4 meses atrás
pai
commit
4c09feeaaa

+ 2 - 0
backend/prisma/migrations/20260628050000_add_customer_follow_content/migration.sql

@@ -0,0 +1,2 @@
+ALTER TABLE `customer`
+  ADD COLUMN `follow_content` TEXT NULL COMMENT '跟进内容';

+ 1 - 0
backend/prisma/schema.prisma

@@ -84,6 +84,7 @@ model Customer {
   notice             String?                  @db.Text
   allergy            String                   @default("") @db.VarChar(500)
   innerRemark        String?                  @map("inner_remark") @db.Text
+  followContent      String?                  @map("follow_content") @db.Text
   nextFollowDate     DateTime?                @map("next_follow_date") @db.Date
   recommendVisitDate DateTime?                @map("recommend_visit_date") @db.Date
   firstSuggestHair   CustomerFirstSuggestHair @default(AUTO) @map("first_suggest_hair")

+ 9 - 0
backend/src/modules/customers/customers.service.spec.ts

@@ -63,6 +63,7 @@ describe('CustomersService', () => {
       notice: null,
       allergy: '',
       innerRemark: null,
+      followContent: '确认褪色情况并提醒护理',
       nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
       recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
       firstSuggestHair: 'AUTO',
@@ -93,6 +94,7 @@ describe('CustomersService', () => {
         trouble: '发量少',
         consultContent: '希望有轻盈感',
         expectLook: '自然层次感',
+        followContent: '确认褪色情况并提醒护理',
       } as never,
     );
 
@@ -108,6 +110,7 @@ describe('CustomersService', () => {
           hairTexture: 'NORMAL',
           nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
           recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
+          followContent: '确认褪色情况并提醒护理',
           planDate: new Date('2026-06-28T00:00:00.000Z'),
           isFirstRegister: 1,
           visitPurpose: 'CONSULT_SIM',
@@ -194,6 +197,7 @@ describe('CustomersService', () => {
       notice: '注意保湿',
       allergy: '无',
       innerRemark: '常客',
+      followContent: '确认褪色情况并提醒护理',
       nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
       recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
       firstSuggestHair: 'AUTO',
@@ -219,6 +223,7 @@ describe('CustomersService', () => {
       visitFrequencyLabel: '未记录',
       recommendVisitDate: '2026-07-20',
       nextFollowDate: '2026-07-12',
+      followContent: '确认褪色情况并提醒护理',
     });
   });
 
@@ -238,6 +243,7 @@ describe('CustomersService', () => {
       notice: null,
       allergy: '',
       innerRemark: null,
+      followContent: null,
       nextFollowDate: null,
       recommendVisitDate: null,
       firstSuggestHair: 'AUTO',
@@ -283,6 +289,7 @@ describe('CustomersService', () => {
       notice: null,
       allergy: '',
       innerRemark: null,
+      followContent: '改约下次染发并确认可联系时间',
       nextFollowDate: null,
       recommendVisitDate: null,
       firstSuggestHair: 'AUTO',
@@ -307,6 +314,7 @@ describe('CustomersService', () => {
         expectContact: 'PHONE',
         hairLength: 'LONG',
         hairTexture: 'CURLY',
+        followContent: '改约下次染发并确认可联系时间',
       } as never,
     );
 
@@ -319,6 +327,7 @@ describe('CustomersService', () => {
           expectContact: 'PHONE',
           hairLength: 'LONG',
           hairTexture: 'CURLY',
+          followContent: '改约下次染发并确认可联系时间',
         }),
       }),
     );

+ 4 - 0
backend/src/modules/customers/customers.service.ts

@@ -30,6 +30,7 @@ type CustomerRecord = {
   notice: string | null;
   allergy: string;
   innerRemark: string | null;
+  followContent: string | null;
   nextFollowDate: Date | null;
   recommendVisitDate: Date | null;
   firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT';
@@ -98,6 +99,7 @@ export class CustomersService {
         notice: body.notice?.trim() || null,
         allergy: body.allergy?.trim() ?? '',
         innerRemark: body.innerRemark?.trim() || null,
+        followContent: body.followContent?.trim() || null,
         nextFollowDate: parseDateOnly(body.nextFollowDate),
         recommendVisitDate: parseDateOnly(body.recommendVisitDate),
         firstSuggestHair: body.firstSuggestHair ?? 'AUTO',
@@ -224,6 +226,7 @@ export class CustomersService {
         notice: body.notice?.trim() || null,
         allergy: body.allergy?.trim() ?? '',
         innerRemark: body.innerRemark?.trim() || null,
+        followContent: body.followContent?.trim() || null,
         nextFollowDate: parseDateOnly(body.nextFollowDate),
         recommendVisitDate: parseDateOnly(body.recommendVisitDate),
         firstSuggestHair: body.firstSuggestHair ?? 'AUTO',
@@ -324,6 +327,7 @@ export class CustomersService {
       notice: customer.notice ?? undefined,
       allergy: customer.allergy || undefined,
       innerRemark: customer.innerRemark ?? undefined,
+      followContent: customer.followContent ?? undefined,
       nextFollowDate: formatDateOnly(customer.nextFollowDate),
       recommendVisitDate: formatDateOnly(customer.recommendVisitDate),
       firstSuggestHair: customer.firstSuggestHair,

+ 4 - 0
backend/src/modules/customers/dto/customer-upsert.dto.ts

@@ -47,6 +47,10 @@ export class CustomerUpsertDto {
   @IsString()
   innerRemark?: string;
 
+  @IsOptional()
+  @IsString()
+  followContent?: string;
+
   @IsOptional()
   @IsString()
   nextFollowDate?: string;

+ 6 - 0
backend/test/app.e2e-spec.ts

@@ -155,6 +155,7 @@ describe('App (e2e)', () => {
         trouble: '发量少',
         consultContent: '希望有轻盈感',
         expectLook: '自然层次感',
+        followContent: '确认褪色情况并提醒护理',
       })
       .expect(201);
 
@@ -164,6 +165,7 @@ describe('App (e2e)', () => {
     expect(response.body.data.avatar).toBe(
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
     );
+    expect(response.body.data.followContent).toBe('确认褪色情况并提醒护理');
   });
 
   it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => {
@@ -257,6 +259,7 @@ describe('App (e2e)', () => {
         phone: `091-${String(uniqueSuffix).slice(-4)}-3333`,
         recommendVisitDate: '2026-07-22',
         nextFollowDate: '2026-07-15',
+        followContent: '请提前确认发色退色情况',
       })
       .expect(201);
 
@@ -268,6 +271,7 @@ describe('App (e2e)', () => {
     expect(detail.body.ok).toBe(true);
     expect(detail.body.data.name).toBe(customerName);
     expect(detail.body.data.visitFrequencyLabel).toBe('未记录');
+    expect(detail.body.data.followContent).toBe('请提前确认发色退色情况');
   });
 
   it('/api/v1/customers/:id (PATCH) updates customer detail', async () => {
@@ -292,6 +296,7 @@ describe('App (e2e)', () => {
         phone: `093-${String(uniqueSuffix).slice(-4)}-5555`,
         hairLength: 'LONG',
         hairTexture: 'CURLY',
+        followContent: '更新后的跟进内容',
       })
       .expect(200);
 
@@ -299,6 +304,7 @@ describe('App (e2e)', () => {
     expect(updated.body.data.name).toBe(`${customerName}-updated`);
     expect(updated.body.data.phone).toBe(`093-${String(uniqueSuffix).slice(-4)}-5555`);
     expect(updated.body.data.hairLength).toBe('LONG');
+    expect(updated.body.data.followContent).toBe('更新后的跟进内容');
   });
 
   it('/api/v1/templates (GET) returns an empty list envelope', async () => {

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

@@ -410,7 +410,9 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
 }
 .customer-row-action {
     display: flex;
+    align-items: center;
     justify-content: flex-end;
+    justify-self: end;
 }
 .customer-avatar {
     width: 42px;
@@ -589,6 +591,17 @@ button.customer-pager-link {
     object-fit: cover;
     display: block;
 }
+.customer-photo-placeholder {
+    width: 100%;
+    aspect-ratio: 4 / 5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #f7f7f8;
+    color: var(--medium-gray);
+    font-size: 14px;
+    font-weight: 700;
+}
 .customer-photo-card div {
     padding: 16px;
 }

+ 22 - 22
frontend/src/i18n/locales/ja/translation.json

@@ -535,7 +535,7 @@
       "nextVisitNew": "おすすめ来店日",
       "nextVisitEdit": "フォロー状況",
       "templateNew": "初回提案テンプレート",
-      "templateEdit": "連絡先",
+      "templateEdit": "フォロー連絡先",
       "messageNew": "フォローメモ",
       "messageEdit": "フォロー内容",
       "timingOptions": ["30日後", "14日後", "60日後", "設定しない"],
@@ -554,9 +554,10 @@
   },
   "customerDetailPage": {
     "title": "顧客詳細",
-    "description": "Aitori Stylist の顧客詳細ページ。顧客の基本情報、カウンセリング内容、髪情報、次回来店予定を確認できます。",
+    "description": "Aitori Stylist の顧客詳細ページ。顧客カルテ、施術履歴、写真、AI シミュレーション履歴、次回来店提案を確認できます。",
     "back": "顧客一覧へ戻る",
-    "edit": "顧客を編集",
+    "edit": "プロフィール編集",
+    "simulate": "AIヘアシミュレーション",
     "badge": "顧客プロフィール",
     "nextVisit": {
       "badge": "次回来店",
@@ -564,35 +565,34 @@
       "action": "リマインド送信(未開発)"
     },
     "states": {
-      "undeveloped": "現在のバージョンでは未開発です"
+      "undeveloped": "現在のバージョンでは未開発です",
+      "empty": "未入力"
     },
     "basic": {
       "badge": "基本情報",
       "title": "基本情報",
-      "labels": ["電話番号", "メール", "誕生日", "希望連絡手段", "来店頻度"]
-    },
-    "consultation": {
-      "badge": "カウンセリング",
-      "title": "カウンセリング情報",
-      "labels": ["来店予定", "来店目的", "悩み", "相談内容", "希望スタイル"]
-    },
-    "hair": {
-      "badge": "髪情報",
-      "title": "髪情報",
-      "labels": ["長さ", "髪質", "注意事項", "アレルギー", "内部メモ"]
+      "labels": ["電話番号", "メールアドレス", "生年月日", "来店頻度", "髪質", "薬剤メモ"],
+      "fallbacks": ["1994/08/18", "4〜6週間", "細毛 / 軽いうねり / 乾燥しやすい", "8Lvベージュ、赤み抑制、低刺激優先"]
     },
     "history": {
       "badge": "施術履歴",
-      "title": "施術履歴"
+      "title": "施術履歴",
+      "action": "記録を追加"
     },
     "photos": {
-      "badge": "過去写真",
-      "title": "過去写真"
+      "badge": "写真とシミュレーション",
+      "title": "過去写真とAIヘアシミュレーション",
+      "items": [
+        { "label": "来店前", "title": "来店時記録", "alt": "来店時の写真" },
+        { "label": "AIヘアシミュレーション", "title": "AIヘアシミュレーション結果", "alt": "AIヘアシミュレーション結果" },
+        { "label": "仕上がり", "title": "仕上がり記録", "alt": "仕上がり写真" },
+        { "label": "フォロー", "title": "30〜60日後提案", "alt": "次回提案シミュレーション" }
+      ]
     },
-    "simulation": {
-      "badge": "AI ヘアシミュレーション",
-      "title": "AI ヘアシミュレーション",
-      "action": "シミュレーション開始(未開発)"
+    "cta": {
+      "badge": "カウンセリング",
+      "title": "次回の相談と施術を始める",
+      "lead": "前回の写真、AI シミュレーション結果、薬剤メモを引き継いで新しい提案を作成します。"
     }
   },
   "simulationResultPage": {

+ 22 - 22
frontend/src/i18n/locales/zh/translation.json

@@ -535,7 +535,7 @@
       "nextVisitNew": "推荐到店日",
       "nextVisitEdit": "跟进状态",
       "templateNew": "首次建议发型模板",
-      "templateEdit": "联系方式",
+      "templateEdit": "跟进联系方式",
       "messageNew": "跟进备注",
       "messageEdit": "跟进",
       "timingOptions": ["30天后", "14天后", "60天后", "不设置"],
@@ -554,9 +554,10 @@
   },
   "customerDetailPage": {
     "title": "顾客详情",
-    "description": "Aitori Stylist 顾客详情页面。可查看顾客基础信息、咨询内容、头发信息与下次到店安排。",
+    "description": "Aitori Stylist 顾客详情页面。可确认顾客档案、施术历史、照片、AI 发型模拟历史和下次到店建议。",
     "back": "返回顾客列表",
-    "edit": "编辑顾客",
+    "edit": "编辑档案",
+    "simulate": "AI发型模拟",
     "badge": "顾客档案",
     "nextVisit": {
       "badge": "下次到店",
@@ -564,35 +565,34 @@
       "action": "发送提醒(未开发)"
     },
     "states": {
-      "undeveloped": "当前版本暂未开发"
+      "undeveloped": "当前版本暂未开发",
+      "empty": "未填写"
     },
     "basic": {
       "badge": "基本资料",
       "title": "基本资料",
-      "labels": ["电话号码", "邮箱", "生日", "期望联系", "到店频率"]
-    },
-    "consultation": {
-      "badge": "咨询信息",
-      "title": "咨询信息",
-      "labels": ["到店计划", "来店目的", "烦恼", "咨询内容", "期待造型"]
-    },
-    "hair": {
-      "badge": "头发信息",
-      "title": "头发信息",
-      "labels": ["发长", "发质", "注意事项", "过敏备注", "内部备注"]
+      "labels": ["电话号码", "邮箱", "生日", "到店频率", "发质", "药剂备注"],
+      "fallbacks": ["1994/08/18", "4〜6周", "细软 / 轻微发流 / 容易干燥", "8Lv米色、红调抑制、低刺激优先"]
     },
     "history": {
       "badge": "施术历史",
-      "title": "施术历史"
+      "title": "施术历史",
+      "action": "添加记录"
     },
     "photos": {
-      "badge": "过往照片",
-      "title": "过往照片"
+      "badge": "照片与模拟",
+      "title": "过往照片和AI发型模拟",
+      "items": [
+        { "label": "到店前", "title": "到店时记录", "alt": "到店时的照片" },
+        { "label": "AI发型模拟", "title": "AI发型模拟结果", "alt": "AI发型模拟结果" },
+        { "label": "完成后", "title": "完成效果记录", "alt": "完成效果照片" },
+        { "label": "跟进", "title": "30〜60天后建议", "alt": "下次建议用模拟" }
+      ]
     },
-    "simulation": {
-      "badge": "AI 发型模拟",
-      "title": "AI 发型模拟",
-      "action": "开始模拟(未开发)"
+    "cta": {
+      "badge": "咨询",
+      "title": "开始下一次咨询与施术",
+      "lead": "沿用上次照片、AI发型模拟结果和药剂备注,创建新的发型建议。"
     }
   },
   "simulationResultPage": {

+ 102 - 75
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -9,17 +9,10 @@ function getDisplayValue(value?: string) {
   return value?.trim() ? value : '—'
 }
 
-function InfoList({ labels, values }: { labels: string[]; values: string[] }) {
-  return (
-    <div className="customer-info-list">
-      {labels.map((label, index) => (
-        <div key={label}>
-          <span>{label}</span>
-          <strong>{values[index]}</strong>
-        </div>
-      ))}
-    </div>
-  )
+type PhotoPlaceholderItem = {
+  label: string
+  title: string
+  alt: string
 }
 
 export function CustomerDetailPage() {
@@ -37,6 +30,19 @@ export function CustomerDetailPage() {
     return null
   }
 
+  const summaryText = customer.trouble || customer.consultContent || customer.expectLook || pageCopy.nextVisit.lead
+  const basicValues = [
+    getDisplayValue(customer.phone),
+    getDisplayValue(customer.email),
+    getDisplayValue(customer.birthday),
+    getDisplayValue(customer.visitFrequencyLabel),
+    [customer.hairLength, customer.hairTexture].filter(Boolean).join(' / ') || pageCopy.basic.fallbacks[2],
+    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 (
     <DashboardLayout>
       <Helmet>
@@ -51,9 +57,14 @@ export function CustomerDetailPage() {
             <Link to={path('/customers')} 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>
-            <Link to={path(`/customers/${customer.id}/edit`)} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">
-              <i className="feather icon-feather-edit-3 me-8px"></i>{pageCopy.edit}
-            </Link>
+            <div className="d-flex flex-wrap gap-2">
+              <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">
+                <i className="feather icon-feather-camera"></i>{pageCopy.simulate}
+              </button>
+            </div>
           </div>
 
           <div className="customer-detail-hero workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
@@ -62,17 +73,17 @@ export function CustomerDetailPage() {
               <div>
                 <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">{customer.name}</h1>
-                <p className="fs-18 lh-30 mb-0">{getDisplayValue(customer.namePronunciation)}</p>
+                <p className="fs-18 lh-30 mb-0">{summaryText}</p>
               </div>
             </div>
             <div className="customer-next-visit">
               <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.nextVisit.badge}</span>
               <strong>{getDisplayValue(customer.recommendVisitDate)}</strong>
               <p>{pageCopy.nextVisit.lead.replace('{{date}}', getDisplayValue(customer.nextFollowDate))}</p>
-              <button type="button" disabled className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+              <p>{followContentValue}</p>
+              <button type="button" disabled className="btn btn-small btn-rounded btn-base-color fw-700">
                 {pageCopy.nextVisit.action}
               </button>
-              <p className="fs-14 text-medium-gray mt-10px mb-0">{pageCopy.states.undeveloped}</p>
             </div>
           </div>
         </div>
@@ -85,77 +96,93 @@ export function CustomerDetailPage() {
               <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
                 <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.basic.badge}</span>
                 <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.basic.title}</h2>
-                <InfoList
-                  labels={pageCopy.basic.labels}
-                  values={[
-                    getDisplayValue(customer.phone),
-                    getDisplayValue(customer.email),
-                    getDisplayValue(customer.birthday),
-                    getDisplayValue(customer.expectContact),
-                    getDisplayValue(customer.visitFrequencyLabel),
-                  ]}
-                />
+                <div className="customer-info-list">
+                  {pageCopy.basic.labels.map((label: string, index: number) => (
+                    <div key={label}>
+                      <span>{label}</span>
+                      <strong>{basicValues[index]}</strong>
+                    </div>
+                  ))}
+                  {allergyValue !== '—' ? (
+                    <div>
+                      <span>{copy.customerFormPage.hair.fieldEditRight}</span>
+                      <strong>{allergyValue}</strong>
+                    </div>
+                  ) : null}
+                </div>
               </div>
             </div>
 
             <div className="col-xl-8 col-lg-7">
               <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
-                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.consultation.badge}</span>
-                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.consultation.title}</h2>
-                <InfoList
-                  labels={pageCopy.consultation.labels}
-                  values={[
-                    getDisplayValue(customer.planDate),
-                    getDisplayValue(customer.visitPurpose),
-                    getDisplayValue(customer.trouble),
-                    getDisplayValue(customer.consultContent),
-                    getDisplayValue(customer.expectLook),
-                  ]}
-                />
+                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
+                  <div>
+                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.history.badge}</span>
+                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.history.title}</h2>
+                  </div>
+                  <button type="button" disabled className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+                    {pageCopy.history.action}
+                  </button>
+                </div>
+                <div className="customer-history">
+                  <div className="customer-history-item">
+                    <span className="customer-history-date">{getDisplayValue(customer.planDate)}</span>
+                    <div>
+                      <strong>{getDisplayValue(customer.visitPurpose)}</strong>
+                      <p>{pageCopy.states.undeveloped}</p>
+                    </div>
+                  </div>
+                  <div className="customer-history-item">
+                    <span className="customer-history-date">{getDisplayValue(customer.nextFollowDate)}</span>
+                    <div>
+                      <strong>{getDisplayValue(customer.trouble)}</strong>
+                      <p>{pageCopy.states.undeveloped}</p>
+                    </div>
+                  </div>
+                </div>
               </div>
             </div>
           </div>
+        </div>
+      </section>
 
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
-            <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.hair.badge}</span>
-            <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.hair.title}</h2>
-            <InfoList
-              labels={pageCopy.hair.labels}
-              values={[
-                getDisplayValue(customer.hairLength),
-                getDisplayValue(customer.hairTexture),
-                getDisplayValue(customer.notice),
-                getDisplayValue(customer.allergy),
-                getDisplayValue(customer.innerRemark),
-              ]}
-            />
-          </div>
-
-          <div className="row g-4 mt-1">
-            <div className="col-lg-4">
-              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
-                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.history.badge}</span>
-                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.history.title}</h2>
-                <p className="fs-15 text-medium-gray mb-0">{pageCopy.states.undeveloped}</p>
-              </div>
-            </div>
-            <div className="col-lg-4">
-              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+      <section className="pt-0">
+        <div className="container">
+          <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">
+              <div>
                 <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-25px">{pageCopy.photos.title}</h2>
-                <p className="fs-15 text-medium-gray mb-0">{pageCopy.states.undeveloped}</p>
+                <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">
+                <i className="feather icon-feather-camera me-6px"></i>{pageCopy.simulate}
+              </button>
             </div>
-            <div className="col-lg-4">
-              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
-                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.simulation.badge}</span>
-                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.simulation.title}</h2>
-                <button type="button" disabled className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
-                  {pageCopy.simulation.action}
-                </button>
-                <p className="fs-15 text-medium-gray mt-15px mb-0">{pageCopy.states.undeveloped}</p>
-              </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>
+                </div>
+              ))}
+            </div>
+          </div>
+        </div>
+      </section>
+
+      <section className="pt-0">
+        <div className="container">
+          <div className="customer-detail-cta workbench-panel border-radius-8px p-30px">
+            <div>
+              <span className="fs-14 fw-700 text-white text-uppercase">{pageCopy.cta.badge}</span>
+              <h2 className="fw-700 text-white fs-30 mb-10px">{pageCopy.cta.title}</h2>
+              <p className="text-white opacity-8 mb-0">{pageCopy.states.undeveloped}</p>
             </div>
+            <button type="button" disabled className="btn btn-large btn-rounded bg-white text-dark-gray fw-700">
+              <i className="feather icon-feather-play-circle me-8px"></i>{copy.common.start}
+            </button>
           </div>
         </div>
       </section>

+ 23 - 12
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -217,28 +217,39 @@ export function CustomerFormPage() {
               <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
                 <div>
                   <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.follow.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.follow.titleNew}</h2>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.follow.titleEdit}</h2>
                 </div>
-                <span className="workbench-badge">{pageCopy.follow.statusNew}</span>
+                <span className="workbench-badge">{pageCopy.follow.statusEdit}</span>
               </div>
               <div className="row g-3">
                 <div className="col-lg-4 col-md-6">
-                  <label htmlFor="follow-timing" className="customer-edit-label">{pageCopy.follow.timingNew}</label>
-                  <input id="follow-timing" type="date" className="form-control customer-edit-control" value={form.nextFollowDate} onChange={(event) => patch({ nextFollowDate: event.target.value })} />
+                  <label htmlFor="follow-recommend-visit" className="customer-edit-label">{pageCopy.follow.timingEdit}</label>
+                  <input id="follow-recommend-visit" type="date" className="form-control customer-edit-control" value={form.recommendVisitDate} onChange={(event) => patch({ recommendVisitDate: event.target.value })} />
                 </div>
                 <div className="col-lg-4 col-md-6">
-                  <label htmlFor="next-visit" className="customer-edit-label">{pageCopy.follow.nextVisitNew}</label>
-                  <input id="next-visit" type="date" className="form-control customer-edit-control" value={form.recommendVisitDate} onChange={(event) => patch({ recommendVisitDate: event.target.value })} />
+                  <label htmlFor="follow-timing" className="customer-edit-label">{pageCopy.follow.nextVisitNew}</label>
+                  <input id="follow-timing" type="date" className="form-control customer-edit-control" value={form.nextFollowDate} onChange={(event) => patch({ nextFollowDate: event.target.value })} />
                 </div>
                 <div className="col-lg-4 col-md-6">
-                  <label htmlFor="template-source" className="customer-edit-label">{pageCopy.follow.templateNew}</label>
-                  <select id="template-source" className="form-select customer-edit-control" value={form.firstSuggestHair} onChange={(event) => patch({ firstSuggestHair: event.target.value as CustomerFormValues['firstSuggestHair'] })}>
-                    <option value="AUTO">{pageCopy.follow.templateOptions[0]}</option>
-                    <option value="LONG">{pageCopy.follow.templateOptions[1]}</option>
-                    <option value="MEDIUM">{pageCopy.follow.templateOptions[2]}</option>
-                    <option value="SHORT">{pageCopy.follow.templateOptions[3]}</option>
+                  <label htmlFor="follow-contact" className="customer-edit-label">{pageCopy.follow.templateEdit}</label>
+                  <select id="follow-contact" className="form-select customer-edit-control" value={form.expectContact} onChange={(event) => patch({ expectContact: event.target.value as CustomerFormValues['expectContact'] })}>
+                    <option value="LINE">LINE</option>
+                    <option value="PHONE">{pageCopy.follow.contactOptions[1]}</option>
+                    <option value="EMAIL">{pageCopy.follow.contactOptions[2]}</option>
+                    <option value="NONE">{pageCopy.basic.contactNewOptions[3]}</option>
                   </select>
                 </div>
+                <div className="col-12">
+                  <label htmlFor="follow-content" className="customer-edit-label">{pageCopy.follow.messageEdit}</label>
+                  <textarea
+                    id="follow-content"
+                    className="form-control customer-edit-control"
+                    rows={4}
+                    value={form.followContent}
+                    onChange={(event) => patch({ followContent: event.target.value })}
+                    placeholder={pageCopy.follow.messageEdit}
+                  ></textarea>
+                </div>
               </div>
             </div>
 

+ 3 - 1
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -201,7 +201,9 @@ export function CustomerListPage() {
 
             <div className="customer-table">
               <div className="customer-row customer-head">
-                {pageCopy.tableHeaders.map((label: string) => <span key={label}>{label}</span>)}
+                {pageCopy.tableHeaders.map((label: string, index: number) => (
+                  <span key={label} className={index === pageCopy.tableHeaders.length - 1 ? 'customer-row-action' : undefined}>{label}</span>
+                ))}
               </div>
               {items.length === 0 ? (
                 <p className="fs-15 text-medium-gray mb-0 mt-3">{copy.common.empty}</p>

+ 58 - 11
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
 import { MemoryRouter, Route, Routes } from 'react-router-dom'
 import { HelmetProvider } from 'react-helmet-async'
 import { vi, describe, it, expect } from 'vitest'
@@ -19,21 +19,35 @@ vi.mock('../../../../shared/pageContext', async () => {
       path: (value: string) => `/zh${value}`,
       navigate: vi.fn(),
       copy: {
-        common: {},
+        common: { start: '开始' },
+        customerFormPage: { hair: { fieldEditRight: '过敏备注' } },
         customerDetailPage: {
           title: '顾客详情',
           description: 'desc',
           badge: 'DETAIL',
           back: '返回列表',
           edit: '编辑',
-          states: { undeveloped: '当前版本暂未开发' },
+          simulate: 'AI发型模拟',
+          states: { undeveloped: '当前版本暂未开发', empty: '未填写' },
           nextVisit: { badge: '下次到店', lead: '提醒时间:{{date}}', action: '发送提醒(未开发)' },
-          basic: { badge: '基础信息', title: '基础信息', labels: ['电话', '邮箱', '生日', '期望联系', '到店频率'] },
-          consultation: { badge: '咨询信息', title: '咨询信息', labels: ['到店计划', '来店目的', '烦恼', '咨询内容', '期待造型'] },
-          hair: { badge: '头发信息', title: '头发信息', labels: ['发长', '发质', '注意事项', '过敏备注', '内部备注'] },
-          history: { badge: '施术历史', title: '施术历史' },
-          photos: { badge: '过往照片', title: '过往照片' },
-          simulation: { badge: 'AI 发型模拟', title: 'AI 发型模拟', action: '开始模拟(未开发)' },
+          basic: {
+            badge: '基本资料',
+            title: '基本资料',
+            labels: ['电话', '邮箱', '生日', '到店频率', '发质', '药剂备注'],
+            fallbacks: ['1994/08/18', '4〜6周', '细软 / 轻微发流 / 容易干燥', '8Lv米色、红调抑制、低刺激优先'],
+          },
+          history: { badge: '施术历史', title: '施术历史', action: '添加记录' },
+          photos: {
+            badge: '照片与模拟',
+            title: '过往照片和AI发型模拟',
+            items: [
+              { label: '到店前', title: '到店时记录', alt: '到店时的照片' },
+              { label: 'AI发型模拟', title: 'AI发型模拟结果', alt: 'AI发型模拟结果' },
+              { label: '完成后', title: '完成效果记录', alt: '完成效果照片' },
+              { label: '跟进', title: '30〜60天后建议', alt: '下次建议用模拟' },
+            ],
+          },
+          cta: { badge: '咨询', title: '开始下一次咨询与施术', lead: 'lead' },
         },
       },
     }),
@@ -59,6 +73,7 @@ describe('CustomerDetailPage', () => {
       notice: '注意保湿',
       allergy: '无',
       innerRemark: '常客',
+      followContent: '确认褪色情况并提醒护理',
       nextFollowDate: '2026-07-15',
       recommendVisitDate: '2026-07-22',
       planDate: '2026-06-28',
@@ -84,9 +99,41 @@ describe('CustomerDetailPage', () => {
       expect(screen.getByText('Sato Misaki')).toBeInTheDocument()
     })
 
+    const nextVisitCard = screen.getByRole('button', { name: '发送提醒(未开发)' }).closest('.customer-next-visit')
+
     expect(screen.getByText('约 2 个月')).toBeInTheDocument()
-    expect(screen.getAllByText('当前版本暂未开发').length).toBeGreaterThanOrEqual(3)
+    expect(screen.getByText('确认褪色情况并提醒护理')).toBeInTheDocument()
+    expect(nextVisitCard).not.toBeNull()
+    expect(within(nextVisitCard as HTMLElement).queryByText('当前版本暂未开发')).not.toBeInTheDocument()
     expect(screen.getByRole('button', { name: '发送提醒(未开发)' })).toBeDisabled()
-    expect(screen.getByRole('button', { name: '开始模拟(未开发)' })).toBeDisabled()
+    expect(screen.getAllByRole('button', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
+  })
+
+  it('renders 未填写 when followContent is empty', async () => {
+    getCustomerById.mockResolvedValue({
+      id: '9',
+      name: 'Sato Aki',
+      phone: '090-1111-2222',
+      nextFollowDate: '2026-06-30',
+      recommendVisitDate: '2026-07-10',
+      followContent: '',
+      visitFrequencyLabel: '未记录',
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/9']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('Sato Aki')).toBeInTheDocument()
+    })
+
+    expect(screen.getByText('未填写')).toBeInTheDocument()
   })
 })

+ 9 - 2
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -94,13 +94,13 @@ vi.mock('../../../../shared/pageContext', () => ({
           statusNew: '待设置',
           statusEdit: '待设置',
           timingNew: '跟进时间',
-          timingEdit: '跟进时间',
+          timingEdit: '推荐到店日',
           timingOptions: ['今天'],
           nextVisitNew: '下次到店',
           nextVisitEdit: '跟进状态',
           statusOptions: ['需跟进', '已发送'],
           templateNew: '跟进模板',
-          templateEdit: '联系方式',
+          templateEdit: '跟进联系方式',
           contactOptions: ['LINE', '电话', '邮箱'],
           templateOptions: ['需跟进', '已发送'],
           messageNew: '跟进内容',
@@ -153,6 +153,7 @@ describe('CustomerFormPage', () => {
 
     fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } })
     fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0000' } })
+    fireEvent.change(screen.getByLabelText(/跟进内容/i), { target: { value: '确认褪色情况并提醒护理' } })
 
     const file = new File(['avatar'], 'avatar.png', { type: 'image/png' })
     const input = document.querySelector('input[type="file"]') as HTMLInputElement
@@ -169,6 +170,7 @@ describe('CustomerFormPage', () => {
           name: 'Sato Misaki',
           phone: '090-0000-0000',
           avatar: 'https://example.com/avatar.png',
+          followContent: '确认褪色情况并提醒护理',
         }),
       )
     })
@@ -215,6 +217,8 @@ describe('CustomerFormPage', () => {
     expect(
       screen.getByRole('option', { name: '请选择到店目的' }),
     ).toBeInTheDocument()
+    expect(screen.getByLabelText('跟进内容')).toBeInTheDocument()
+    expect(screen.getByLabelText('跟进联系方式')).toBeInTheDocument()
   })
 
   it('loads existing customer data in edit mode and submits update', async () => {
@@ -231,6 +235,7 @@ describe('CustomerFormPage', () => {
       notice: '注意保湿',
       allergy: '无',
       innerRemark: '常客',
+      followContent: '原有跟进内容',
       nextFollowDate: '2026-07-15',
       recommendVisitDate: '2026-07-22',
       firstSuggestHair: 'AUTO',
@@ -259,6 +264,7 @@ describe('CustomerFormPage', () => {
     })
 
     fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0001' } })
+    fireEvent.change(screen.getByLabelText(/跟进内容/i), { target: { value: '更新后的跟进内容' } })
     fireEvent.click(screen.getAllByRole('button', { name: /保存编辑/i })[0])
 
     await waitFor(() => {
@@ -269,6 +275,7 @@ describe('CustomerFormPage', () => {
           phone: '090-0000-0001',
           nextFollowDate: '2026-07-15',
           recommendVisitDate: '2026-07-22',
+          followContent: '更新后的跟进内容',
         }),
       )
     })

+ 1 - 0
frontend/src/services/api/contracts.ts

@@ -20,6 +20,7 @@ export type CustomerEntity = {
   notice?: string
   allergy?: string
   innerRemark?: string
+  followContent?: string
   nextFollowDate?: string
   recommendVisitDate?: string
   firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'

+ 1 - 0
frontend/src/services/api/customers.ts

@@ -25,6 +25,7 @@ export type CustomerUpsertInput = {
   notice?: string
   allergy?: string
   innerRemark?: string
+  followContent?: string
   nextFollowDate?: string
   recommendVisitDate?: string
   firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'

+ 4 - 0
frontend/src/shared/customerForm.ts

@@ -12,6 +12,7 @@ export type CustomerFormValues = {
   notice: string
   allergy: string
   innerRemark: string
+  followContent: string
   nextFollowDate: string
   recommendVisitDate: string
   firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
@@ -35,6 +36,7 @@ export function emptyCustomerForm(): CustomerFormValues {
     notice: '',
     allergy: '',
     innerRemark: '',
+    followContent: '',
     nextFollowDate: '',
     recommendVisitDate: '',
     firstSuggestHair: 'AUTO',
@@ -59,6 +61,7 @@ export function customerToFormValues(customer: CustomerEntity): CustomerFormValu
     notice: customer.notice || '',
     allergy: customer.allergy || '',
     innerRemark: customer.innerRemark || '',
+    followContent: customer.followContent || '',
     nextFollowDate: customer.nextFollowDate || '',
     recommendVisitDate: customer.recommendVisitDate || '',
     firstSuggestHair: customer.firstSuggestHair || 'AUTO',
@@ -86,6 +89,7 @@ export function formValuesToPayload(values: CustomerFormValues, avatar?: string)
     notice: pick(values.notice),
     allergy: pick(values.allergy),
     innerRemark: pick(values.innerRemark),
+    followContent: pick(values.followContent),
     nextFollowDate: pick(values.nextFollowDate),
     recommendVisitDate: pick(values.recommendVisitDate),
     firstSuggestHair: values.firstSuggestHair,