Ver Fonte

feat: polish dashboard customer and template flows

ChenYu há 3 meses atrás
pai
commit
cc50babcbd
25 ficheiros alterados com 546 adições e 391 exclusões
  1. 2 1
      backend/src/modules/customers/customers.service.ts
  2. 93 4
      frontend/public/css/aitori.css
  3. 5 0
      frontend/public/images/default-user-avatar.svg
  4. 3 10
      frontend/src/i18n/locales/ja/translation.json
  5. 16 21
      frontend/src/i18n/locales/zh/translation.json
  6. 3 57
      frontend/src/pages/dashboard/DashboardPage.tsx
  7. 34 3
      frontend/src/pages/dashboard/__tests__/DashboardPage.test.tsx
  8. 25 81
      frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
  9. 7 47
      frontend/src/pages/dashboard/customers/CustomerFormPage.tsx
  10. 11 57
      frontend/src/pages/dashboard/customers/CustomerListPage.tsx
  11. 13 8
      frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
  12. 13 10
      frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx
  13. 14 16
      frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx
  14. 50 7
      frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
  15. 3 2
      frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx
  16. 56 5
      frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx
  17. 2 0
      frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
  18. 66 14
      frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
  19. 24 18
      frontend/src/pages/dashboard/templates/TemplateListPage.tsx
  20. 5 8
      frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  21. 39 10
      frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx
  22. 25 9
      frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx
  23. 34 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
  24. 0 3
      frontend/src/shared/customerForm.ts
  25. 3 0
      frontend/src/shared/uiAssets.ts

+ 2 - 1
backend/src/modules/customers/customers.service.ts

@@ -202,7 +202,7 @@ export class CustomersService {
         hairstylistId: stylistId,
         isDeleted: 0,
       },
-      select: { id: true },
+      select: { id: true, avatar: true },
     });
 
     if (!existing) {
@@ -219,6 +219,7 @@ export class CustomersService {
         birthday: parseDateOnly(body.birthday),
         avatar:
           body.avatar?.trim() ||
+          existing.avatar ||
           this.configService.getOrThrow<string>('customer.defaultAvatarUrl'),
         expectContact: body.expectContact ?? 'NONE',
         hairLength: body.hairLength ?? 'UNCONFIRM',

+ 93 - 4
frontend/public/css/aitori.css

@@ -382,13 +382,20 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     border: 1px solid rgba(44, 46, 60, 0.08);
     background-color: #f7f7f8;
 }
+.customer-list-filter-grid {
+    justify-content: space-between;
+}
+.customer-list-filter-panel .customer-filter-control,
+.customer-list-filter-panel .customer-search-field .form-control {
+    width: 100%;
+}
 .customer-table {
     overflow-x: auto;
 }
 .customer-row {
-    min-width: 900px;
+    min-width: 720px;
     display: grid;
-    grid-template-columns: 260px 140px minmax(180px, 1fr) 120px 140px;
+    grid-template-columns: 260px minmax(220px, 1fr) 140px;
     gap: 16px;
     align-items: center;
     padding: 16px 0;
@@ -832,6 +839,9 @@ textarea.customer-edit-control {
     transform: none;
     box-shadow: none;
 }
+.template-card.is-muted {
+    background-color: #f5f5f6;
+}
 .template-card img {
     width: 100%;
     aspect-ratio: 4 / 5;
@@ -1301,9 +1311,14 @@ textarea.customer-edit-control {
     border-color: rgba(245, 126, 72, 0.35);
     box-shadow: 0 16px 36px rgba(44, 46, 60, 0.12);
 }
-.simulation-result-card > a {
+.simulation-result-image-button {
+    width: 100%;
     display: block;
+    padding: 0;
+    border: 0;
     overflow: hidden;
+    background: transparent;
+    cursor: zoom-in;
 }
 .simulation-result-image {
     width: 100%;
@@ -1312,9 +1327,54 @@ textarea.customer-edit-control {
     display: block;
     transition: transform 0.25s ease;
 }
-.simulation-result-card > a:hover .simulation-result-image {
+.simulation-result-image-button:hover .simulation-result-image {
     transform: scale(1.03);
 }
+.simulation-result-card-loading {
+    width: 56px;
+    height: 56px;
+    object-fit: contain;
+    display: block;
+    margin-bottom: 12px;
+}
+.simulation-loading-state {
+    display: grid;
+    justify-items: center;
+    gap: 16px;
+}
+.simulation-loading-gif {
+    width: 84px;
+    height: 84px;
+    object-fit: contain;
+}
+.simulation-lightbox {
+    position: fixed;
+    inset: 0;
+    z-index: 9999;
+    display: grid;
+    place-items: center;
+    padding: 24px;
+    background-color: rgba(12, 14, 24, 0.82);
+}
+.simulation-lightbox-close {
+    position: absolute;
+    top: 24px;
+    right: 24px;
+    width: 42px;
+    height: 42px;
+    border: 0;
+    border-radius: 50%;
+    background-color: rgba(255, 255, 255, 0.92);
+    color: var(--dark-gray);
+    font-size: 20px;
+    font-weight: 800;
+}
+.simulation-lightbox-image {
+    max-width: min(92vw, 960px);
+    max-height: 88vh;
+    border-radius: 12px;
+    display: block;
+}
 .simulation-compare-images {
     display: grid;
     grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1626,6 +1686,29 @@ textarea.customer-edit-control {
     display: grid;
     gap: 10px;
 }
+.template-upload-placeholder {
+    width: 100%;
+    aspect-ratio: 4 / 5;
+    display: grid;
+    place-items: center;
+    gap: 8px;
+    background-color: #f7f7f8;
+    color: var(--medium-gray);
+    text-align: center;
+}
+.template-upload-placeholder strong {
+    color: var(--dark-gray);
+    font-size: 15px;
+    font-weight: 800;
+}
+.template-upload-inline-preview {
+    width: 100%;
+    max-width: 220px;
+    aspect-ratio: 4 / 5;
+    object-fit: cover;
+    border-radius: 8px;
+    display: block;
+}
 .template-task-layout {
     display: grid;
     grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
@@ -1674,6 +1757,12 @@ textarea.customer-edit-control {
     gap: 8px;
     margin-top: 24px;
 }
+.template-task-loading-gif {
+    width: 72px;
+    height: 72px;
+    margin-top: 20px;
+    object-fit: contain;
+}
 .template-task-meta-list {
     display: grid;
     gap: 0;

+ 5 - 0
frontend/public/images/default-user-avatar.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">
+  <rect width="240" height="240" rx="120" fill="#F1F3F5"/>
+  <circle cx="120" cy="90" r="42" fill="#C9CED6"/>
+  <path d="M52 196c8-39 37-62 68-62s60 23 68 62" fill="#C9CED6"/>
+</svg>

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

@@ -215,31 +215,24 @@
   },
   "customerListPage": {
     "title": "顧客一覧",
-    "description": "Aitori Stylist の顧客一覧ページ。検索や絞り込みから顧客カルテ、来店予定、相談内容、フォロー状況を確認できます。",
+    "description": "Aitori Stylist の顧客一覧ページ。検索や絞り込みから顧客カルテと相談内容を確認できます。",
     "badge": "顧客管理",
-    "lead": "顧客を素早く検索・絞り込みし、カルテ、来店予定、相談内容、フォロー状況を確認できます。",
+    "lead": "顧客を素早く検索・絞り込みし、カルテと相談内容を確認できます。",
     "newCustomer": "新規顧客",
     "filters": {
       "searchLabel": "顧客検索",
       "searchPlaceholder": "氏名・電話番号・メモで検索",
-      "visitLabel": "来店状況",
-      "visitOptions": ["すべて", "本日来店", "今週来店"],
-      "followLabel": "フォロー",
-      "followOptions": ["すべて", "要フォロー", "フォロー不要"],
       "styleLabel": "髪の長さ",
       "styleOptions": ["すべて", "ショート", "ミディアム", "ロング", "未確認"]
     },
     "stats": {
       "total": "顧客総数",
-      "today": "本日来店",
-      "followUp": "要フォロー",
       "simulated": "シミュレーション済み"
     },
     "resultBadge": "検索結果",
     "resultTitle": "検索結果 {{count}}件",
-    "tableHeaders": ["顧客", "来店予定", "相談内容", "状態", "操作"],
+    "tableHeaders": ["顧客", "相談内容", "操作"],
     "hairLengthMap": { "short": "ショート", "medium": "ミディアム", "long": "ロング" },
-    "statusMap": { "none": "未設定" },
     "detail": "タスクを見る",
     "pagination": { "prev": "前へ", "next": "次へ" }
   },

+ 16 - 21
frontend/src/i18n/locales/zh/translation.json

@@ -178,11 +178,11 @@
   },
   "dashboardPage": {
     "title": "美发师工作台",
-    "description": "Aitori Stylist 美发师工作台。可快速访问新建顾客、AI 发型模拟、发型模板、今日预约、待跟进顾客和最近施术记录。",
+    "description": "Aitori Stylist 美发师工作台。可快速访问新建顾客、AI 发型模拟和发型模板管理。",
     "badge": "工作台",
     "greeting": "早上好",
     "greetingName": "小川先生",
-    "summary": "可在这里快速查看今日预约、AI发型模拟和待跟进顾客。",
+    "summary": "可在这里快速进入顾客建档、AI发型模拟和发型模板管理。",
     "dateLabel": "2026年6月1日 星期一",
     "headlineStat": "今日预约6件 / 未处理跟进4件",
     "actions": {
@@ -215,31 +215,24 @@
   },
   "customerListPage": {
     "title": "顾客列表",
-    "description": "Aitori Stylist 顾客列表页面。可搜索和筛选顾客,查看顾客档案、到店计划、咨询内容和跟进状态,并新建顾客。",
+    "description": "Aitori Stylist 顾客列表页面。可搜索和筛选顾客,查看顾客档案、咨询内容,并新建顾客。",
     "badge": "顾客管理",
-    "lead": "可快速搜索和筛选顾客,并查看顾客档案、到店计划、咨询记录和跟进状态。",
+    "lead": "可快速搜索和筛选顾客,并查看顾客档案与咨询记录。",
     "newCustomer": "新建顾客",
     "filters": {
       "searchLabel": "顾客搜索",
       "searchPlaceholder": "按姓名、电话号码、备注搜索",
-      "visitLabel": "到店状态",
-      "visitOptions": ["全部", "今天到店", "本周到店"],
-      "followLabel": "跟进",
-      "followOptions": ["全部", "需跟进", "无需跟进"],
       "styleLabel": "发长",
       "styleOptions": ["全部", "短发", "中长发", "长发", "未确认"]
     },
     "stats": {
       "total": "顾客总数",
-      "today": "今天到店",
-      "followUp": "需跟进",
       "simulated": "已模拟"
     },
     "resultBadge": "搜索结果",
     "resultTitle": "搜索结果 {{count}}件",
-    "tableHeaders": ["顾客", "到店计划", "咨询内容", "状态", "操作"],
+    "tableHeaders": ["顾客", "咨询内容", "操作"],
     "hairLengthMap": { "short": "短发", "medium": "中长发", "long": "长发" },
-    "statusMap": { "none": "待设置" },
     "detail": "查看任务",
     "pagination": { "prev": "上一页", "next": "下一页" }
   },
@@ -279,7 +272,6 @@
     "categoryMap": { "LONG": "长发", "MEDIUM": "中长发", "SHORT": "短发", "DYE": "染发" },
     "statusMap": { "DRAFT": "草稿", "PUBLIC": "公开中" },
     "usedCount": "使用 {{count}}次",
-    "detail": "查看任务",
     "selectAll": "全选",
     "deselectAll": "取消全选",
     "pagination": { "prev": "上一页", "next": "下一页" }
@@ -345,7 +337,10 @@
     },
     "validation": {
       "referenceRequired": "请先上传参考图",
-      "assetsRequired": "请选择模特、服装和背景"
+      "assetsRequired": "请选择模特、服装和背景",
+      "customModelRequired": "请先选择自定义模特图片",
+      "customOutfitRequired": "请先选择自定义服装图片",
+      "customBackgroundRequired": "请先选择自定义背景图片"
     },
     "statusOptions": { "Draft": "草稿", "Published": "公开中" },
     "categoryOptions": { "Long": "长发", "Medium": "中长发", "Short": "短发", "Color": "染发" }
@@ -402,7 +397,7 @@
     "title": "模板制作任务",
     "description": "Aitori Stylist 模板制作任务页面。展示模板制作的 step1 和 step2 进度,并在完成后跳转到模板编辑页。",
     "badge": "模板任务",
-    "lead": "模板创建后会自动推进 step1 和 step2,请停留在当前页面等待结果。",
+    "lead": "模板创建后会自动推进任务,请停留在当前页面等待结果。",
     "pendingName": "正在创建模板...",
     "resourceFallback": "暂无预览",
     "bundleReady": "模板包已生成",
@@ -447,10 +442,10 @@
     "steps": {
       "step1": {
         "title": "Step 1 参考发型解析",
-        "description": "读取 step1 模板并生成中间发型图。"
+        "description": "读取 step1 模板并确认是否处理成功。"
       },
       "step2": {
-        "title": "Step 2 模板封面合成",
+        "title": "Step 2 模板封面生成",
         "description": "组合模板包并生成最终模板封面。"
       }
     }
@@ -714,7 +709,7 @@
   "simulationResultPage": {
     "title": "AI发型模拟结果",
     "description": "Aitori Stylist AI发型模拟结果页面。可确认顾客照片套用发型模板后的多个生成结果,对各方案进行微调并保存到本次施术记录。",
-    "back": "修改条件",
+    "back": "返回",
     "regenerate": "重新生成",
     "badge": "生成结果",
     "lead": "展示顾客照片套用所选发型模板后的生成结果。可进入各方案微调,或保存到本次施术记录。",
@@ -759,15 +754,15 @@
     },
     "original": {
       "badge": "顾客照片",
-      "title": "本次完成效果",
+      "title": "顾客照片",
       "customer": "顾客",
       "generatedAt": "生成时间",
       "count": "生成数量",
       "countValue": "{{count}}个方案"
     },
     "results": {
-      "badge": "未来变化",
-      "title": "剪发后变化预测结果",
+      "badge": "模拟结果",
+      "title": "模拟结果",
       "count": "{{count}}个方案",
       "templatePrefix": "模板",
       "adjust": "微调",

+ 3 - 57
frontend/src/pages/dashboard/DashboardPage.tsx

@@ -21,18 +21,6 @@ export function DashboardPage() {
   const pageCopy = copy.dashboardPage
   const sessionIdentity = useMemo(() => getSessionIdentity(), [])
   const stylistName = sessionIdentity.username || pageCopy.greetingName
-  const undevelopedCopy = pageCopy.undeveloped
-  const undevelopedStats = [
-    pageCopy.stats.todayCustomers,
-    pageCopy.stats.followUp,
-    pageCopy.stats.weeklyRecords,
-    pageCopy.stats.templates,
-  ]
-  const undevelopedPanels = [
-    { layoutClassName: 'col-xl-7', ...pageCopy.panels.today },
-    { layoutClassName: 'col-xl-5', ...pageCopy.panels.followUp },
-    { layoutClassName: 'col-12', ...pageCopy.panels.records },
-  ]
 
   const now = useMemo(() => new Date(), [])
   const dateLabel = useMemo(
@@ -56,18 +44,17 @@ export function DashboardPage() {
       <section className="stylist-workbench-top ipad-top-space-margin">
         <div className="container">
           <div className="row align-items-end justify-content-between mb-4">
-            <div className="col-lg-10">
+            <div className="col-lg-8">
               <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">{greeting}<br />{stylistName}</h1>
               <p className="fs-18 lh-30 mb-0">{pageCopy.summary}</p>
             </div>
-            <div className="col-lg-4 text-lg-end mt-3 mt-lg-0">
+            <div className="col-lg-4 text-center mt-3 mt-lg-0">
               <span className="d-block fs-14 text-medium-gray">{dateLabel}</span>
-              {/* 当前版本未开发欢迎区统计,后续从 workbench 聚合接口恢复预约与跟进真实数据。 */}
             </div>
           </div>
 
-          <div className="row row-cols-1 row-cols-lg-3 row-cols-md-3 g-4 mb-5">
+          <div className="row row-cols-1 row-cols-lg-3 row-cols-md-3 g-4">
             <div className="col">
               <Link to={path('/customers/new')} className="workbench-action-card bg-white border-radius-8px box-shadow-extra-large p-25px h-100">
                 <span className="workbench-action-icon"><i className="feather icon-feather-user-plus"></i></span>
@@ -90,47 +77,6 @@ export function DashboardPage() {
               </Link>
             </div>
           </div>
-
-          <div className="row row-cols-2 row-cols-lg-4 row-cols-md-4 g-3 mb-5">
-            {/* 当前版本未开发统计卡,后续从顾客汇总、模板汇总和工作台聚合接口恢复真实数据。 */}
-            {undevelopedStats.map((label) => (
-              <div key={label} className="col">
-                <div className="workbench-stat bg-white border-radius-8px p-20px opacity-75">
-                  <span className="d-block fs-26 fw-800 text-dark-gray">{undevelopedCopy.statValue}</span>
-                  <span className="fs-14 d-block">{label}</span>
-                  <span className="fs-13 text-medium-gray d-block mt-2">{undevelopedCopy.badge}</span>
-                </div>
-              </div>
-            ))}
-          </div>
-        </div>
-      </section>
-
-      <section className="pt-0">
-        <div className="container">
-          <div className="row g-4">
-            {/* 当前版本未开发工作台列表区块,后续从 workbench 聚合接口恢复今日顾客、待跟进顾客和最近施术记录。 */}
-            {undevelopedPanels.map((panel) => (
-              <div key={panel.title} className={panel.layoutClassName}>
-                <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100 opacity-75">
-                  <div className="d-flex align-items-center justify-content-between mb-25px">
-                    <div>
-                      <span className="fs-14 fw-700 text-base-color text-uppercase">{panel.badge}</span>
-                      <h2 className="fw-700 text-dark-gray fs-26 mb-0">{panel.title}</h2>
-                    </div>
-                    <button
-                      type="button"
-                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
-                      disabled
-                    >
-                      {undevelopedCopy.disabledAction}
-                    </button>
-                  </div>
-                  <p className="fs-15 text-medium-gray mb-0">{undevelopedCopy.body}</p>
-                </div>
-              </div>
-            ))}
-          </div>
         </div>
       </section>
     </DashboardLayout>

+ 34 - 3
frontend/src/pages/dashboard/__tests__/DashboardPage.test.tsx

@@ -70,7 +70,7 @@ describe('DashboardPage', () => {
     expect(entryLinks).toHaveLength(3)
   })
 
-  it('renders undeveloped placeholders for stats and workbench panels', () => {
+  it('removes undeveloped placeholders from the dashboard', () => {
     setSession(
       createToken({
         sub: 1,
@@ -90,7 +90,38 @@ describe('DashboardPage', () => {
       </HelmetProvider>,
     )
 
-    expect(screen.getAllByText('当前版本暂未开发').length).toBeGreaterThanOrEqual(4)
-    expect(screen.getAllByRole('button', { name: '当前版本暂未开发' }).length).toBeGreaterThanOrEqual(3)
+    expect(screen.queryAllByText('当前版本暂未开发')).toHaveLength(0)
+    expect(screen.queryAllByText('今日顾客')).toHaveLength(0)
+    expect(screen.queryAllByRole('button', { name: '当前版本暂未开发' })).toHaveLength(0)
+  })
+
+  it('centers the dashboard date display', () => {
+    setSession(
+      createToken({
+        sub: 1,
+        email: 'stylist@example.com',
+        username: 'Ogawa',
+      }),
+      'Authorization',
+    )
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/dashboard']}>
+          <Routes>
+            <Route path="/:locale/dashboard" element={<DashboardPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const dateLabel = new Intl.DateTimeFormat('zh-CN', {
+      year: 'numeric',
+      month: 'long',
+      day: 'numeric',
+      weekday: 'long',
+    }).format(new Date())
+
+    expect(screen.getByText(dateLabel).closest('div')).toHaveClass('text-center')
   })
 })

+ 25 - 81
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -5,6 +5,7 @@ import { getCustomerById, type CustomerDetailEntity } from '../../../services/ap
 import type { CustomerSimulationGalleryItem } from '../../../services/api/contracts'
 import { listCustomerSimulationGallery } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
+import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 function getDisplayValue(value?: string) {
@@ -56,7 +57,7 @@ export function CustomerDetailPage() {
     return null
   }
 
-  const summaryText = customer.trouble || customer.consultContent || customer.expectLook || pageCopy.nextVisit.lead
+  const summaryText = customer.trouble || customer.consultContent || customer.expectLook || ''
   const basicValues = [
     getDisplayValue(customer.phone),
     getDisplayValue(customer.email),
@@ -66,7 +67,6 @@ export function CustomerDetailPage() {
     getDisplayValue(customer.notice) === '—' ? pageCopy.basic.fallbacks[3] : getDisplayValue(customer.notice),
   ]
   const allergyValue = getDisplayValue(customer.allergy)
-  const followContentValue = customer.followContent?.trim() ? customer.followContent : (pageCopy.states.empty || '未填写')
 
   return (
     <DashboardLayout>
@@ -94,22 +94,13 @@ export function CustomerDetailPage() {
 
           <div className="customer-detail-hero workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
             <div className="customer-detail-profile">
-              <img src={customer.avatar || '/images/aitori-before-photo.png'} alt={customer.name} className="customer-detail-avatar" />
+              <img src={customer.avatar || DEFAULT_USER_AVATAR_URL} alt={customer.name} className="customer-detail-avatar" />
               <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">{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>
-              <p>{followContentValue}</p>
-              <button type="button" disabled className="btn btn-small btn-rounded btn-base-color fw-700">
-                {pageCopy.nextVisit.action}
-              </button>
-            </div>
           </div>
         </div>
       </section>
@@ -145,83 +136,36 @@ export function CustomerDetailPage() {
                     <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>
+                  <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}
+                  </Link>
                 </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 className="customer-photo-grid">
+                  {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>
             </div>
           </div>
         </div>
       </section>
-
-      <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-0">{pageCopy.photos.title}</h2>
-              </div>
-              <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}
-              </Link>
-            </div>
-            <div className="customer-photo-grid">
-              {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>
-      </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>
     </DashboardLayout>
   )
 }

+ 7 - 47
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -10,6 +10,7 @@ import {
   type CustomerFormValues,
 } from '../../../shared/customerForm'
 import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
+import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 function getSubmitErrorMessage(error: unknown, locale: 'zh' | 'ja') {
@@ -35,7 +36,7 @@ export function CustomerFormPage() {
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
   const [photoFile, setPhotoFile] = useState<File | null>(null)
-  const photoPreview = usePreviewUrl(photoFile, '/images/aitori-before-photo.png')
+  const photoPreview = usePreviewUrl(photoFile, DEFAULT_USER_AVATAR_URL)
 
   const patch = (part: Partial<CustomerFormValues>) => setForm((current) => ({ ...current, ...part }))
   const pageCopy = copy.customerFormPage
@@ -57,7 +58,11 @@ export function CustomerFormPage() {
     setSubmitting(true)
     try {
       let avatar: string | undefined
-      if (photoFile) avatar = (await uploadMedia(photoFile, 'avatar')).url
+      if (photoFile) {
+        avatar = (await uploadMedia(photoFile, 'avatar')).url
+      } else if (!isEditMode) {
+        avatar = DEFAULT_USER_AVATAR_URL
+      }
       const payload = formValuesToPayload(form, avatar)
       if (isEditMode && customerId) {
         await updateCustomer(customerId, payload)
@@ -136,11 +141,6 @@ export function CustomerFormPage() {
                       <i className="feather icon-feather-upload me-6px"></i>{pageCopy.photo.change}
                     </label>
                   </div>
-                  <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.nextStep.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.nextStep.title}</h2>
-                  <div className="customer-new-steps">
-                    {pageCopy.nextStep.steps.map((item: string, index: number) => <div key={item}><span>{index + 1}</span><strong>{item}</strong></div>)}
-                  </div>
                 </div>
               </div>
             </div>
@@ -213,46 +213,6 @@ export function CustomerFormPage() {
               </div>
             </div>
 
-            <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
-              <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.titleEdit}</h2>
-                </div>
-                <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-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="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="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>
-
             <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
               <div>
                 <strong>{isEditMode ? pageCopy.submit.titleEdit : pageCopy.submit.titleNew}</strong>

+ 11 - 57
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import {
@@ -18,24 +18,6 @@ const emptySummary: CustomerDashboardSummary = {
   weeklyRecords: 0,
 }
 
-const VISIT_STATUS_API_MAP: Record<string, string | undefined> = {
-  '全部': undefined,
-  '今天到店': 'TODAY',
-  '本周到店': 'THIS_WEEK',
-  '全て': undefined,
-  '本日来店': 'TODAY',
-  '今週来店': 'THIS_WEEK',
-}
-
-const FOLLOW_STATUS_API_MAP: Record<string, string | undefined> = {
-  '全部': undefined,
-  '需跟进': 'NEED_FOLLOW_UP',
-  '无需跟进': 'NO_NEED_FOLLOW_UP',
-  '全て': undefined,
-  '要フォロー': 'NEED_FOLLOW_UP',
-  'フォロー不要': 'NO_NEED_FOLLOW_UP',
-}
-
 const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
   '全部': undefined,
   '短发': 'SHORT',
@@ -49,12 +31,6 @@ const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
   '未確認': 'UNCONFIRM',
 }
 
-function getFollowStatusLabel(status: string | undefined, pageCopy: CustomerListPageCopy) {
-  if (status === 'NEED_FOLLOW_UP') return pageCopy.filters.followOptions[1]
-  if (status === 'NO_NEED_FOLLOW_UP') return pageCopy.statusMap?.none || '—'
-  return '—'
-}
-
 function getHairLengthLabel(length: string | undefined, pageCopy: CustomerListPageCopy) {
   if (length === 'SHORT') return pageCopy.hairLengthMap.short
   if (length === 'MEDIUM') return pageCopy.hairLengthMap.medium
@@ -82,18 +58,15 @@ export function CustomerListPage() {
   const [page, setPage] = useState(1)
   const [pageSize] = useState(20)
   const [total, setTotal] = useState(0)
-  const [visitFilter, setVisitFilter] = useState(copy.customerListPage.filters.visitOptions[0])
-  const [followFilter, setFollowFilter] = useState(copy.customerListPage.filters.followOptions[0])
   const [styleFilter, setStyleFilter] = useState(copy.customerListPage.filters.styleOptions[0])
   const pageCopy = copy.customerListPage
 
-  const loadCustomers = useCallback(() => {
+  useEffect(() => {
+    const timer = setTimeout(() => {
     listCustomers({
       page,
       pageSize,
       q: query.trim() || undefined,
-      visitStatus: VISIT_STATUS_API_MAP[visitFilter],
-      followStatus: FOLLOW_STATUS_API_MAP[followFilter],
       hairLength: HAIR_LENGTH_API_MAP[styleFilter],
     })
       .then((result) => {
@@ -104,16 +77,13 @@ export function CustomerListPage() {
         setItems([])
         setTotal(0)
       })
-  }, [followFilter, page, pageSize, query, styleFilter, visitFilter])
-
-  useEffect(() => {
-    const timer = setTimeout(loadCustomers, 300)
+    }, 300)
     return () => clearTimeout(timer)
-  }, [loadCustomers])
+  }, [page, pageSize, query, styleFilter])
 
   useEffect(() => {
     setPage(1)
-  }, [query, visitFilter, followFilter, styleFilter])
+  }, [query, styleFilter])
 
   useEffect(() => {
     getCustomerDashboardSummary().then(setSummary).catch(() => setSummary(emptySummary))
@@ -150,28 +120,16 @@ export function CustomerListPage() {
             </div>
           </div>
 
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4">
-            <div className="row g-3 align-items-end">
-              <div className="col-lg-5">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4 customer-list-filter-panel">
+            <div className="row g-3 align-items-end customer-list-filter-grid">
+              <div className="col-lg-8">
                 <label htmlFor="customer-search" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.searchLabel}</label>
                 <div className="customer-search-field">
                   <i className="feather icon-feather-search"></i>
                   <input id="customer-search" type="search" className="form-control" value={query} onChange={(event) => setQuery(event.target.value)} placeholder={pageCopy.filters.searchPlaceholder} />
                 </div>
               </div>
-              <div className="col-lg-3 col-md-4">
-                <label htmlFor="visit-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.visitLabel}</label>
-                <select id="visit-filter" className="form-select customer-filter-control" value={visitFilter} onChange={(event) => setVisitFilter(event.target.value)}>
-                  {pageCopy.filters.visitOptions.map((option: string) => <option key={option} value={option}>{option}</option>)}
-                </select>
-              </div>
-              <div className="col-lg-2 col-md-4">
-                <label htmlFor="follow-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.followLabel}</label>
-                <select id="follow-filter" className="form-select customer-filter-control" value={followFilter} onChange={(event) => setFollowFilter(event.target.value)}>
-                  {pageCopy.filters.followOptions.map((option: string) => <option key={option} value={option}>{option}</option>)}
-                </select>
-              </div>
-              <div className="col-lg-2 col-md-4">
+              <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>)}
@@ -180,10 +138,8 @@ export function CustomerListPage() {
             </div>
           </div>
 
-          <div className="row row-cols-2 row-cols-lg-4 row-cols-md-4 g-3 mb-4">
+          <div className="row row-cols-2 row-cols-lg-2 row-cols-md-2 g-3 mb-4">
             <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.total}</span><span className="fs-14">{pageCopy.stats.total}</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.today}</span><span className="fs-14">{pageCopy.stats.today}</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.followUp}</span><span className="fs-14">{pageCopy.stats.followUp}</span></div></div>
             <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.simulated}</span><span className="fs-14">{pageCopy.stats.simulated}</span></div></div>
           </div>
         </div>
@@ -220,9 +176,7 @@ export function CustomerListPage() {
                       </span>
                     </div>
                   </div>
-                  <span>{customer.planDate || '—'}</span>
                   <span>{customer.trouble || '—'}</span>
-                  <span><span className="workbench-badge">{getFollowStatusLabel(customer.followState, pageCopy)}</span></span>
                   <span className="customer-row-action">
                     <Link
                       to={path(`/customers/${customer.id}`)}

+ 13 - 8
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -65,7 +65,7 @@ vi.mock('../../../../layouts/DashboardLayout', () => ({
 }))
 
 describe('CustomerDetailPage', () => {
-  it('renders current-field sections and undeveloped placeholders', async () => {
+  it('removes next-visit and cta blocks and uses the history section for gallery content', async () => {
     getCustomerById.mockResolvedValue({
       id: '8',
       name: 'Sato Misaki',
@@ -108,15 +108,17 @@ 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.getByText('确认褪色情况并提醒护理')).toBeInTheDocument()
-    expect(nextVisitCard).not.toBeNull()
-    expect(within(nextVisitCard as HTMLElement).queryByText('当前版本暂未开发')).not.toBeInTheDocument()
-    expect(screen.getByRole('button', { name: '发送提醒(未开发)' })).toBeDisabled()
+    expect(screen.queryByText('下次到店')).not.toBeInTheDocument()
+    expect(screen.queryByText('提醒时间:2026-07-15')).not.toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: '发送提醒(未开发)' })).not.toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: '施术历史' })).toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: '添加记录' })).not.toBeInTheDocument()
     expect(screen.getAllByRole('link', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
     expect(screen.getByText('暂无 AI 发型模拟结果')).toBeInTheDocument()
+    expect(screen.queryByText('照片与模拟')).not.toBeInTheDocument()
+    expect(screen.queryByText('过往照片和AI发型模拟')).not.toBeInTheDocument()
+    expect(screen.queryByText('开始下一次咨询与施术')).not.toBeInTheDocument()
   })
 
   it('renders 未填写 when followContent is empty', async () => {
@@ -147,7 +149,7 @@ describe('CustomerDetailPage', () => {
       expect(screen.getByText('Sato Aki')).toBeInTheDocument()
     })
 
-    expect(screen.getByText('未填写')).toBeInTheDocument()
+    expect(screen.queryByText('未填写')).not.toBeInTheDocument()
   })
 
   it('navigates to simulation create page with customerId', async () => {
@@ -218,5 +220,8 @@ describe('CustomerDetailPage', () => {
 
     const card = await screen.findByRole('link', { name: /Layer Bob/i })
     expect(card).toHaveAttribute('href', '/zh/simulation/result/91')
+    const historySection = screen.getByRole('heading', { name: '施术历史' }).closest('.workbench-panel')
+    expect(historySection).not.toBeNull()
+    expect(within(historySection as HTMLElement).getByRole('link', { name: /Layer Bob/i })).toBeInTheDocument()
   })
 })

+ 13 - 10
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -49,7 +49,7 @@ vi.mock('../../../../shared/pageContext', () => ({
           birthday: '生日',
           contactNew: '联系方式',
           contactEdit: 'LINE 状态',
-          contactNewOptions: ['LINE', '电话', '邮箱'],
+          contactNewOptions: ['LINE', '电话', '邮箱', '未设置'],
           contactEditOptions: ['已关联', '未关联'],
         },
         consultation: {
@@ -153,7 +153,6 @@ 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
@@ -170,12 +169,14 @@ describe('CustomerFormPage', () => {
           name: 'Sato Misaki',
           phone: '090-0000-0000',
           avatar: 'https://example.com/avatar.png',
-          followContent: '确认褪色情况并提醒护理',
         }),
       )
     })
     expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('image')
     expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('isFirstRegister')
+    expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('followContent')
+    expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('nextFollowDate')
+    expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('recommendVisitDate')
   })
 
   it('shows a friendly error message when create fails', async () => {
@@ -203,7 +204,7 @@ describe('CustomerFormPage', () => {
     })
   })
 
-  it('shows a visible placeholder option for visit purpose', () => {
+  it('keeps visit purpose placeholder but removes next-step and follow blocks', () => {
     render(
       <HelmetProvider>
         <MemoryRouter initialEntries={['/zh/customers/new']}>
@@ -217,8 +218,11 @@ describe('CustomerFormPage', () => {
     expect(
       screen.getByRole('option', { name: '请选择到店目的' }),
     ).toBeInTheDocument()
-    expect(screen.getByLabelText('跟进内容')).toBeInTheDocument()
-    expect(screen.getByLabelText('跟进联系方式')).toBeInTheDocument()
+    expect(screen.queryByText('下一步')).not.toBeInTheDocument()
+    expect(screen.queryByText('后续')).not.toBeInTheDocument()
+    expect(screen.queryByText('跟进')).not.toBeInTheDocument()
+    expect(screen.queryByLabelText('跟进内容')).not.toBeInTheDocument()
+    expect(screen.queryByLabelText('跟进联系方式')).not.toBeInTheDocument()
   })
 
   it('loads existing customer data in edit mode and submits update', async () => {
@@ -264,7 +268,6 @@ 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(() => {
@@ -273,11 +276,11 @@ describe('CustomerFormPage', () => {
         expect.objectContaining({
           name: 'Sato Misaki',
           phone: '090-0000-0001',
-          nextFollowDate: '2026-07-15',
-          recommendVisitDate: '2026-07-22',
-          followContent: '更新后的跟进内容',
         }),
       )
     })
+    expect(updateCustomer.mock.calls[0][1]).not.toHaveProperty('followContent')
+    expect(updateCustomer.mock.calls[0][1]).not.toHaveProperty('nextFollowDate')
+    expect(updateCustomer.mock.calls[0][1]).not.toHaveProperty('recommendVisitDate')
   })
 })

+ 14 - 16
frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx

@@ -34,30 +34,21 @@ vi.mock('../../../../shared/pageContext', () => ({
         filters: {
           searchLabel: '搜索',
           searchPlaceholder: '输入关键词',
-          visitLabel: '到店状态',
-          visitOptions: ['全部', '今天到店', '本周到店', '30日以上未到店'],
-          followLabel: '跟进状态',
-          followOptions: ['全部', '需跟进', '已发送'],
           styleLabel: '发长',
           styleOptions: ['全部', '短发', '中长发', '长发'],
         },
         stats: {
           total: '顾客总数',
-          today: '今天到店',
-          followUp: '需跟进',
           simulated: '已模拟',
         },
         resultBadge: '搜索结果',
         resultTitle: '搜索结果 {{count}}件',
-        tableHeaders: ['顾客', '最近到店', '备注', '状态', '操作'],
+        tableHeaders: ['顾客', '备注', '操作'],
         hairLengthMap: {
           short: '短发',
           medium: '中长发',
           long: '长发',
         },
-        statusMap: {
-          none: '待设置',
-        },
         pagination: {
           prev: '上一页',
           next: '下一页',
@@ -99,7 +90,7 @@ describe('CustomerListPage', () => {
     })
   })
 
-  it('renders backend summary and total search count', async () => {
+  it('keeps only total and simulated summary cards', async () => {
     render(
       <HelmetProvider>
         <MemoryRouter>
@@ -113,12 +104,12 @@ describe('CustomerListPage', () => {
     })
 
     expect(screen.getAllByText('42').length).toBeGreaterThan(0)
-    expect(screen.getAllByText('3').length).toBeGreaterThan(0)
-    expect(screen.getAllByText('4').length).toBeGreaterThan(0)
     expect(screen.getAllByText('2').length).toBeGreaterThan(0)
+    expect(screen.queryByText('今天到店')).not.toBeInTheDocument()
+    expect(screen.queryByText('需跟进')).not.toBeInTheDocument()
   })
 
-  it('renders status label and pagination controls', async () => {
+  it('removes visit and follow fields from filters and search results', async () => {
     render(
       <HelmetProvider>
         <MemoryRouter>
@@ -128,11 +119,18 @@ describe('CustomerListPage', () => {
     )
 
     await waitFor(() => {
-      expect(screen.getByText('待设置')).toBeInTheDocument()
+      expect(screen.getByText('想换发型')).toBeInTheDocument()
     })
 
-    expect(screen.getByText('2026-06-20')).toBeInTheDocument()
+    expect(screen.queryByLabelText('到店状态')).not.toBeInTheDocument()
+    expect(screen.queryByLabelText('跟进状态')).not.toBeInTheDocument()
+    expect(screen.queryByText('到店计划')).not.toBeInTheDocument()
+    expect(screen.queryByText('状态')).not.toBeInTheDocument()
+    expect(screen.queryByText('2026-06-20')).not.toBeInTheDocument()
+    expect(screen.queryByText('待设置')).not.toBeInTheDocument()
     expect(screen.getByText('想换发型')).toBeInTheDocument()
+    expect(screen.getByLabelText('搜索').closest('.col-lg-8')).not.toBeNull()
+    expect(screen.getByLabelText('发长').closest('.col-lg-4')).not.toBeNull()
     expect(screen.getByRole('button', { name: '上一页' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: '下一页' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()

+ 50 - 7
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx

@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async'
 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 { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 function formatDisplayDate(input: string) {
@@ -26,6 +27,7 @@ export function SimulationResultPage() {
   const [detail, setDetail] = useState<AiHairSimulationDetail | null>(null)
   const [loading, setLoading] = useState(true)
   const [error, setError] = useState('')
+  const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null)
 
   const getTaskSummary = (
     task: AiHairSimulationDetail['tasks'][number],
@@ -112,7 +114,10 @@ export function SimulationResultPage() {
 
           {loading ? (
             <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-              <p className="mb-0 text-medium-gray">Loading...</p>
+              <div className="simulation-loading-state">
+                <img src={LOADING_ANIMATION_URL} alt="生成中" className="simulation-loading-gif" />
+                <p className="mb-0 text-medium-gray">Loading...</p>
+              </div>
             </div>
           ) : null}
 
@@ -128,7 +133,7 @@ export function SimulationResultPage() {
                   </div>
                   <div className="simulation-original-card">
                     <img
-                      src={detail.originImage || '/images/aitori-before-photo.png'}
+                      src={detail.originImage || DEFAULT_USER_AVATAR_URL}
                       alt={pageCopy.title}
                       className="simulation-result-image"
                     />
@@ -179,11 +184,23 @@ export function SimulationResultPage() {
                   <div className="simulation-result-grid">
                     {detail.tasks.map((task) => (
                       <article key={task.id} className="simulation-result-card">
-                        <img
-                          className="simulation-result-image"
-                          src={task.resultImages[0] || '/images/aitori-simulation-result.png'}
-                          alt={task.templateName}
-                        />
+                        <button
+                          type="button"
+                          className="simulation-result-image-button"
+                          onClick={() => {
+                            if (!task.resultImages[0]) return
+                            setLightboxImage({
+                              src: task.resultImages[0],
+                              alt: task.templateName,
+                            })
+                          }}
+                        >
+                          <img
+                            className="simulation-result-image"
+                            src={task.resultImages[0] || '/images/aitori-simulation-result.png'}
+                            alt={task.templateName}
+                          />
+                        </button>
                         <div className="simulation-result-body">
                           <div className="simulation-result-card-title-row">
                             <h3>{task.templateName}</h3>
@@ -193,6 +210,9 @@ export function SimulationResultPage() {
                                 : pageCopy.source.stylist}
                             </span>
                           </div>
+                          {task.taskStatus === 'PROCESSING' ? (
+                            <img src={LOADING_ANIMATION_URL} alt="生成中" className="simulation-result-card-loading" />
+                          ) : null}
                           <p className="mb-0">{getTaskSummary(task)}</p>
                           {task.errorMsg ? (
                             <p role="alert" className="text-red mt-10px mb-0">
@@ -207,6 +227,29 @@ export function SimulationResultPage() {
               </div>
             </>
           ) : null}
+
+          {lightboxImage ? (
+            <div
+              role="dialog"
+              aria-modal="true"
+              className="simulation-lightbox"
+              onClick={() => setLightboxImage(null)}
+            >
+              <button
+                type="button"
+                className="simulation-lightbox-close"
+                aria-label="关闭预览"
+                onClick={() => setLightboxImage(null)}
+              >
+                x
+              </button>
+              <img
+                src={lightboxImage.src}
+                alt={`${lightboxImage.alt} 放大预览`}
+                className="simulation-lightbox-image"
+              />
+            </div>
+          ) : null}
         </div>
       </section>
     </DashboardLayout>

+ 3 - 2
frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx

@@ -15,6 +15,7 @@ import {
   usePageContext,
   usePreviewUrl,
 } from '../../../shared/pageContext'
+import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
 
 export function StylistSimulationPage() {
   const [searchParams] = useSearchParams()
@@ -35,7 +36,7 @@ export function StylistSimulationPage() {
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
 
-  const previewUrl = usePreviewUrl(file, originImageUrl || '/images/aitori-before-photo.png')
+  const previewUrl = usePreviewUrl(file, originImageUrl || DEFAULT_USER_AVATAR_URL)
   const selectedCustomer = customers.find((item) => item.id === selectedCustomerId) || null
 
   useEffect(() => {
@@ -219,7 +220,7 @@ export function StylistSimulationPage() {
               </div>
               <div className="simulation-selected-customer mt-25px">
                 <img
-                  src={selectedCustomer?.avatar || '/images/aitori-before-photo.png'}
+                  src={selectedCustomer?.avatar || DEFAULT_USER_AVATAR_URL}
                   alt={selectedCustomer?.name || pageCopy.customerLabel}
                 />
                 <div className="w-100">

+ 56 - 5
frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx

@@ -1,4 +1,4 @@
-import { act, render, screen } from '@testing-library/react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
 import { HelmetProvider } from 'react-helmet-async'
 import { MemoryRouter, Route, Routes } from 'react-router-dom'
 import type { ReactNode } from 'react'
@@ -34,15 +34,15 @@ vi.mock('../../../../shared/pageContext', () => ({
         anonymousLead: '未绑定顾客档案',
         original: {
           badge: '顾客照片',
-          title: '本次完成效果',
+          title: '顾客照片',
           customer: '顾客',
           generatedAt: '施术日',
           count: '生成数量',
           countValue: '{{count}}个方案',
         },
         results: {
-          badge: '未来变化',
-          title: '剪发后变化预测结果',
+          badge: '模拟结果',
+          title: '模拟结果',
           count: '{{count}}个方案',
         },
         counters: {
@@ -145,6 +145,7 @@ describe('SimulationResultPage', () => {
 
     expect(screen.getByText('Layer Bob')).toBeInTheDocument()
     expect(screen.getByText('正在生成中,请稍候查看当前方案效果。')).toBeInTheDocument()
+    expect(screen.getAllByAltText('生成中').length).toBeGreaterThan(0)
 
     await act(async () => {
       await vi.advanceTimersByTimeAsync(5000)
@@ -203,11 +204,12 @@ describe('SimulationResultPage', () => {
     expect(await screen.findByText('Mika')).toBeInTheDocument()
     expect(screen.queryByRole('link', { name: '重新发起模拟' })).not.toBeInTheDocument()
     expect(container.querySelector('.customer-detail-hero')).toBeNull()
+    expect(screen.getByRole('link', { name: '返回' })).toHaveAttribute('href', '/zh/customers/5')
 
     const originPanel = container.querySelector('.simulation-result-layout aside')
     expect(originPanel).not.toBeNull()
     expect(originPanel).toHaveTextContent('顾客')
-    expect(originPanel).toHaveTextContent('施术日')
+    expect(originPanel).toHaveTextContent('顾客照片')
     expect(originPanel).toHaveTextContent('2026/06/28')
     expect(originPanel).toHaveTextContent('生成数量')
     expect(originPanel).toHaveTextContent('2个方案')
@@ -215,5 +217,54 @@ describe('SimulationResultPage', () => {
     expect(originPanel).not.toHaveTextContent('处理中')
     expect(originPanel).not.toHaveTextContent('成功')
     expect(originPanel).not.toHaveTextContent('失败')
+    expect(screen.getByRole('heading', { name: '模拟结果' })).toBeInTheDocument()
+    expect(screen.queryByText('未来变化')).not.toBeInTheDocument()
+    expect(screen.queryByText('剪发后变化预测结果')).not.toBeInTheDocument()
+  })
+
+  it('opens a lightbox when clicking a generated result image', async () => {
+    getSimulationById.mockResolvedValueOnce({
+      id: '91',
+      customer: null,
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: 'airy',
+      createdAt: '2026-06-28T09:00:00.000Z',
+      totalTaskCount: 1,
+      processingTaskCount: 0,
+      successTaskCount: 1,
+      failedTaskCount: 0,
+      overallStatus: 'SUCCESSES',
+      tasks: [
+        {
+          id: '1',
+          templateId: '11',
+          templateName: 'Layer Bob',
+          templateCoverUrl: '',
+          templateCategory: 'SHORT',
+          templateSource: 'STYLIST',
+          taskId: 'task-11',
+          taskStatus: 'SUCCESSES',
+          resultImages: ['https://cdn.example.com/result.png'],
+          errorMsg: null,
+          taskStartTime: '2026-06-28T10:00:00.000Z',
+          taskFinishTime: '2026-06-28T10:01:00.000Z',
+        },
+      ],
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation/result/91']}>
+          <Routes>
+            <Route path="/:locale/simulation/result/:simulationId" element={<SimulationResultPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    fireEvent.click(await screen.findByAltText('Layer Bob'))
+
+    expect(screen.getByRole('dialog')).toBeInTheDocument()
+    expect(screen.getByAltText('Layer Bob 放大预览')).toHaveAttribute('src', 'https://cdn.example.com/result.png')
   })
 })

+ 2 - 0
frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx

@@ -89,6 +89,8 @@ describe('StylistSimulationPage', () => {
 
     expect(await screen.findByLabelText('不绑定顾客')).toBeChecked()
     expect(screen.getByLabelText('顾客')).toBeDisabled()
+    expect(screen.getByAltText('顾客')).toHaveAttribute('src', '/images/default-user-avatar.svg')
+    expect(screen.getByAltText('上传顾客照片')).toHaveAttribute('src', '/images/default-user-avatar.svg')
   })
 
   it('preselects the customer from query string and uploads origin image with ai-user-origin-image', async () => {

+ 66 - 14
frontend/src/pages/dashboard/templates/TemplateFormPage.tsx

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
+import { hairstyleTemplateAssets } from '../../../config/hairstyleTemplateAssets.generated'
 import {
   createTemplateMakeTask,
   getTemplateAssets,
@@ -32,6 +33,34 @@ const EMPTY_ASSETS: TemplateAssets = {
   backgrounds: [],
 }
 
+const STATIC_PRESET_ASSETS: TemplateAssets = {
+  models: hairstyleTemplateAssets
+    .filter((item) => item.category === 'model')
+    .map((item) => ({
+      id: item.id,
+      name: item.label,
+      url: item.previewUrl,
+      configKey: item.id,
+    })),
+  clothes: hairstyleTemplateAssets
+    .filter((item) => item.category === 'outfit')
+    .map((item) => ({
+      id: item.id,
+      name: item.label,
+      url: item.previewUrl,
+      configKey: item.id,
+    })),
+  backgrounds: hairstyleTemplateAssets
+    .filter((item) => item.category === 'background')
+    .slice(0, 4)
+    .map((item) => ({
+      id: item.id,
+      name: item.label,
+      url: item.previewUrl,
+      configKey: item.id,
+    })),
+}
+
 export function TemplateFormPage() {
   const { locale, navigate, path, copy } = usePageContext()
   const pageCopy = copy.templateFormPage
@@ -53,7 +82,10 @@ export function TemplateFormPage() {
   const [customModelFile, setCustomModelFile] = useState<File | null>(null)
   const [customOutfitFile, setCustomOutfitFile] = useState<File | null>(null)
   const [customBackgroundFile, setCustomBackgroundFile] = useState<File | null>(null)
-  const referencePreview = usePreviewUrl(referenceFile, '/images/stylist-profile/hairstyle/long/User3-4.jpg')
+  const referencePreview = usePreviewUrl(referenceFile, '')
+  const customModelPreview = usePreviewUrl(customModelFile, '')
+  const customOutfitPreview = usePreviewUrl(customOutfitFile, '')
+  const customBackgroundPreview = usePreviewUrl(customBackgroundFile, '')
   const [submitting, setSubmitting] = useState(false)
   const [submitPhase, setSubmitPhase] = useState<'idle' | 'uploading' | 'generating'>('idle')
   const [error, setError] = useState('')
@@ -69,17 +101,27 @@ export function TemplateFormPage() {
         const data = await getTemplateAssets()
         if (cancelled) return
 
-        setAssets(data)
-        setSelectedModelId((current) => current || data.models[0]?.id || '')
-        setSelectedOutfitId((current) => current || data.clothes[0]?.id || '')
-        setSelectedBackgroundId((current) => current || data.backgrounds[0]?.id || '')
+        const nextAssets: TemplateAssets = {
+          models: STATIC_PRESET_ASSETS.models,
+          clothes: data.clothes.length ? data.clothes : STATIC_PRESET_ASSETS.clothes,
+          backgrounds: STATIC_PRESET_ASSETS.backgrounds,
+        }
+
+        setAssets(nextAssets)
+        setSelectedModelId((current) => current || nextAssets.models[0]?.id || '')
+        setSelectedOutfitId((current) => current || nextAssets.clothes[0]?.id || '')
+        setSelectedBackgroundId((current) => current || nextAssets.backgrounds[0]?.id || '')
 
-        if (data.models.length === 0 || data.clothes.length === 0 || data.backgrounds.length === 0) {
+        if (nextAssets.models.length === 0 || nextAssets.clothes.length === 0 || nextAssets.backgrounds.length === 0) {
           setAssetsError(pageCopy.assetEmpty || '素材未配置完成,请检查服务器配置')
         }
       } catch {
         if (cancelled) return
-        setAssets(EMPTY_ASSETS)
+        setAssets({
+          models: STATIC_PRESET_ASSETS.models,
+          clothes: [],
+          backgrounds: STATIC_PRESET_ASSETS.backgrounds,
+        })
         setAssetsError(pageCopy.assetLoadFailed || '素材加载失败,请刷新后重试')
       } finally {
         if (!cancelled) {
@@ -123,7 +165,7 @@ export function TemplateFormPage() {
 
   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault()
-    if (!name || !description || submitting) return
+    if (!name || submitting) return
     setError('')
     setSubmitting(true)
     setSubmitPhase('idle')
@@ -143,21 +185,21 @@ export function TemplateFormPage() {
 
       if (modelSource === 'upload') {
         if (!customModelFile) {
-          throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+          throw new Error(pageCopy.validation?.customModelRequired || '请先选择自定义模特图片')
         }
         modelUrl = (await uploadMedia(customModelFile, 'ai-template-model-image')).url
       }
 
       if (outfitSource === 'upload') {
         if (!customOutfitFile) {
-          throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+          throw new Error(pageCopy.validation?.customOutfitRequired || '请先选择自定义服装图片')
         }
         clothesUrl = (await uploadMedia(customOutfitFile, 'ai-template-clothing-image')).url
       }
 
       if (backgroundSource === 'upload') {
         if (!customBackgroundFile) {
-          throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+          throw new Error(pageCopy.validation?.customBackgroundRequired || '请先选择自定义背景图片')
         }
         backgroundUrl = (await uploadMedia(customBackgroundFile, 'ai-template-background-image')).url
       }
@@ -178,7 +220,7 @@ export function TemplateFormPage() {
                 ? 'SHORT'
                 : 'DYE',
         status: status === 'Published' ? 'PUBLIC' : 'DRAFT',
-        description,
+        description: description.trim() || undefined,
         tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
         refHairUrl,
         modelUrl,
@@ -228,7 +270,14 @@ export function TemplateFormPage() {
                   <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.reference.title}</h2>
                   <label htmlFor="template-reference-upload" className="template-upload-box">
                     <input id="template-reference-upload" type="file" accept="image/*" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
-                    <img src={referencePreview} alt={pageCopy.reference.previewAlt} />
+                    {referencePreview ? (
+                      <img src={referencePreview} alt={pageCopy.reference.previewAlt} />
+                    ) : (
+                      <div className="template-upload-placeholder">
+                        <i className="feather icon-feather-image"></i>
+                        <strong>{pageCopy.reference.upload}</strong>
+                      </div>
+                    )}
                     <span><i className="feather icon-feather-upload"></i>{pageCopy.reference.upload}</span>
                   </label>
                   <p className="fs-14 text-medium-gray lh-24 mt-15px mb-0">{pageCopy.reference.help}</p>
@@ -244,7 +293,7 @@ export function TemplateFormPage() {
                     <div className="col-md-5"><label htmlFor="template-visibility" className="customer-edit-label">{pageCopy.info.status}</label><select id="template-visibility" className="form-select customer-edit-control" value={status} onChange={(event) => setStatus(event.target.value)}><option value="Draft">{pageCopy.statusOptions.Draft}</option><option value="Published">{pageCopy.statusOptions.Published}</option></select></div>
                     <div className="col-md-6"><label htmlFor="template-category" className="customer-edit-label">{pageCopy.info.category}</label><select id="template-category" className="form-select customer-edit-control" value={category} onChange={(event) => setCategory(event.target.value)}><option value="Long">{pageCopy.categoryOptions.Long}</option><option value="Medium">{pageCopy.categoryOptions.Medium}</option><option value="Short">{pageCopy.categoryOptions.Short}</option><option value="Color">{pageCopy.categoryOptions.Color}</option></select></div>
                     <div className="col-md-6"><label htmlFor="template-tags" className="customer-edit-label">{pageCopy.info.tags}</label><input id="template-tags" type="text" className="form-control customer-edit-control" value={tags} onChange={(event) => setTags(event.target.value)} placeholder={pageCopy.info.tagsPlaceholder} /></div>
-                    <div className="col-12"><label htmlFor="template-description" className="customer-edit-label">{pageCopy.info.description}</label><textarea id="template-description" className="form-control customer-edit-control" rows={6} value={description} onChange={(event) => setDescription(event.target.value)} placeholder={pageCopy.info.descriptionPlaceholder}></textarea></div>
+                    <div className="col-12"><label htmlFor="template-description" className="customer-edit-label">{pageCopy.info.description}</label><textarea aria-label={pageCopy.info.description} id="template-description" className="form-control customer-edit-control" rows={6} value={description} onChange={(event) => setDescription(event.target.value)} placeholder={pageCopy.info.descriptionPlaceholder}></textarea></div>
                   </div>
                 </div>
               </div>
@@ -286,6 +335,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}
                 </div>
               )}
             </div>
@@ -318,6 +368,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}
                     </div>
                   )}
                 </div>
@@ -350,6 +401,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}
                     </div>
                   )}
                 </div>

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

@@ -101,6 +101,9 @@ export function TemplateListPage() {
   }
 
   const handleDelete = async (templateId: string) => {
+    const template = items.find((item) => item.id === templateId)
+    if (!window.confirm(`确认删除模板「${template?.name || ''}」吗?`)) return
+
     await deleteTemplate(templateId)
     setItems((current) => current.filter((item) => item.id !== templateId))
     setSelectedIds((current) => current.filter((id) => id !== templateId))
@@ -109,6 +112,7 @@ export function TemplateListPage() {
 
   const handleBulkDelete = async () => {
     if (selectedIds.length === 0) return
+    if (!window.confirm(`确认删除已选中的 ${selectedIds.length} 个模板吗?`)) return
 
     const deletingIds = [...selectedIds]
     await deleteTemplates(deletingIds)
@@ -142,6 +146,21 @@ export function TemplateListPage() {
     return null
   }
 
+  const getCardImage = (template: TemplateEntity) => {
+    const isProcessing =
+      template.makeTaskStatus === 'WAIT'
+      || template.makeTaskStatus === 'PROCESSING'
+      || template.makeTaskStatus === 'STEP1_SUCCESS'
+      || template.generationStatus === 'pending'
+      || template.generationStatus === 'processing'
+
+    if (isProcessing) {
+      return template.referenceImage || template.image || FALLBACK_TEMPLATE_IMAGE
+    }
+
+    return template.image || FALLBACK_TEMPLATE_IMAGE
+  }
+
   const totalPages = Math.max(1, Math.ceil(total / pageSize))
   const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
 
@@ -269,16 +288,12 @@ export function TemplateListPage() {
                   <>
                     <div className="template-card-image">
                       {target ? (
-                        <Link
-                          to={target}
-                          className="template-card-image-link"
-                          aria-label={`${pageCopy.detail || '查看任务'}图片`}
-                        >
-                          <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
+                        <Link to={target} className="template-card-image-link" aria-label="查看模板封面">
+                          <img src={getCardImage(template)} alt={template.name} />
                         </Link>
                       ) : (
                         <div className="template-card-image-link" aria-disabled="true">
-                          <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
+                          <img src={getCardImage(template)} alt={template.name} />
                         </div>
                       )}
                       <div className="template-card-image-selection">
@@ -303,7 +318,7 @@ export function TemplateListPage() {
                           <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
                         </div>
                         {target ? (
-                          <Link to={target} className="template-card-title-link">
+                          <Link to={target} className="template-card-title-link" aria-label={template.name}>
                             <h3>{template.name}</h3>
                           </Link>
                         ) : (
@@ -314,15 +329,6 @@ export function TemplateListPage() {
                       <div className="template-card-footer">
                         <span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span>
                         <div className="template-card-footer-actions">
-                          {target ? (
-                            <Link to={target} className="template-card-detail-action">
-                              {pageCopy.detail}
-                            </Link>
-                          ) : (
-                            <span className="template-card-detail-action is-disabled">
-                              {pageCopy.detail}
-                            </span>
-                          )}
                           <button
                             type="button"
                             className="template-card-delete-action"
@@ -340,7 +346,7 @@ export function TemplateListPage() {
                 return (
                   <article
                     key={template.id}
-                    className={`template-card${target ? '' : ' is-static'}`}
+                    className={`template-card${target ? '' : ' is-static'}${template.status === 'DRAFT' || generationBadge ? ' is-muted' : ''}`}
                   >
                     <div className="template-card-shell">{cardContent}</div>
                   </article>

+ 5 - 8
frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx

@@ -7,6 +7,7 @@ import {
   type TemplateMakeTaskDetail,
 } from '../../../services/api/templates'
 import { usePageContext } from '../../../shared/pageContext'
+import { LOADING_ANIMATION_URL } from '../../../shared/uiAssets'
 
 const FALLBACK_PAGE_COPY = {
   title: '模板制作任务',
@@ -60,7 +61,7 @@ const FALLBACK_PAGE_COPY = {
       description: '读取 step1 模板并生成中间发型图。',
     },
     step2: {
-      title: 'Step 2 模板封面合成',
+      title: 'Step 2 模板封面生成',
       description: '组合模板包并生成最终模板封面。',
     },
   },
@@ -174,6 +175,9 @@ export function TemplateMakeTaskPage() {
                 <span>{labels.status}</span>
                 <strong>{stateCopy?.label ?? taskStatus}</strong>
               </div>
+              {tone === 'processing' || tone === 'waiting' ? (
+                <img src={LOADING_ANIMATION_URL} alt="模板制作中" className="template-task-loading-gif" />
+              ) : null}
               <div className="template-task-headline">
                 <h2 className="fw-700 text-dark-gray fs-24 mb-10px">
                   {task?.templateName || pageCopy.pendingName || FALLBACK_PAGE_COPY.pendingName}
@@ -221,13 +225,6 @@ export function TemplateMakeTaskPage() {
                     <span>{task?.step1.status || 'WAIT'}</span>
                   </div>
                   <p>{step1Copy.description}</p>
-                  {task?.step1.resultImage ? (
-                    <img
-                      src={task.step1.resultImage}
-                      alt={step1Copy.title}
-                      className="template-task-step-preview"
-                    />
-                  ) : null}
                 </div>
                 <div className={`template-task-step-card is-${getTaskTone(task?.step2.resultImage ? 'STEP2_SUCCESS' : task?.step2.status as TemplateMakeTaskDetail['taskStatus'])}`}>
                   <div className="template-task-step-header">

+ 39 - 10
frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx

@@ -111,7 +111,13 @@ describe('TemplateFormPage', () => {
     getTemplateAssets.mockResolvedValue({
       models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
       clothes: [{ id: 'c1', name: '服装A', url: 'https://cdn.example.com/clothes-a.jpg', configKey: 'c1' }],
-      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' }],
+      backgrounds: [
+        { id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' },
+        { id: 'b2', name: '背景B', url: 'https://cdn.example.com/background-b.jpg', configKey: 'b2' },
+        { id: 'b3', name: '背景C', url: 'https://cdn.example.com/background-c.jpg', configKey: 'b3' },
+        { id: 'b4', name: '背景D', url: 'https://cdn.example.com/background-d.jpg', configKey: 'b4' },
+        { id: 'b5', name: '背景E', url: 'https://cdn.example.com/background-e.jpg', configKey: 'b5' },
+      ],
     })
     uploadMedia
       .mockResolvedValueOnce({ url: 'https://cdn.example.com/reference-uploaded.jpg' })
@@ -124,7 +130,7 @@ describe('TemplateFormPage', () => {
     })
   })
 
-  it('loads dynamic assets, uploads custom model, and redirects to the task page', async () => {
+  it('loads static preset assets, limits backgrounds to four, uploads custom model, and allows empty description', async () => {
     render(
       <HelmetProvider>
         <MemoryRouter>
@@ -133,15 +139,11 @@ describe('TemplateFormPage', () => {
       </HelmetProvider>,
     )
 
-    expect(await screen.findByText('模特A')).toBeInTheDocument()
+    expect(await screen.findByText('model-1')).toBeInTheDocument()
 
     fireEvent.change(screen.getByLabelText('模板名称 *'), {
       target: { value: '柔和层次长发' },
     })
-    fireEvent.change(screen.getByLabelText('描述'), {
-      target: { value: '适合脸周柔和修饰' },
-    })
-
     fireEvent.change(screen.getByLabelText('上传参考图'), {
       target: { files: [new File(['ref'], 'reference.jpg', { type: 'image/jpeg' })] },
     })
@@ -155,12 +157,14 @@ describe('TemplateFormPage', () => {
 
     await waitFor(() => {
       expect(getTemplateAssets).toHaveBeenCalledTimes(1)
+      expect(document.querySelectorAll('input[name="background"]')).toHaveLength(4)
       expect(uploadMedia).toHaveBeenCalledWith(expect.any(File), 'ai-template-origin-image')
       expect(createTemplateMakeTask).toHaveBeenCalledWith(
         expect.objectContaining({
           templateName: '柔和层次长发',
           modelUrl: 'https://cdn.example.com/model-uploaded.jpg',
           refHairUrl: 'https://cdn.example.com/reference-uploaded.jpg',
+          description: undefined,
         }),
       )
       expect(navigate).toHaveBeenCalledWith('/zh/templates/tasks/88')
@@ -182,7 +186,7 @@ describe('TemplateFormPage', () => {
     expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
   })
 
-  it('disables submit when any asset group is empty', async () => {
+  it('falls back to bundled preset outfits when the assets response omits clothes', async () => {
     getTemplateAssets.mockResolvedValue({
       models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
       clothes: [],
@@ -197,7 +201,32 @@ describe('TemplateFormPage', () => {
       </HelmetProvider>,
     )
 
-    expect(await screen.findByRole('alert')).toHaveTextContent('素材未配置完成,请检查服务器配置')
-    expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
+    expect(await screen.findByText('1-whiteT')).toBeInTheDocument()
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+    expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).not.toBeDisabled()
+  })
+
+  it('shows a clear error when custom background is selected without choosing a file', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('model-1')).toBeInTheDocument()
+
+    fireEvent.change(screen.getByLabelText('模板名称 *'), {
+      target: { value: '柔和层次长发' },
+    })
+    fireEvent.change(screen.getByLabelText('上传参考图'), {
+      target: { files: [new File(['ref'], 'reference.jpg', { type: 'image/jpeg' })] },
+    })
+    fireEvent.click(screen.getByRole('button', { name: '上传自定义背景' }))
+    fireEvent.click(screen.getAllByRole('button', { name: 'AI生成' })[0])
+
+    expect(await screen.findByRole('alert')).toHaveTextContent('请先选择自定义背景图片')
+    expect(createTemplateMakeTask).not.toHaveBeenCalled()
   })
 })

+ 25 - 9
frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx

@@ -74,6 +74,7 @@ vi.mock('../../../../shared/pageContext', () => ({
 
 describe('TemplateListPage', () => {
   beforeEach(() => {
+    vi.stubGlobal('confirm', vi.fn(() => true))
     listTemplates.mockResolvedValue({
       items: [
         {
@@ -84,6 +85,7 @@ describe('TemplateListPage', () => {
           description: '处理中',
           tags: [],
           image: '/processing.png',
+          referenceImage: '/reference-processing.png',
           makeTaskId: '88',
           makeTaskStatus: 'PROCESSING',
           usedCount: 0,
@@ -242,9 +244,10 @@ describe('TemplateListPage', () => {
     expect(imageOverlay?.textContent).toContain('生成中')
     expect(templateCard?.querySelector('.template-card-main .template-status-badge')).toBeNull()
     expect(templateCard?.querySelector('.template-card-main .template-card-checkbox')).toBeNull()
+    expect(templateCard?.querySelector('img')).toHaveAttribute('src', '/reference-processing.png')
   })
 
-  it('renders footer delete action beside detail action and toggles current-page selection only', async () => {
+  it('removes the detail action and confirms before deleting', async () => {
     render(
       <HelmetProvider>
         <MemoryRouter>
@@ -258,18 +261,31 @@ describe('TemplateListPage', () => {
 
     expect(templateCard?.querySelector('.template-card-footer-actions')).not.toBeNull()
     expect(templateCard?.querySelector('.template-card-delete-action')).not.toBeNull()
+    expect(screen.queryByText('查看任务')).not.toBeInTheDocument()
 
-    fireEvent.click(screen.getByRole('button', { name: '全选' }))
+    fireEvent.click(screen.getByRole('button', { name: '删除处理中模板' }))
 
-    expect(screen.getByRole('button', { name: '取消全选' })).toBeInTheDocument()
-    expect(screen.getByLabelText('选择处理中模板')).toBeChecked()
-    expect(screen.getByLabelText('选择已完成模板')).toBeChecked()
-    expect(screen.getByLabelText('选择异常模板')).toBeChecked()
+    expect(confirm).toHaveBeenCalledTimes(1)
+    await waitFor(() => {
+      expect(deleteTemplate).toHaveBeenCalledWith('101')
+    })
+  })
+
+  it('marks draft and processing cards with muted styling', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
 
-    fireEvent.click(screen.getByRole('button', { name: '取消全选' }))
+    const processingLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const processingCard = processingLink.closest('article')
+    const staticDraftCard = screen.getByText('异常模板').closest('article')
 
-    expect(screen.getByRole('button', { name: '全选' })).toBeInTheDocument()
-    expect(screen.getByLabelText('选择处理中模板')).not.toBeChecked()
+    expect(processingCard).toHaveClass('is-muted')
+    expect(staticDraftCard).toHaveClass('is-muted')
   })
 
   it('renders zero summary values when the summary request fails', async () => {

+ 34 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx

@@ -135,4 +135,38 @@ describe('TemplateMakeTaskPage', () => {
       '/zh/templates/101/edit?taskId=88',
     )
   })
+
+  it('hides the step1 result and uses the updated step2 copy', async () => {
+    getTemplateMakeTaskById.mockResolvedValue({
+      id: '88',
+      templateId: '101',
+      templateName: '柔和层次长发',
+      taskStatus: 'STEP2_SUCCESS',
+      errorMessage: null,
+      createTime: '2026-06-28T12:00:00.000Z',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1: { taskId: 'step1', status: 'STEP1_SUCCESS', templateUrl: 'https://cdn.example.com/step1.json', resultImage: 'https://cdn.example.com/step1.jpg' },
+      step2: { taskId: 'step2', status: 'STEP2_SUCCESS', bundleUrl: 'https://cdn.example.com/template.zip', resultImage: 'https://cdn.example.com/final.jpg' },
+      template: { coverUrl: 'https://cdn.example.com/final.jpg', templateUrl: 'https://cdn.example.com/template.zip', status: 'DRAFT' },
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/templates/tasks/88']}>
+          <Routes>
+            <Route path="/:locale/templates/tasks/:taskId" element={<TemplateMakeTaskPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('柔和层次长发')).toBeInTheDocument()
+    expect(screen.queryByAltText('Step 1 参考发型解析')).not.toBeInTheDocument()
+    expect(screen.getByText('Step 2 模板封面生成')).toBeInTheDocument()
+    expect(screen.getByAltText('封面结果')).toHaveAttribute('src', 'https://cdn.example.com/final.jpg')
+    expect(screen.queryByAltText('模板制作中')).not.toBeInTheDocument()
+  })
 })

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

@@ -89,9 +89,6 @@ 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,
     planDate: pick(values.planDate),
     visitPurpose: values.visitPurpose || undefined,

+ 3 - 0
frontend/src/shared/uiAssets.ts

@@ -0,0 +1,3 @@
+export const DEFAULT_USER_AVATAR_URL = '/images/default-user-avatar.svg'
+
+export const LOADING_ANIMATION_URL = 'https://cdn-icons-gif.flaticon.com/10282/10282582.gif'