Эх сурвалжийг харах

Merge branch 'feature/customer-detail-form-unification'

ChenYu 4 сар өмнө
parent
commit
7d27b1150b

+ 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")

+ 23 - 0
backend/src/modules/customers/customers.controller.ts

@@ -2,6 +2,8 @@ import {
   Body,
   Controller,
   Get,
+  Param,
+  Patch,
   Post,
   Query,
   Req,
@@ -38,4 +40,25 @@ export class CustomersController {
   create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) {
     return this.customersService.create(BigInt(request.user.stylistId), body);
   }
+
+  @Get(':id')
+  getById(@Req() request: AuthenticatedRequest, @Param('id') id: string) {
+    return this.customersService.getById(
+      BigInt(request.user.stylistId),
+      BigInt(id),
+    );
+  }
+
+  @Patch(':id')
+  update(
+    @Req() request: AuthenticatedRequest,
+    @Param('id') id: string,
+    @Body() body: CustomerUpsertDto,
+  ) {
+    return this.customersService.update(
+      BigInt(request.user.stylistId),
+      BigInt(id),
+      body,
+    );
+  }
 }

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

@@ -8,9 +8,14 @@ describe('CustomersService', () => {
   const prisma = {
     customer: {
       create: jest.fn(),
+      findFirst: jest.fn(),
       findMany: jest.fn(),
+      update: jest.fn(),
       count: jest.fn(),
     },
+    customerTreatmentRecord: {
+      findMany: jest.fn(),
+    },
     aiHairSimulation: {
       findMany: jest.fn(),
     },
@@ -58,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',
@@ -88,6 +94,7 @@ describe('CustomersService', () => {
         trouble: '发量少',
         consultContent: '希望有轻盈感',
         expectLook: '自然层次感',
+        followContent: '确认褪色情况并提醒护理',
       } as never,
     );
 
@@ -103,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',
@@ -172,4 +180,156 @@ describe('CustomersService', () => {
       }),
     });
   });
+
+  it('returns customer detail with 未记录 visit frequency when fewer than two treatment records exist', async () => {
+    prisma.customer.findFirst.mockResolvedValue({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0000',
+      email: 'misaki@example.com',
+      birthday: new Date('1998-04-10T00:00:00.000Z'),
+      avatar: 'https://example.com/avatar.png',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: '注意保湿',
+      allergy: '无',
+      innerRemark: '常客',
+      followContent: '确认褪色情况并提醒护理',
+      nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
+      recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
+      firstSuggestHair: 'AUTO',
+      planDate: new Date('2026-06-28T00:00:00.000Z'),
+      isFirstRegister: 1,
+      visitPurpose: 'CONSULT_SIM',
+      trouble: '发量少',
+      consultContent: '希望轻盈',
+      expectLook: '自然层次',
+      createTime: new Date('2026-06-28T00:00:00.000Z'),
+      updateTime: new Date('2026-06-28T00:00:00.000Z'),
+      isDeleted: 0,
+    });
+    prisma.customerTreatmentRecord.findMany.mockResolvedValue([
+      { treatmentTime: new Date('2026-05-01T00:00:00.000Z') },
+    ]);
+
+    const result = await service.getById(BigInt(1), BigInt(8));
+
+    expect(result).toMatchObject({
+      id: '8',
+      name: 'Sato Misaki',
+      visitFrequencyLabel: '未记录',
+      recommendVisitDate: '2026-07-20',
+      nextFollowDate: '2026-07-12',
+      followContent: '确认褪色情况并提醒护理',
+    });
+  });
+
+  it('maps treatment intervals to a visit frequency label', async () => {
+    prisma.customer.findFirst.mockResolvedValue({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki',
+      namePronunciation: '',
+      phone: '090-0000-0000',
+      email: '',
+      birthday: null,
+      avatar: 'https://example.com/avatar.png',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: null,
+      allergy: '',
+      innerRemark: null,
+      followContent: null,
+      nextFollowDate: null,
+      recommendVisitDate: null,
+      firstSuggestHair: 'AUTO',
+      planDate: null,
+      isFirstRegister: 1,
+      visitPurpose: null,
+      trouble: null,
+      consultContent: null,
+      expectLook: null,
+      createTime: new Date('2026-06-28T00:00:00.000Z'),
+      updateTime: new Date('2026-06-28T00:00:00.000Z'),
+      isDeleted: 0,
+    });
+    prisma.customerTreatmentRecord.findMany.mockResolvedValue([
+      { treatmentTime: new Date('2026-01-01T00:00:00.000Z') },
+      { treatmentTime: new Date('2026-03-02T00:00:00.000Z') },
+      { treatmentTime: new Date('2026-05-01T00:00:00.000Z') },
+    ]);
+
+    const result = await service.getById(BigInt(1), BigInt(8));
+
+    expect(result.visitFrequencyLabel).toBe('约 2 个月');
+  });
+
+  it('updates only the current stylist customer', async () => {
+    prisma.customer.findFirst.mockResolvedValueOnce({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.customer.update.mockResolvedValue({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki Updated',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0001',
+      email: '',
+      birthday: null,
+      avatar: 'https://example.com/avatar.png',
+      expectContact: 'PHONE',
+      hairLength: 'LONG',
+      hairTexture: 'CURLY',
+      notice: null,
+      allergy: '',
+      innerRemark: null,
+      followContent: '改约下次染发并确认可联系时间',
+      nextFollowDate: null,
+      recommendVisitDate: null,
+      firstSuggestHair: 'AUTO',
+      planDate: null,
+      isFirstRegister: 1,
+      visitPurpose: null,
+      trouble: null,
+      consultContent: null,
+      expectLook: null,
+      createTime: new Date('2026-06-28T00:00:00.000Z'),
+      updateTime: new Date('2026-06-28T00:00:00.000Z'),
+      isDeleted: 0,
+    });
+    prisma.customerTreatmentRecord.findMany.mockResolvedValue([]);
+
+    await service.update(
+      BigInt(1),
+      BigInt(8),
+      {
+        name: 'Sato Misaki Updated',
+        phone: '090-0000-0001',
+        expectContact: 'PHONE',
+        hairLength: 'LONG',
+        hairTexture: 'CURLY',
+        followContent: '改约下次染发并确认可联系时间',
+      } as never,
+    );
+
+    expect(prisma.customer.update).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { id: BigInt(8) },
+        data: expect.objectContaining({
+          name: 'Sato Misaki Updated',
+          phone: '090-0000-0001',
+          expectContact: 'PHONE',
+          hairLength: 'LONG',
+          hairTexture: 'CURLY',
+          followContent: '改约下次染发并确认可联系时间',
+        }),
+      }),
+    );
+  });
 });

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

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Prisma } from '@prisma/client';
 import { PrismaService } from '../../prisma/prisma.service';
@@ -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',
@@ -162,6 +164,91 @@ export class CustomersService {
     };
   }
 
+  async getById(
+    stylistId: bigint,
+    customerId: bigint,
+  ): Promise<Record<string, unknown>> {
+    const customer = await this.prisma.customer.findFirst({
+      where: {
+        id: customerId,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+    });
+
+    if (!customer) {
+      throw new NotFoundException('Customer not found');
+    }
+
+    const visitFrequencyLabel = await this.getVisitFrequencyLabel(
+      stylistId,
+      customerId,
+    );
+
+    return {
+      ...this.toCustomerEntity(customer as unknown as CustomerRecord),
+      visitFrequencyLabel,
+    };
+  }
+
+  async update(
+    stylistId: bigint,
+    customerId: bigint,
+    body: CustomerUpsertDto,
+  ): Promise<Record<string, unknown>> {
+    const existing = await this.prisma.customer.findFirst({
+      where: {
+        id: customerId,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      select: { id: true },
+    });
+
+    if (!existing) {
+      throw new NotFoundException('Customer not found');
+    }
+
+    const updated = await this.prisma.customer.update({
+      where: { id: customerId },
+      data: {
+        name: body.name.trim(),
+        namePronunciation: body.namePronunciation?.trim() ?? '',
+        phone: body.phone.trim(),
+        email: body.email?.trim() ?? '',
+        birthday: parseDateOnly(body.birthday),
+        avatar:
+          body.avatar?.trim() ||
+          this.configService.getOrThrow<string>('customer.defaultAvatarUrl'),
+        expectContact: body.expectContact ?? 'NONE',
+        hairLength: body.hairLength ?? 'UNCONFIRM',
+        hairTexture: body.hairTexture ?? 'UNCONFIRM',
+        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',
+        planDate: parseDateOnly(body.planDate),
+        visitPurpose: body.visitPurpose ?? null,
+        trouble: body.trouble?.trim() || null,
+        consultContent: body.consultContent?.trim() || null,
+        expectLook: body.expectLook?.trim() || null,
+      },
+    });
+
+    const visitFrequencyLabel = await this.getVisitFrequencyLabel(
+      stylistId,
+      customerId,
+    );
+
+    return {
+      ...this.toCustomerEntity(updated as unknown as CustomerRecord),
+      visitFrequencyLabel,
+    };
+  }
+
   private buildListWhere(
     stylistId: bigint,
     query: ListCustomersQueryDto,
@@ -240,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,
@@ -252,4 +340,37 @@ export class CustomersService {
       followState: toFollowState(customer.nextFollowDate),
     };
   }
+
+  private async getVisitFrequencyLabel(
+    stylistId: bigint,
+    customerId: bigint,
+  ): Promise<string> {
+    const records = await this.prisma.customerTreatmentRecord.findMany({
+      where: {
+        customerId,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      orderBy: { treatmentTime: 'asc' },
+      select: { treatmentTime: true },
+    });
+
+    if (records.length < 2) {
+      return '未记录';
+    }
+
+    const gaps = records.slice(1).map((record, index) => {
+      const previousTime = records[index].treatmentTime.getTime();
+      const currentTime = record.treatmentTime.getTime();
+      return Math.round(
+        (currentTime - previousTime) / (1000 * 60 * 60 * 24),
+      );
+    });
+    const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
+
+    if (averageGap <= 45) return '约 1.5 个月';
+    if (averageGap <= 75) return '约 2 个月';
+    if (averageGap <= 105) return '约 3 个月';
+    return '约 3 个月以上';
+  }
 }

+ 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;

+ 63 - 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 () => {
@@ -244,6 +246,67 @@ describe('App (e2e)', () => {
     });
   });
 
+  it('/api/v1/customers/:id (GET) returns customer detail', async () => {
+    const token = await signToken();
+    const uniqueSuffix = Date.now();
+    const customerName = `e2e-d-${uniqueSuffix}`;
+
+    const created = await request(app.getHttpServer())
+      .post('/api/v1/customers')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        name: customerName,
+        phone: `091-${String(uniqueSuffix).slice(-4)}-3333`,
+        recommendVisitDate: '2026-07-22',
+        nextFollowDate: '2026-07-15',
+        followContent: '请提前确认发色退色情况',
+      })
+      .expect(201);
+
+    const detail = await request(app.getHttpServer())
+      .get(`/api/v1/customers/${created.body.data.id}`)
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    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 () => {
+    const token = await signToken();
+    const uniqueSuffix = Date.now();
+    const customerName = `e2e-u-${uniqueSuffix}`;
+
+    const created = await request(app.getHttpServer())
+      .post('/api/v1/customers')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        name: customerName,
+        phone: `092-${String(uniqueSuffix).slice(-4)}-4444`,
+      })
+      .expect(201);
+
+    const updated = await request(app.getHttpServer())
+      .patch(`/api/v1/customers/${created.body.data.id}`)
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        name: `${customerName}-updated`,
+        phone: `093-${String(uniqueSuffix).slice(-4)}-5555`,
+        hairLength: 'LONG',
+        hairTexture: 'CURLY',
+        followContent: '更新后的跟进内容',
+      })
+      .expect(200);
+
+    expect(updated.body.ok).toBe(true);
+    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 () => {
     const response = await request(app.getHttpServer())
       .get('/api/v1/templates')

+ 19 - 2
frontend/public/css/aitori.css

@@ -386,9 +386,9 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     overflow-x: auto;
 }
 .customer-row {
-    min-width: 760px;
+    min-width: 900px;
     display: grid;
-    grid-template-columns: 260px 140px minmax(180px, 1fr) 120px;
+    grid-template-columns: 260px 140px minmax(180px, 1fr) 120px 140px;
     gap: 16px;
     align-items: center;
     padding: 16px 0;
@@ -408,6 +408,12 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     align-items: center;
     gap: 14px;
 }
+.customer-row-action {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    justify-self: end;
+}
 .customer-avatar {
     width: 42px;
     height: 42px;
@@ -585,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;
 }

+ 3 - 0
frontend/src/App.tsx

@@ -6,6 +6,7 @@ import { LoginPage } from './pages/public/LoginPage'
 import { DashboardPage } from './pages/dashboard/DashboardPage'
 import { CustomerListPage } from './pages/dashboard/customers/CustomerListPage'
 import { CustomerFormPage } from './pages/dashboard/customers/CustomerFormPage'
+import { CustomerDetailPage } from './pages/dashboard/customers/CustomerDetailPage'
 import { TemplateListPage } from './pages/dashboard/templates/TemplateListPage'
 import { TemplateFormPage } from './pages/dashboard/templates/TemplateFormPage'
 import { TemplateDetailPage } from './pages/dashboard/templates/TemplateDetailPage'
@@ -27,6 +28,8 @@ function App() {
         <Route path={routes.dashboard} element={<RequireAuth><DashboardPage /></RequireAuth>} />
         <Route path={routes.customers} element={<RequireAuth><CustomerListPage /></RequireAuth>} />
         <Route path={routes.customerNew} element={<RequireAuth><CustomerFormPage /></RequireAuth>} />
+        <Route path={routes.customerDetail} element={<RequireAuth><CustomerDetailPage /></RequireAuth>} />
+        <Route path={routes.customerEdit} element={<RequireAuth><CustomerFormPage /></RequireAuth>} />
         <Route path={routes.templates} element={<RequireAuth><TemplateListPage /></RequireAuth>} />
         <Route path={routes.templateNew} element={<RequireAuth><TemplateFormPage /></RequireAuth>} />
         <Route path={routes.templateDetail} element={<RequireAuth><TemplateDetailPage /></RequireAuth>} />

+ 8 - 6
frontend/src/__tests__/App.test.tsx

@@ -22,6 +22,10 @@ vi.mock('../pages/dashboard/customers/CustomerFormPage', () => ({
   CustomerFormPage: () => <div>customer-form-new</div>,
 }))
 
+vi.mock('../pages/dashboard/customers/CustomerDetailPage', () => ({
+  CustomerDetailPage: () => <div>customer-detail-page</div>,
+}))
+
 vi.mock('../pages/dashboard/templates/TemplateListPage', () => ({
   TemplateListPage: () => <div>template-list-page</div>,
 }))
@@ -67,25 +71,23 @@ describe('App customer routes', () => {
     window.history.replaceState({}, '', '/zh')
   })
 
-  it('redirects removed customer detail routes back to locale root', async () => {
+  it('renders customer detail route', async () => {
     window.history.replaceState({}, '', '/zh/customers/123')
 
     render(<App />)
 
     await waitFor(() => {
-      expect(window.location.pathname).toBe('/zh')
+      expect(screen.getByText('customer-detail-page')).toBeInTheDocument()
     })
-    expect(screen.getByText('landing-page')).toBeInTheDocument()
   })
 
-  it('redirects removed customer edit routes back to locale root', async () => {
+  it('renders customer edit route with the shared form page', async () => {
     window.history.replaceState({}, '', '/zh/customers/123/edit')
 
     render(<App />)
 
     await waitFor(() => {
-      expect(window.location.pathname).toBe('/zh')
+      expect(screen.getByText('customer-form-new')).toBeInTheDocument()
     })
-    expect(screen.getByText('landing-page')).toBeInTheDocument()
   })
 })

+ 2 - 0
frontend/src/config/routes.ts

@@ -5,6 +5,8 @@ export const routes = {
   dashboard: '/:locale/dashboard',
   customers: '/:locale/customers',
   customerNew: '/:locale/customers/new',
+  customerDetail: '/:locale/customers/:customerId',
+  customerEdit: '/:locale/customers/:customerId/edit',
   templates: '/:locale/templates',
   templateNew: '/:locale/templates/new',
   templateDetail: '/:locale/templates/:templateId',

+ 12 - 9
frontend/src/i18n/locales/ja/translation.json

@@ -24,7 +24,7 @@
     "back": "戻る",
     "create": "新規作成",
     "edit": "編集",
-    "detail": "詳細",
+    "detail": "詳細を見る",
     "search": "検索",
     "upload": "写真をアップロード",
     "generated": "生成済み",
@@ -440,8 +440,8 @@
     "edit": {
       "title": "顧客情報編集",
       "description": "Aitori Stylist の顧客編集ページ。基本情報、髪質情報、薬剤メモ、施術メモ、次回フォロー設定を更新できます。",
-      "back": "詳細へ戻る",
-      "submit": "保存",
+      "back": "顧客詳細へ戻る",
+      "submit": "編集を保存",
       "badge": "顧客編集",
       "heading": "顧客情報編集",
       "lead": "{{name}}さんの基本情報、髪質情報、薬剤メモ、次回フォロー設定を更新します。"
@@ -535,7 +535,7 @@
       "nextVisitNew": "おすすめ来店日",
       "nextVisitEdit": "フォロー状況",
       "templateNew": "初回提案テンプレート",
-      "templateEdit": "連絡先",
+      "templateEdit": "フォロー連絡先",
       "messageNew": "フォローメモ",
       "messageEdit": "フォロー内容",
       "timingOptions": ["30日後", "14日後", "60日後", "設定しない"],
@@ -559,11 +559,14 @@
     "edit": "プロフィール編集",
     "simulate": "AIヘアシミュレーション",
     "badge": "顧客プロフィール",
-    "heroTags": ["初回来店 2025/11/12", "担当スタイリスト 小川泰明", "LINE 連携済み"],
     "nextVisit": {
       "badge": "次回来店",
-      "lead": "カラー退色の状況を確認し、前髪と顔周りレイヤーを調整する提案です。30日後フォロー対象です。",
-      "action": "来店リマインドを送信"
+      "lead": "リマインド日:{{date}}",
+      "action": "リマインド送信(未開発)"
+    },
+    "states": {
+      "undeveloped": "現在のバージョンでは未開発です",
+      "empty": "未入力"
     },
     "basic": {
       "badge": "基本情報",
@@ -580,8 +583,8 @@
       "badge": "写真とシミュレーション",
       "title": "過去写真とAIヘアシミュレーション",
       "items": [
-        { "label": "来店前", "title": "2026/06/01 来店時", "alt": "来店時の写真" },
-        { "label": "AIヘアシミュレーション", "title": "顔周りレイヤー案", "alt": "AIヘアシミュレーション結果" },
+        { "label": "来店前", "title": "来店時記録", "alt": "来店時の写真" },
+        { "label": "AIヘアシミュレーション", "title": "AIヘアシミュレーション結果", "alt": "AIヘアシミュレーション結果" },
         { "label": "仕上がり", "title": "仕上がり記録", "alt": "仕上がり写真" },
         { "label": "フォロー", "title": "30〜60日後提案", "alt": "次回提案シミュレーション" }
       ]

+ 12 - 9
frontend/src/i18n/locales/zh/translation.json

@@ -24,7 +24,7 @@
     "back": "返回",
     "create": "新建",
     "edit": "编辑",
-    "detail": "详情",
+    "detail": "查看详情",
     "search": "搜索",
     "upload": "上传照片",
     "generated": "已生成",
@@ -440,8 +440,8 @@
     "edit": {
       "title": "编辑顾客信息",
       "description": "Aitori Stylist 顾客信息编辑页面。可更新基本资料、发质信息、药剂备注、施术备注和下次跟进设置。",
-      "back": "返回详情",
-      "submit": "保存",
+      "back": "返回顾客详情",
+      "submit": "保存编辑",
       "badge": "编辑顾客",
       "heading": "顾客信息编辑",
       "lead": "更新{{name}}的基本资料、发质信息、药剂备注和下次跟进设置。"
@@ -535,7 +535,7 @@
       "nextVisitNew": "推荐到店日",
       "nextVisitEdit": "跟进状态",
       "templateNew": "首次建议发型模板",
-      "templateEdit": "联系方式",
+      "templateEdit": "跟进联系方式",
       "messageNew": "跟进备注",
       "messageEdit": "跟进",
       "timingOptions": ["30天后", "14天后", "60天后", "不设置"],
@@ -559,11 +559,14 @@
     "edit": "编辑档案",
     "simulate": "AI发型模拟",
     "badge": "顾客档案",
-    "heroTags": ["首次到店 2025/11/12", "负责美发师 小川泰明", "LINE 已关联"],
     "nextVisit": {
       "badge": "下次到店",
-      "lead": "建议确认染后褪色情况,并调整刘海和脸周层次。该顾客为30天后跟进对象。",
-      "action": "发送到店提醒"
+      "lead": "提醒时间:{{date}}",
+      "action": "发送提醒(未开发)"
+    },
+    "states": {
+      "undeveloped": "当前版本暂未开发",
+      "empty": "未填写"
     },
     "basic": {
       "badge": "基本资料",
@@ -580,8 +583,8 @@
       "badge": "照片与模拟",
       "title": "过往照片和AI发型模拟",
       "items": [
-        { "label": "到店前", "title": "2026/06/01 到店时", "alt": "到店时的照片" },
-        { "label": "AI发型模拟", "title": "脸周层次方案", "alt": "AI发型模拟结果" },
+        { "label": "到店前", "title": "到店时记录", "alt": "到店时的照片" },
+        { "label": "AI发型模拟", "title": "AI发型模拟结果", "alt": "AI发型模拟结果" },
         { "label": "完成后", "title": "完成效果记录", "alt": "完成效果照片" },
         { "label": "跟进", "title": "30〜60天后建议", "alt": "下次建议用模拟" }
       ]

+ 191 - 0
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -0,0 +1,191 @@
+import { useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { Helmet } from 'react-helmet-async'
+import { getCustomerById, type CustomerDetailEntity } from '../../../services/api/customers'
+import { usePageContext } from '../../../shared/pageContext'
+import { DashboardLayout } from '../../../layouts/DashboardLayout'
+
+function getDisplayValue(value?: string) {
+  return value?.trim() ? value : '—'
+}
+
+type PhotoPlaceholderItem = {
+  label: string
+  title: string
+  alt: string
+}
+
+export function CustomerDetailPage() {
+  const { locale, path, copy } = usePageContext()
+  const { customerId } = useParams()
+  const [customer, setCustomer] = useState<CustomerDetailEntity | null>(null)
+  const pageCopy = copy.customerDetailPage
+
+  useEffect(() => {
+    if (!customerId) return
+    getCustomerById(customerId).then(setCustomer).catch(() => setCustomer(null))
+  }, [customerId])
+
+  if (!customer) {
+    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>
+        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
+        <meta name="description" content={pageCopy.description} />
+      </Helmet>
+
+      <section className="stylist-workbench-top ipad-top-space-margin mb-2">
+        <div className="container">
+          <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
+            <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>
+            <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">
+            <div className="customer-detail-profile">
+              <img src={customer.avatar || '/images/aitori-before-photo.png'} 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>
+
+      <section className="pt-0">
+        <div className="container">
+          <div className="row g-4">
+            <div className="col-xl-4 col-lg-5">
+              <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>
+                <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">
+                <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>
+
+      <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>
+              <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="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>
+    </DashboardLayout>
+  )
+}

+ 51 - 24
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -1,9 +1,10 @@
-import { useState, type FormEvent } from 'react'
-import { Link } from 'react-router-dom'
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import { createCustomer } from '../../../services/api/customers'
+import { createCustomer, getCustomerById, updateCustomer } from '../../../services/api/customers'
 import { uploadMedia } from '../../../services/api/media'
 import {
+  customerToFormValues,
   emptyCustomerForm,
   formValuesToPayload,
   type CustomerFormValues,
@@ -29,6 +30,7 @@ function getSubmitErrorMessage(error: unknown, locale: 'zh' | 'ja') {
 
 export function CustomerFormPage() {
   const { locale, navigate, path, copy } = usePageContext()
+  const { customerId } = useParams()
   const [form, setForm] = useState<CustomerFormValues>(emptyCustomerForm)
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
@@ -37,7 +39,16 @@ export function CustomerFormPage() {
 
   const patch = (part: Partial<CustomerFormValues>) => setForm((current) => ({ ...current, ...part }))
   const pageCopy = copy.customerFormPage
-  const modeCopy = pageCopy.new
+  const isEditMode = Boolean(customerId)
+  const modeCopy = isEditMode ? pageCopy.edit : pageCopy.new
+
+  useEffect(() => {
+    if (!customerId) return
+
+    getCustomerById(customerId)
+      .then((customer) => setForm(customerToFormValues(customer)))
+      .catch(() => setError(locale === 'ja' ? '顧客情報の取得に失敗しました' : '获取顾客信息失败'))
+  }, [customerId, locale])
 
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
@@ -48,8 +59,13 @@ export function CustomerFormPage() {
       let avatar: string | undefined
       if (photoFile) avatar = (await uploadMedia(photoFile, 'avatar')).url
       const payload = formValuesToPayload(form, avatar)
-      await createCustomer(payload)
-      navigate(path('/customers'))
+      if (isEditMode && customerId) {
+        await updateCustomer(customerId, payload)
+        navigate(path(`/customers/${customerId}`))
+      } else {
+        await createCustomer(payload)
+        navigate(path('/customers'))
+      }
     } catch (err) {
       setError(getSubmitErrorMessage(err, locale))
     } finally {
@@ -67,9 +83,9 @@ export function CustomerFormPage() {
       <section className="stylist-workbench-top ipad-top-space-margin mb-2">
         <div className="container">
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
-            <Link to={path('/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>{modeCopy.back}</Link>
+            <Link to={isEditMode && customerId ? path(`/customers/${customerId}`) : 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>{modeCopy.back}</Link>
             <div className="d-flex flex-wrap gap-2">
-              <Link to={path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
+              <Link to={isEditMode && customerId ? path(`/customers/${customerId}`) : path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
               <button type="submit" form="customer-new-form" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user-plus me-10px"></i>{modeCopy.submit}</button>
             </div>
           </div>
@@ -77,7 +93,7 @@ export function CustomerFormPage() {
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
             <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{modeCopy.badge}</span>
             <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{modeCopy.heading}</h1>
-            <p className="fs-18 lh-30 mb-0">{modeCopy.lead}</p>
+            <p className="fs-18 lh-30 mb-0">{modeCopy.lead.replace('{{name}}', form.name || '')}</p>
           </div>
         </div>
       </section>
@@ -201,35 +217,46 @@ 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>
 
             <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
               <div>
-                <strong>{pageCopy.submit.titleNew}</strong>
-                <span>{pageCopy.submit.leadNew}</span>
+                <strong>{isEditMode ? pageCopy.submit.titleEdit : pageCopy.submit.titleNew}</strong>
+                <span>{isEditMode ? pageCopy.submit.leadEdit : pageCopy.submit.leadNew}</span>
               </div>
               <div className="customer-edit-action-panel">
                 {error ? (
@@ -238,7 +265,7 @@ export function CustomerFormPage() {
                   </div>
                 ) : null}
                 <div className="customer-edit-action-buttons">
-                  <Link to={path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
+                  <Link to={isEditMode && customerId ? path(`/customers/${customerId}`) : path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
                   <button type="submit" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user-plus me-10px"></i>{modeCopy.submit}</button>
                 </div>
               </div>

+ 11 - 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.slice(0, 4).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>
@@ -221,6 +223,14 @@ export function CustomerListPage() {
                   <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}`)}
+                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+                    >
+                      {pageCopy.detail}
+                    </Link>
+                  </span>
                 </div>
               ))}
             </div>

+ 139 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -0,0 +1,139 @@
+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'
+import { CustomerDetailPage } from '../CustomerDetailPage'
+
+const getCustomerById = vi.fn()
+
+vi.mock('../../../../services/api/customers', () => ({
+  getCustomerById: (...args: unknown[]) => getCustomerById(...args),
+}))
+
+vi.mock('../../../../shared/pageContext', async () => {
+  const actual = await vi.importActual<typeof import('../../../../shared/pageContext')>('../../../../shared/pageContext')
+  return {
+    ...actual,
+    usePageContext: () => ({
+      locale: 'zh',
+      path: (value: string) => `/zh${value}`,
+      navigate: vi.fn(),
+      copy: {
+        common: { start: '开始' },
+        customerFormPage: { hair: { fieldEditRight: '过敏备注' } },
+        customerDetailPage: {
+          title: '顾客详情',
+          description: 'desc',
+          badge: 'DETAIL',
+          back: '返回列表',
+          edit: '编辑',
+          simulate: 'AI发型模拟',
+          states: { undeveloped: '当前版本暂未开发', empty: '未填写' },
+          nextVisit: { badge: '下次到店', lead: '提醒时间:{{date}}', 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' },
+        },
+      },
+    }),
+  }
+})
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}))
+
+describe('CustomerDetailPage', () => {
+  it('renders current-field sections and undeveloped placeholders', async () => {
+    getCustomerById.mockResolvedValue({
+      id: '8',
+      name: 'Sato Misaki',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0000',
+      email: 'misaki@example.com',
+      birthday: '1998-04-10',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: '注意保湿',
+      allergy: '无',
+      innerRemark: '常客',
+      followContent: '确认褪色情况并提醒护理',
+      nextFollowDate: '2026-07-15',
+      recommendVisitDate: '2026-07-22',
+      planDate: '2026-06-28',
+      visitPurpose: 'CONSULT_SIM',
+      trouble: '发量少',
+      consultContent: '希望轻盈',
+      expectLook: '自然层次',
+      avatar: 'https://example.com/avatar.png',
+      visitFrequencyLabel: '约 2 个月',
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/8']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      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.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()
+  })
+})

+ 72 - 4
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -5,9 +5,11 @@ import type { ReactNode } from 'react'
 import { describe, expect, it, vi } from 'vitest'
 import { CustomerFormPage } from '../CustomerFormPage'
 
-const { uploadMedia, createCustomer, navigate } = vi.hoisted(() => ({
+const { uploadMedia, createCustomer, getCustomerById, updateCustomer, navigate } = vi.hoisted(() => ({
   uploadMedia: vi.fn(),
   createCustomer: vi.fn(),
+  getCustomerById: vi.fn(),
+  updateCustomer: vi.fn(),
   navigate: vi.fn(),
 }))
 
@@ -21,6 +23,8 @@ vi.mock('../../../../services/api/media', () => ({
 
 vi.mock('../../../../services/api/customers', () => ({
   createCustomer,
+  getCustomerById,
+  updateCustomer,
 }))
 
 vi.mock('../../../../shared/pageContext', () => ({
@@ -90,13 +94,13 @@ vi.mock('../../../../shared/pageContext', () => ({
           statusNew: '待设置',
           statusEdit: '待设置',
           timingNew: '跟进时间',
-          timingEdit: '跟进时间',
+          timingEdit: '推荐到店日',
           timingOptions: ['今天'],
           nextVisitNew: '下次到店',
           nextVisitEdit: '跟进状态',
           statusOptions: ['需跟进', '已发送'],
           templateNew: '跟进模板',
-          templateEdit: '联系方式',
+          templateEdit: '跟进联系方式',
           contactOptions: ['LINE', '电话', '邮箱'],
           templateOptions: ['需跟进', '已发送'],
           messageNew: '跟进内容',
@@ -123,7 +127,7 @@ vi.mock('../../../../shared/pageContext', () => ({
           badge: 'badge',
           heading: '编辑顾客',
           lead: '编辑 {{name}}',
-          submit: '保存顾客',
+          submit: '保存编辑',
           back: '返回顾客详情',
         },
       },
@@ -149,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
@@ -165,6 +170,7 @@ describe('CustomerFormPage', () => {
           name: 'Sato Misaki',
           phone: '090-0000-0000',
           avatar: 'https://example.com/avatar.png',
+          followContent: '确认褪色情况并提醒护理',
         }),
       )
     })
@@ -211,5 +217,67 @@ 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 () => {
+    getCustomerById.mockResolvedValue({
+      id: '8',
+      name: 'Sato Misaki',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0000',
+      email: 'misaki@example.com',
+      birthday: '1998-04-10',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: '注意保湿',
+      allergy: '无',
+      innerRemark: '常客',
+      followContent: '原有跟进内容',
+      nextFollowDate: '2026-07-15',
+      recommendVisitDate: '2026-07-22',
+      firstSuggestHair: 'AUTO',
+      planDate: '2026-06-28',
+      visitPurpose: 'CONSULT_SIM',
+      trouble: '发量少',
+      consultContent: '希望轻盈',
+      expectLook: '自然层次',
+      avatar: 'https://example.com/avatar.png',
+      visitFrequencyLabel: '约 2 个月',
+    })
+    updateCustomer.mockResolvedValue({ id: '8' })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/8/edit']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId/edit" element={<CustomerFormPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByDisplayValue('Sato Misaki')).toBeInTheDocument()
+    })
+
+    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(() => {
+      expect(updateCustomer).toHaveBeenCalledWith(
+        '8',
+        expect.objectContaining({
+          name: 'Sato Misaki',
+          phone: '090-0000-0001',
+          nextFollowDate: '2026-07-15',
+          recommendVisitDate: '2026-07-22',
+          followContent: '更新后的跟进内容',
+        }),
+      )
+    })
   })
 })

+ 4 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx

@@ -138,5 +138,9 @@ describe('CustomerListPage', () => {
     expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: '查看详情' })).toHaveAttribute(
+      'href',
+      '/zh/customers/customer-1',
+    )
   })
 })

+ 5 - 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'
@@ -31,6 +32,10 @@ export type CustomerEntity = {
   expectLook?: string
   followState?: 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP'
 }
+
+export type CustomerDetailEntity = CustomerEntity & {
+  visitFrequencyLabel: string
+}
 export type TemplateEntity = {
   id: string
   name: string

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

@@ -1,11 +1,13 @@
 import { http } from '../http/client'
 import type {
   CustomerDashboardSummary,
+  CustomerDetailEntity,
   CustomerEntity,
   PaginatedCustomerList,
 } from './contracts'
 export type {
   CustomerDashboardSummary,
+  CustomerDetailEntity,
   CustomerEntity,
   PaginatedCustomerList,
 } from './contracts'
@@ -23,6 +25,7 @@ export type CustomerUpsertInput = {
   notice?: string
   allergy?: string
   innerRemark?: string
+  followContent?: string
   nextFollowDate?: string
   recommendVisitDate?: string
   firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
@@ -58,6 +61,17 @@ export async function createCustomer(input: CustomerUpsertInput): Promise<Custom
   return http.post<CustomerEntity>('/customers', input)
 }
 
+export async function getCustomerById(customerId: string): Promise<CustomerDetailEntity> {
+  return http.get<CustomerDetailEntity>(`/customers/${customerId}`)
+}
+
+export async function updateCustomer(
+  customerId: string,
+  input: CustomerUpsertInput,
+): Promise<CustomerDetailEntity> {
+  return http.patch<CustomerDetailEntity>(`/customers/${customerId}`, input)
+}
+
 export async function getCustomerDashboardSummary(): Promise<CustomerDashboardSummary> {
   return http.get<CustomerDashboardSummary>('/customers/summary')
 }

+ 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,