2 Комити 945fdd9632 ... 38a311df9d

Аутор SHA1 Порука Датум
  chenyu 38a311df9d fix: persist public profile identity and keep /profile usable пре 1 месец
  chenyu a9d9318a89 feat: persist stylist public profiles and split live vs demo pages пре 1 месец

+ 20 - 0
backend/prisma/migrations/20260917_add_stylist_public_profile/migration.sql

@@ -0,0 +1,20 @@
+ALTER TABLE `hairstylist_user`
+ADD COLUMN `display_name` VARCHAR(100) NOT NULL DEFAULT '' AFTER `simulation_invite_code`,
+ADD COLUMN `public_profile_slug` VARCHAR(80) NULL AFTER `display_name`,
+ADD COLUMN `photo_url` VARCHAR(500) NOT NULL DEFAULT '' AFTER `public_profile_slug`,
+ADD COLUMN `lead` TEXT NULL AFTER `photo_url`,
+ADD COLUMN `bio` TEXT NULL AFTER `lead`,
+ADD COLUMN `booking_url` VARCHAR(500) NOT NULL DEFAULT '' AFTER `bio`,
+ADD COLUMN `experience_years` TINYINT UNSIGNED NULL AFTER `booking_url`,
+ADD COLUMN `rating` VARCHAR(8) NOT NULL DEFAULT '' AFTER `experience_years`,
+ADD COLUMN `review_count` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `rating`,
+ADD COLUMN `specialties` TEXT NULL AFTER `review_count`,
+ADD COLUMN `address` TEXT NULL AFTER `specialties`,
+ADD COLUMN `hours` TEXT NULL AFTER `address`;
+
+UPDATE `hairstylist_user`
+SET `display_name` = `username`
+WHERE `display_name` = '';
+
+CREATE UNIQUE INDEX `uk_hairstylist_public_profile_slug`
+ON `hairstylist_user` (`public_profile_slug`);

+ 12 - 0
backend/prisma/schema.prisma

@@ -87,6 +87,18 @@ model HairstylistUser {
   email                String   @db.VarChar(100)
   password             String   @db.VarChar(100)
   simulationInviteCode String?  @unique @map("simulation_invite_code") @db.VarChar(8)
+  displayName          String   @default("") @map("display_name") @db.VarChar(100)
+  slug                 String?  @unique @map("public_profile_slug") @db.VarChar(80)
+  photoUrl             String   @default("") @map("photo_url") @db.VarChar(500)
+  lead                 String?  @db.Text
+  bio                  String?  @db.Text
+  bookingUrl           String   @default("") @map("booking_url") @db.VarChar(500)
+  experienceYears      Int?     @map("experience_years") @db.UnsignedTinyInt
+  rating               String   @default("") @db.VarChar(8)
+  reviewCount          Int      @default(0) @map("review_count") @db.UnsignedInt
+  specialties          String?  @db.Text
+  address              String?  @db.Text
+  hours                String?  @db.Text
   createTime           DateTime @default(now()) @map("create_time")
   updateTime           DateTime @updatedAt @map("update_time")
   isDeleted            Int      @default(0) @map("is_deleted") @db.UnsignedTinyInt

+ 2 - 0
backend/src/app.module.ts

@@ -11,6 +11,7 @@ import { TemplatesModule } from './modules/templates/templates.module';
 import { PrismaModule } from './prisma/prisma.module';
 import { RedisModule } from './redis/redis.module';
 import { BetaApplicationsModule } from './modules/beta-applications/beta-applications.module';
+import { StylistModule } from './modules/stylist/stylist.module';
 
 @Module({
   imports: [
@@ -29,6 +30,7 @@ import { BetaApplicationsModule } from './modules/beta-applications/beta-applica
     TemplatesModule,
     SimulationsModule,
     BetaApplicationsModule,
+    StylistModule,
   ],
 })
 export class AppModule {}

+ 33 - 0
backend/src/modules/stylist/dto/stylist-profile.dto.ts

@@ -0,0 +1,33 @@
+import type { TemplateNameI18n } from '../../templates/template-name-i18n';
+
+export type StylistPublicTemplateDto = {
+  id: string;
+  templateName: string;
+  templateNameI18n: TemplateNameI18n | null;
+  coverUrl: string;
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  description: string;
+};
+
+export type StylistProfileDto = {
+  id: string;
+  username: string;
+  email: string;
+  displayName: string;
+  slug: string;
+  photoUrl: string;
+  lead: string;
+  bio: string;
+  bookingUrl: string;
+  experienceYears: number | null;
+  rating: string;
+  reviewCount: number;
+  specialties: string;
+  address: string;
+  hours: string;
+  inviteCode: string;
+  publishedTemplateCount: number;
+  templates: StylistPublicTemplateDto[];
+};
+
+export type PublicStylistProfileDto = Omit<StylistProfileDto, 'email' | 'username'>;

+ 87 - 0
backend/src/modules/stylist/dto/update-stylist-profile.dto.ts

@@ -0,0 +1,87 @@
+import { Transform, Type } from 'class-transformer';
+import {
+  IsInt,
+  IsOptional,
+  IsString,
+  IsUrl,
+  Matches,
+  Max,
+  MaxLength,
+  Min,
+  ValidateIf,
+} from 'class-validator';
+
+const optionalText = ({ value }: { value: unknown }) =>
+  typeof value === 'string' ? value : '';
+
+export class UpdateStylistProfileDto {
+  @IsString()
+  @MaxLength(100)
+  displayName!: string;
+
+  @IsString()
+  @MaxLength(80)
+  @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
+    message: '公开主页路径只能包含小写字母、数字和连字符',
+  })
+  slug!: string;
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  photoUrl?: string;
+
+  @IsOptional()
+  @IsString()
+  @Transform(optionalText)
+  lead?: string;
+
+  @IsOptional()
+  @IsString()
+  @Transform(optionalText)
+  bio?: string;
+
+  @ValidateIf((_, value) => typeof value === 'string' && value.trim() !== '')
+  @IsUrl({ require_protocol: true })
+  @MaxLength(500)
+  bookingUrl?: string;
+
+  @IsOptional()
+  @Transform(({ value }) => {
+    if (value === null || value === undefined || value === '') return null;
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : value;
+  })
+  @ValidateIf((_, value) => value !== null && value !== undefined)
+  @IsInt()
+  @Min(1)
+  @Max(30)
+  experienceYears?: number | null;
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(8)
+  @Matches(/^$|^\d(?:\.\d)?$/, { message: '评分格式不正确' })
+  rating?: string;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  reviewCount?: number;
+
+  @IsOptional()
+  @IsString()
+  @Transform(optionalText)
+  specialties?: string;
+
+  @IsOptional()
+  @IsString()
+  @Transform(optionalText)
+  address?: string;
+
+  @IsOptional()
+  @IsString()
+  @Transform(optionalText)
+  hours?: string;
+}

+ 14 - 0
backend/src/modules/stylist/stylist-profile.util.ts

@@ -0,0 +1,14 @@
+const RESERVED_SLUGS = new Set(['manage', 'new', 'edit', 'live', 'me']);
+
+export function slugifyProfilePath(value: string): string {
+  return value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 60);
+}
+
+export function isReservedProfileSlug(slug: string) {
+  return RESERVED_SLUGS.has(slug);
+}

+ 33 - 0
backend/src/modules/stylist/stylist.controller.ts

@@ -0,0 +1,33 @@
+import { Body, Controller, Get, Param, Patch, Req, UseGuards } from '@nestjs/common';
+import type { Request } from 'express';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { UpdateStylistProfileDto } from './dto/update-stylist-profile.dto';
+import { StylistService } from './stylist.service';
+
+type AuthenticatedRequest = Request & {
+  user: {
+    stylistId: string | number;
+  };
+};
+
+@Controller('stylist')
+export class StylistController {
+  constructor(private readonly stylistService: StylistService) {}
+
+  @UseGuards(JwtAuthGuard)
+  @Get('me')
+  me(@Req() request: AuthenticatedRequest) {
+    return this.stylistService.getMyProfile(BigInt(request.user.stylistId));
+  }
+
+  @UseGuards(JwtAuthGuard)
+  @Patch('me')
+  updateMe(@Req() request: AuthenticatedRequest, @Body() body: UpdateStylistProfileDto) {
+    return this.stylistService.updateMyProfile(BigInt(request.user.stylistId), body);
+  }
+
+  @Get('public/:slug')
+  publicProfile(@Param('slug') slug: string) {
+    return this.stylistService.getPublicProfile(slug);
+  }
+}

+ 12 - 0
backend/src/modules/stylist/stylist.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { PrismaModule } from '../../prisma/prisma.module';
+import { StylistController } from './stylist.controller';
+import { StylistService } from './stylist.service';
+
+@Module({
+  imports: [AuthModule, PrismaModule],
+  controllers: [StylistController],
+  providers: [StylistService],
+})
+export class StylistModule {}

+ 165 - 0
backend/src/modules/stylist/stylist.service.spec.ts

@@ -0,0 +1,165 @@
+import { ConflictException, NotFoundException } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { PrismaService } from '../../prisma/prisma.service';
+import { StylistService } from './stylist.service';
+
+describe('StylistService', () => {
+  let service: StylistService;
+  const prisma = {
+    hairstylistUser: {
+      findFirst: jest.fn(),
+      update: jest.fn(),
+    },
+    hairTemplate: {
+      count: jest.fn(),
+      findMany: jest.fn(),
+    },
+  };
+
+  const stylist = {
+    id: BigInt(7),
+    username: 'Yasuaki',
+    email: 'yasuaki@example.com',
+    simulationInviteCode: 'ABCD1234',
+    displayName: 'YASUAKI OGAWA',
+    slug: 'yasuaki-ogawa',
+    photoUrl: 'https://cdn.example.com/photo.jpg',
+    lead: '短介绍',
+    bio: '详细介绍',
+    bookingUrl: 'https://example.com/booking',
+    experienceYears: 22,
+    rating: '5.0',
+    reviewCount: 124,
+    specialties: '鲍勃',
+    address: '东京',
+    hours: '预约制',
+    password: 'hashed',
+    createTime: new Date('2026-09-17T00:00:00.000Z'),
+    updateTime: new Date('2026-09-17T00:00:00.000Z'),
+    isDeleted: 0,
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+    const moduleRef = await Test.createTestingModule({
+      providers: [StylistService, { provide: PrismaService, useValue: prisma }],
+    }).compile();
+    service = moduleRef.get(StylistService);
+  });
+
+  it('returns the authenticated stylist profile with published template count', async () => {
+    prisma.hairstylistUser.findFirst.mockResolvedValue(stylist);
+    prisma.hairTemplate.count.mockResolvedValue(3);
+
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(11),
+        templateName: '柔和长发',
+        templateNameI18n: { zh: '柔和长发', ja: '柔らかいロング', en: 'Soft long hair' },
+        coverUrl: 'https://cdn.example.com/cover.jpg',
+        category: 'LONG',
+        description: '自然长发',
+      },
+    ]);
+
+    await expect(service.getMyProfile(BigInt(7))).resolves.toMatchObject({
+      id: '7',
+      displayName: 'YASUAKI OGAWA',
+      slug: 'yasuaki-ogawa',
+      inviteCode: 'ABCD1234',
+      publishedTemplateCount: 3,
+      experienceYears: 22,
+    });
+  });
+
+  it('rejects reserved public slugs', async () => {
+    prisma.hairstylistUser.findFirst.mockResolvedValue(stylist);
+
+    await expect(
+      service.updateMyProfile(BigInt(7), {
+        displayName: 'YASUAKI OGAWA',
+        slug: 'manage',
+      }),
+    ).rejects.toThrow('该公开主页路径不可用');
+  });
+
+  it('rejects duplicate public slugs', async () => {
+    prisma.hairstylistUser.findFirst
+      .mockResolvedValueOnce(stylist)
+      .mockResolvedValueOnce({ id: BigInt(8) });
+
+    await expect(
+      service.updateMyProfile(BigInt(7), {
+        displayName: 'YASUAKI OGAWA',
+        slug: 'taken-slug',
+      }),
+    ).rejects.toBeInstanceOf(ConflictException);
+  });
+
+  it('returns a public profile with published templates only', async () => {
+    prisma.hairstylistUser.findFirst.mockResolvedValue(stylist);
+    prisma.hairTemplate.count.mockResolvedValue(1);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(11),
+        templateName: '柔和长发',
+        templateNameI18n: { zh: '柔和长发', ja: '柔らかいロング', en: 'Soft long hair' },
+        coverUrl: 'https://cdn.example.com/cover.jpg',
+        category: 'LONG',
+        description: '自然长发',
+      },
+    ]);
+
+    const result = await service.getPublicProfile('yasuaki-ogawa');
+    expect(result).not.toHaveProperty('email');
+    expect(result).not.toHaveProperty('username');
+    expect(result.templates).toEqual([
+      expect.objectContaining({
+        id: '11',
+        templateName: '柔和长发',
+        coverUrl: 'https://cdn.example.com/cover.jpg',
+      }),
+    ]);
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({
+          status: 'PUBLIC',
+          isSystemTemplate: 'NO',
+          templateUrl: { not: '' },
+        }),
+      }),
+    );
+  });
+
+  it('persists a public slug and invite code when they are missing', async () => {
+    const pending = { ...stylist, slug: null, simulationInviteCode: null };
+    prisma.hairstylistUser.findFirst
+      .mockResolvedValueOnce(pending)
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(null);
+    prisma.hairstylistUser.update.mockResolvedValue({
+      ...pending,
+      slug: 'yasuaki',
+      simulationInviteCode: 'ABCD2345',
+    });
+    prisma.hairTemplate.count.mockResolvedValue(0);
+    prisma.hairTemplate.findMany.mockResolvedValue([]);
+
+    await expect(service.getMyProfile(BigInt(7))).resolves.toMatchObject({
+      slug: 'yasuaki',
+      inviteCode: 'ABCD2345',
+    });
+    expect(prisma.hairstylistUser.update).toHaveBeenCalledWith({
+      where: { id: stylist.id },
+      data: {
+        slug: 'yasuaki',
+        simulationInviteCode: expect.stringMatching(/^[A-HJ-NP-Z2-9]{8}$/),
+      },
+    });
+  });
+
+  it('throws when the public slug does not exist', async () => {
+    prisma.hairstylistUser.findFirst.mockResolvedValue(null);
+    await expect(service.getPublicProfile('missing')).rejects.toBeInstanceOf(NotFoundException);
+  });
+});

+ 271 - 0
backend/src/modules/stylist/stylist.service.ts

@@ -0,0 +1,271 @@
+import {
+  BadRequestException,
+  ConflictException,
+  Injectable,
+  NotFoundException,
+} from '@nestjs/common';
+import { randomBytes } from 'crypto';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../prisma/prisma.service';
+import { normalizeTemplateNameI18n } from '../templates/template-name-i18n';
+import { UpdateStylistProfileDto } from './dto/update-stylist-profile.dto';
+import {
+  PublicStylistProfileDto,
+  StylistProfileDto,
+} from './dto/stylist-profile.dto';
+import { isReservedProfileSlug, slugifyProfilePath } from './stylist-profile.util';
+
+type StylistRow = {
+  id: bigint;
+  username: string;
+  email: string;
+  simulationInviteCode: string | null;
+  displayName: string;
+  slug: string | null;
+  photoUrl: string;
+  lead: string | null;
+  bio: string | null;
+  bookingUrl: string;
+  experienceYears: number | null;
+  rating: string;
+  reviewCount: number;
+  specialties: string | null;
+  address: string | null;
+  hours: string | null;
+};
+
+@Injectable()
+export class StylistService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async getMyProfile(stylistId: bigint): Promise<StylistProfileDto> {
+    const stylist = await this.ensurePublicIdentity(await this.getOwnedStylist(stylistId));
+    const [publishedTemplateCount, templates] = await this.getPublishedTemplates(stylist.id);
+    return this.toProfileDto(stylist, publishedTemplateCount, templates);
+  }
+
+  async updateMyProfile(
+    stylistId: bigint,
+    dto: UpdateStylistProfileDto,
+  ): Promise<StylistProfileDto> {
+    const stylist = await this.getOwnedStylist(stylistId);
+    const slug = this.normalizeSlug(dto.slug, stylist.username, stylist.id);
+
+    if (isReservedProfileSlug(slug)) {
+      throw new BadRequestException('该公开主页路径不可用');
+    }
+
+    const duplicate = await this.prisma.hairstylistUser.findFirst({
+      where: {
+        slug,
+        isDeleted: 0,
+        NOT: { id: stylist.id },
+      },
+      select: { id: true },
+    });
+    if (duplicate) {
+      throw new ConflictException('该公开主页路径已被使用');
+    }
+
+    try {
+      const updated = await this.prisma.hairstylistUser.update({
+        where: { id: stylist.id },
+        data: {
+          displayName: dto.displayName.trim(),
+          slug,
+          photoUrl: dto.photoUrl?.trim() ?? stylist.photoUrl,
+          lead: dto.lead?.trim() ?? '',
+          bio: dto.bio?.trim() ?? '',
+          bookingUrl: dto.bookingUrl?.trim() ?? '',
+          experienceYears: dto.experienceYears ?? null,
+          rating: dto.rating?.trim() ?? '',
+          reviewCount: dto.reviewCount ?? 0,
+          specialties: dto.specialties?.trim() ?? '',
+          address: dto.address?.trim() ?? '',
+          hours: dto.hours?.trim() ?? '',
+        },
+      });
+      const [publishedTemplateCount, templates] = await this.getPublishedTemplates(updated.id);
+      return this.toProfileDto(updated, publishedTemplateCount, templates);
+    } catch (error) {
+      if (
+        error instanceof Prisma.PrismaClientKnownRequestError &&
+        error.code === 'P2002'
+      ) {
+        throw new ConflictException('该公开主页路径已被使用');
+      }
+      throw error;
+    }
+  }
+
+  async getPublicProfile(slug: string): Promise<PublicStylistProfileDto> {
+    const normalizedSlug = slug.trim().toLowerCase();
+    if (!normalizedSlug) {
+      throw new NotFoundException('公开主页不存在');
+    }
+
+    const stylist = await this.prisma.hairstylistUser.findFirst({
+      where: { slug: normalizedSlug, isDeleted: 0 },
+    });
+    if (!stylist) {
+      throw new NotFoundException('公开主页不存在');
+    }
+
+    const resolved = await this.ensurePublicIdentity(stylist);
+    const [publishedTemplateCount, templates] = await this.getPublishedTemplates(resolved.id);
+
+    const profile = this.toProfileDto(resolved, publishedTemplateCount, templates);
+    const { email, username, ...publicProfile } = profile;
+    void email;
+    void username;
+    return publicProfile;
+  }
+
+  private async getOwnedStylist(stylistId: bigint): Promise<StylistRow> {
+    const stylist = await this.prisma.hairstylistUser.findFirst({
+      where: { id: stylistId, isDeleted: 0 },
+    });
+    if (!stylist) {
+      throw new NotFoundException('发型师账号不存在');
+    }
+    return stylist;
+  }
+
+  private async getPublishedTemplates(stylistId: bigint) {
+    const where = {
+      hairstylistId: stylistId,
+      isDeleted: 0,
+      status: 'PUBLIC' as const,
+      isSystemTemplate: 'NO' as const,
+      templateUrl: { not: '' },
+    };
+
+    const [publishedTemplateCount, templates] = await Promise.all([
+      this.prisma.hairTemplate.count({ where }),
+      this.prisma.hairTemplate.findMany({
+        where,
+        orderBy: { id: 'desc' },
+        select: {
+          id: true,
+          templateName: true,
+          templateNameI18n: true,
+          coverUrl: true,
+          category: true,
+          description: true,
+        },
+      }),
+    ]);
+
+    return [
+      publishedTemplateCount,
+      templates.map((item) => ({
+        id: item.id.toString(),
+        templateName: item.templateName,
+        templateNameI18n: normalizeTemplateNameI18n(item.templateNameI18n),
+        coverUrl: item.coverUrl,
+        category: item.category,
+        description: item.description ?? '',
+      })),
+    ] as const;
+  }
+
+  private async ensurePublicIdentity(stylist: StylistRow): Promise<StylistRow> {
+    if (stylist.slug && stylist.simulationInviteCode) return stylist;
+
+    for (let attempt = 0; attempt < 10; attempt += 1) {
+      const data: Prisma.HairstylistUserUpdateInput = {};
+      if (!stylist.slug) {
+        data.slug = await this.allocateSlug(stylist);
+      }
+      if (!stylist.simulationInviteCode) {
+        data.simulationInviteCode = await this.allocateInviteCode(stylist.id);
+      }
+
+      try {
+        return await this.prisma.hairstylistUser.update({
+          where: { id: stylist.id },
+          data,
+        });
+      } catch (error) {
+        if (
+          error instanceof Prisma.PrismaClientKnownRequestError &&
+          error.code === 'P2002'
+        ) {
+          continue;
+        }
+        throw error;
+      }
+    }
+
+    throw new BadRequestException('暂时无法生成公开主页信息,请稍后重试');
+  }
+
+  private async allocateSlug(stylist: StylistRow): Promise<string> {
+    const fromName = slugifyProfilePath(stylist.username);
+    const base =
+      fromName && !isReservedProfileSlug(fromName)
+        ? fromName
+        : `stylist-${stylist.id.toString()}`;
+
+    for (let index = 0; index < 20; index += 1) {
+      const candidate = index === 0 ? base : `${base}-${index + 1}`;
+      if (isReservedProfileSlug(candidate)) continue;
+      const taken = await this.prisma.hairstylistUser.findFirst({
+        where: { slug: candidate, NOT: { id: stylist.id } },
+        select: { id: true },
+      });
+      if (!taken) return candidate;
+    }
+
+    return `stylist-${stylist.id.toString()}`;
+  }
+
+  private async allocateInviteCode(stylistId: bigint): Promise<string> {
+    for (let attempt = 0; attempt < 10; attempt += 1) {
+      const inviteCode = this.generateInviteCode();
+      const taken = await this.prisma.hairstylistUser.findFirst({
+        where: { simulationInviteCode: inviteCode, NOT: { id: stylistId } },
+        select: { id: true },
+      });
+      if (!taken) return inviteCode;
+    }
+    throw new BadRequestException('暂时无法生成模拟代码,请稍后重试');
+  }
+
+  private generateInviteCode(): string {
+    const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+    const bytes = randomBytes(8);
+    return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
+  }
+
+  private normalizeSlug(slug: string, username: string, stylistId: bigint) {
+    return slugifyProfilePath(slug || username) || `stylist-${stylistId.toString()}`;
+  }
+
+  private toProfileDto(
+    stylist: StylistRow,
+    publishedTemplateCount: number,
+    templates: StylistProfileDto['templates'],
+  ): StylistProfileDto {
+    return {
+      id: stylist.id.toString(),
+      username: stylist.username,
+      email: stylist.email,
+      displayName: stylist.displayName || stylist.username,
+      slug: stylist.slug || slugifyProfilePath(stylist.username) || `stylist-${stylist.id.toString()}`,
+      photoUrl: stylist.photoUrl,
+      lead: stylist.lead ?? '',
+      bio: stylist.bio ?? '',
+      bookingUrl: stylist.bookingUrl,
+      experienceYears: stylist.experienceYears,
+      rating: stylist.rating,
+      reviewCount: stylist.reviewCount,
+      specialties: stylist.specialties ?? '',
+      address: stylist.address ?? '',
+      hours: stylist.hours ?? '',
+      inviteCode: stylist.simulationInviteCode ?? '',
+      publishedTemplateCount,
+      templates,
+    };
+  }
+}

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

@@ -514,6 +514,70 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     font-size: 18px;
     font-weight: 900;
 }
+.stylist-profile-form-section {
+    margin-top: 28px;
+    padding-top: 24px;
+    border-top: 1px solid rgba(44, 46, 60, 0.08);
+}
+.stylist-profile-form-section h3 {
+    margin: 0 0 6px;
+    color: var(--dark-gray);
+    font-size: 20px;
+    font-weight: 700;
+}
+.stylist-profile-form-section > p {
+    margin-bottom: 18px;
+    color: var(--medium-gray);
+    line-height: 1.7;
+}
+.stylist-profile-field-hint {
+    display: block;
+    margin-top: 8px;
+    color: var(--medium-gray);
+    font-size: 13px;
+    line-height: 1.6;
+}
+.stylist-live-template-grid {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 24px;
+    padding: 0;
+    margin: 0;
+    list-style: none;
+}
+.stylist-live-template-card {
+    width: 100%;
+    padding: 0;
+    border: 0;
+    background: #fff;
+    border-radius: 8px;
+    overflow: hidden;
+    box-shadow: 0 18px 45px rgba(35, 35, 35, 0.08);
+    text-align: left;
+}
+.stylist-live-template-card img {
+    width: 100%;
+    aspect-ratio: 4 / 5;
+    object-fit: cover;
+    display: block;
+}
+.stylist-live-template-card div {
+    padding: 18px 20px 22px;
+}
+.stylist-live-template-card h3 {
+    margin: 0 0 8px;
+    color: var(--dark-gray);
+    font-size: 20px;
+    font-weight: 700;
+}
+.stylist-live-template-card p {
+    margin: 0;
+    color: var(--medium-gray);
+    line-height: 1.7;
+}
+.ws-pre-line {
+    white-space: pre-line;
+}
 .workbench-action-icon {
     width: 46px;
     height: 46px;
@@ -2536,6 +2600,9 @@ footer ul li {
     .stylist-profile-manage-layout {
         grid-template-columns: 1fr;
     }
+    .stylist-live-template-grid {
+        grid-template-columns: 1fr;
+    }
     .customer-photo-grid {
         grid-template-columns: repeat(2, minmax(0, 1fr));
     }

+ 2 - 1
frontend/src/App.tsx

@@ -45,8 +45,9 @@ function App() {
         <Route path={routes.simulationResult} element={<RequireAuth><SimulationResultPage /></RequireAuth>} />
         <Route path={routes.simulationEdit} element={<RequireAuth><SimulationEditPage /></RequireAuth>} />
         <Route path={routes.simulationAftercare} element={<RequireAuth><AftercareResultPage /></RequireAuth>} />
-        <Route path={routes.profile} element={<StylistProfilePage />} />
         <Route path={routes.profileManage} element={<RequireAuth><StylistProfileManagePage /></RequireAuth>} />
+        <Route path={routes.profile} element={<StylistProfilePage />} />
+        <Route path={routes.profileBySlug} element={<StylistProfilePage />} />
         <Route path={routes.customerSimulation} element={<CustomerSimulationPage />} />
         <Route path={routes.customerSimulationResult} element={<CustomerSimulationResultPage />} />
         <Route path={routes.customerSimulationResultDetail} element={<CustomerSimulationResultPage />} />

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

@@ -18,6 +18,7 @@ export const routes = {
   simulationEdit: '/:locale/simulation/edit',
   simulationAftercare: '/:locale/simulation/aftercare',
   profile: '/:locale/profile',
+  profileBySlug: '/:locale/profile/:slug',
   customerSimulation: '/:locale/customer-simulation',
   customerSimulationResult: '/:locale/customer-simulation/result',
   customerSimulationResultDetail: '/:locale/customer-simulation/result/:simulationId',

+ 51 - 4
frontend/src/i18n/locales/en/translation.json

@@ -50,12 +50,24 @@
     "viewOwn": "View my profile",
     "copyCode": "Copy customer simulation code",
     "inviteLoadFailed": "Unable to load",
-    "preview": { "badge": "Public preview", "photoAlt": "{{name}} profile photo", "published": "Published", "experience": "Experience", "rating": "Rating", "reviews": "Reviews" },
+    "errors": { "loadFailed": "Unable to load profile information", "saveFailed": "Unable to save changes. Please try again shortly." },
+    "preview": { "badge": "Public preview", "photoAlt": "{{name}} profile photo", "published": "Published", "experience": "Experience", "rating": "Rating", "reviews": "Reviews", "yearUnit": " yrs", "untitled": "Untitled stylist", "emptyLead": "Add a short introduction to show it on the public profile hero." },
     "summary": { "publicUrl": "Public URL", "inviteCode": "Customer simulation code", "booking": "Booking link", "configured": "Configured", "notConfigured": "Not configured", "templates": "Published templates", "templateCount": "{{count}}" },
     "form": {
       "badge": "Edit information", "title": "Public profile", "saved": "Profile information saved.", "photoPreview": "Profile photo preview", "changePhoto": "Change profile photo",
-      "fields": { "photo": "Profile photo", "displayName": "Display name", "slug": "Public profile path", "years": "Years of experience", "rating": "Rating", "reviewCount": "Review count", "salonName": "Salon name", "lead": "Short introduction", "bio": "Full introduction", "bookingUrl": "Booking link", "address": "Address", "hours": "Business hours", "specialties": "Specialties" },
-      "submitTitle": "Save profile information", "submitLead": "After saving, the public profile for the current language will use the latest information."
+      "fields": { "photo": "Profile photo", "displayName": "Display name", "slug": "Public profile path", "years": "Years of experience", "rating": "Rating", "reviewCount": "Review count", "lead": "Short introduction", "bio": "Full introduction", "bookingUrl": "Booking link", "address": "Address", "hours": "Business hours", "specialties": "Specialties" },
+      "sections": {
+        "hero": { "title": "Hero section", "lead": "The photo, name, and introduction customers see first." },
+        "booking": { "title": "Booking button", "lead": "A separate booking entry on the public profile hero." },
+        "stats": { "title": "Hero stats", "lead": "Credibility numbers shown under the name and introduction." },
+        "salon": { "title": "Salon information", "lead": "Salon details and specialties at the bottom of the public profile." }
+      },
+      "slugHint": "Customers will open your public profile at /{{locale}}/profile/your-path.",
+      "bookingHint": "When a link is saved, the public page shows a booking button. The button label is generated automatically by language.",
+      "yearsPlaceholder": "Select",
+      "yearsHint": "The public page automatically shows the unit “{{unit}}” for the current language.",
+      "submitTitle": "Save profile information",
+      "submitLead": "After saving, the public profile uses the latest information. Customers can book and start an AI simulation from the page."
     }
   },
   "landing": {
@@ -278,7 +290,8 @@
       "profile": "Stylist profile",
       "templates": "Hair inspiration",
       "simulation": "Try with AI",
-      "reviews": "Reviews"
+      "reviews": "Reviews",
+      "booking": "Book a consultation"
     },
     "footer": "Aitori Stylist — make every consultation clearer before the appointment."
   },
@@ -1374,6 +1387,40 @@
       "lead": "Generate results based on customer information, uploaded photos, and selected templates."
     }
   },
+  "stylistProfileLive": {
+    "title": "{{name}}'s public hairstyle page",
+    "description": "View the stylist's profile and published templates, then book a visit or try an AI hairstyle simulation.",
+    "photoAlt": "{{name}} profile photo",
+    "badgeSuffix": "'s hairstyle looks",
+    "book": "Book a consultation",
+    "tryAi": "Try with my photo",
+    "browse": "Browse templates first",
+    "yearUnit": " yrs",
+    "untitled": "Stylist",
+    "stats": { "experience": "Experience", "rating": "Rating", "reviews": "Reviews" },
+    "actions": [
+      { "title": "Choose a hairstyle", "body": "Preview length, shape, and overall mood first." },
+      { "title": "Upload a photo to try", "body": "Generate an AI simulation with your own photo." },
+      { "title": "Meet the stylist", "body": "See specialties and how consultations work." }
+    ],
+    "templates": {
+      "badge": "Hairstyle looks",
+      "title": "Try these looks before you book",
+      "lead": "After choosing a look, upload your photo and preview it with AI. The result is a reference for your in-salon consultation.",
+      "empty": "This stylist has not published any templates yet."
+    },
+    "salon": { "badge": "Salon info", "title": "Salon information", "address": "Address", "hours": "Hours" },
+    "cta": {
+      "badge": "AI Try-on",
+      "title": "Try it with your own photo before booking.",
+      "body": "The AI simulation is a consultation reference, not a promise of the final result. It helps you and the stylist align faster.",
+      "button": "Start AI try-on",
+      "lightbox": "Try this look with my photo"
+    },
+    "notFound": "This stylist profile could not be found",
+    "backToDemo": "View the sample profile",
+    "loading": "Loading public profile..."
+  },
   "stylistProfilePage": {
     "title": "Omotesando stylist Yasuaki Ogawa profile",
     "description": "Omotesando stylist Yasuaki Ogawa's profile page. We will introduce 22 years of experience, one-on-one treatment, bob and layer cuts, basic salon information, reviews, and templates for AI simulation.",

+ 52 - 5
frontend/src/i18n/locales/ja/translation.json

@@ -44,18 +44,30 @@
   "profileManagePage": {
     "title": "プロフィール管理",
     "badge": "プロフィール",
-    "lead": "各スタイリストには公開プロフィールがあります。お客様はプロフィールや予約情報を確認し、AI ヘアシミュレーションへ進めます。",
+    "lead": "各スタイリストには公開プロフィールがあります。お客様は資料や予約情報を確認し、AI ヘアシミュレーションへ進めます。",
     "back": "ワークベンチに戻る",
     "viewPublic": "公開プロフィールを見る",
     "viewOwn": "自分のプロフィールを見る",
     "copyCode": "お客様用シミュレーションコードをコピー",
     "inviteLoadFailed": "取得できませんでした",
-    "preview": { "badge": "公開プレビュー", "photoAlt": "{{name}}のプロフィール写真", "published": "公開中", "experience": "経験", "rating": "評価", "reviews": "レビュー" },
+    "errors": { "loadFailed": "プロフィール情報を読み込めませんでした", "saveFailed": "保存に失敗しました。しばらくしてから再試行してください" },
+    "preview": { "badge": "公開プレビュー", "photoAlt": "{{name}}のプロフィール写真", "published": "公開中", "experience": "経験", "rating": "評価", "reviews": "レビュー", "yearUnit": "年", "untitled": "未設定のスタイリスト", "emptyLead": "短い紹介文を追加すると、公開ページの先頭に表示されます。" },
     "summary": { "publicUrl": "公開 URL", "inviteCode": "お客様用コード", "booking": "予約リンク", "configured": "設定済み", "notConfigured": "未設定", "templates": "公開テンプレート", "templateCount": "{{count}}件" },
     "form": {
       "badge": "情報を編集", "title": "公開プロフィール", "saved": "プロフィール情報を保存しました。", "photoPreview": "プロフィール写真のプレビュー", "changePhoto": "プロフィール写真を変更",
-      "fields": { "photo": "プロフィール写真", "displayName": "表示名", "slug": "公開プロフィールのパス", "years": "経験年数", "rating": "評価", "reviewCount": "レビュー数", "salonName": "サロン名", "lead": "短い紹介文", "bio": "詳しい紹介文", "bookingUrl": "予約リンク", "address": "住所", "hours": "営業時間", "specialties": "得意な提案" },
-      "submitTitle": "プロフィール情報を保存", "submitLead": "保存すると、現在の言語の公開プロフィールに最新情報が反映されます。"
+      "fields": { "photo": "プロフィール写真", "displayName": "表示名", "slug": "公開プロフィールのパス", "years": "経験年数", "rating": "評価", "reviewCount": "レビュー数", "lead": "短い紹介文", "bio": "詳しい紹介文", "bookingUrl": "予約リンク", "address": "住所", "hours": "営業時間", "specialties": "得意な提案" },
+      "sections": {
+        "hero": { "title": "トップ情報", "lead": "公開ページを開いたときに最初に見える写真、氏名、紹介文です。" },
+        "booking": { "title": "予約ボタン", "lead": "公開ページ先頭に独立して表示される予約入口です。" },
+        "stats": { "title": "実績数字", "lead": "氏名と紹介の下に表示する信頼情報です。" },
+        "salon": { "title": "サロン情報", "lead": "公開ページ下部のサロン情報と得意な提案です。" }
+      },
+      "slugHint": "お客様は /{{locale}}/profile/パス から公開ページにアクセスします。",
+      "bookingHint": "入力すると公開ページに予約ボタンが表示されます。ボタン文言は言語に応じて自動生成されます。",
+      "yearsPlaceholder": "選択してください",
+      "yearsHint": "公開ページでは単位「{{unit}}」が言語に応じて自動表示されます。",
+      "submitTitle": "プロフィール情報を保存",
+      "submitLead": "保存後、公開プロフィールに最新情報が反映されます。お客様は予約と AI シミュレーションに進めます。"
     }
   },
   "landing": {
@@ -209,7 +221,8 @@
       "profile": "プロフィール",
       "templates": "テンプレート",
       "simulation": "AIヘアシミュレーション",
-      "reviews": "レビュー"
+      "reviews": "レビュー",
+      "booking": "予約する"
     },
     "footer": "Aitori Stylist | 美容師プロフィールページ"
   },
@@ -1034,6 +1047,40 @@
       "lead": "顧客情報、アップロード写真、選択したテンプレートをもとに結果を生成します。"
     }
   },
+  "stylistProfileLive": {
+    "title": "{{name}}の公開ヘアスタイルページ",
+    "description": "スタイリストのプロフィールと公開テンプレートを確認し、予約や AI ヘアシミュレーションへ進めます。",
+    "photoAlt": "{{name}}のプロフィール写真",
+    "badgeSuffix": "のヘアスタイル参考",
+    "book": "予約する",
+    "tryAi": "自分の写真で試す",
+    "browse": "先にテンプレートを見る",
+    "yearUnit": "年",
+    "untitled": "スタイリスト",
+    "stats": { "experience": "経験", "rating": "評価", "reviews": "レビュー" },
+    "actions": [
+      { "title": "ヘアスタイルを選ぶ", "body": "長さ・輪郭・雰囲気を先に確認できます。" },
+      { "title": "写真をアップロードして試す", "body": "自分の写真で AI シミュレーションを生成します。" },
+      { "title": "スタイリストを知る", "body": "得意なスタイルやカウンセリングの特長を確認できます。" }
+    ],
+    "templates": {
+      "badge": "ヘアスタイル参考",
+      "title": "来店前に、これらのヘアスタイルを試せます",
+      "lead": "気になるスタイルを選んだら、自分の写真をアップロードして AI で大まかな印象を確認できます。結果は来店時の相談材料になります。",
+      "empty": "このスタイリストはまだ公開テンプレートがありません。"
+    },
+    "salon": { "badge": "サロン情報", "title": "サロン情報", "address": "住所", "hours": "営業時間" },
+    "cta": {
+      "badge": "AI Try-on",
+      "title": "来店前に、自分の写真で試してみましょう。",
+      "body": "AI シミュレーションはカウンセリング前の参考であり、最終仕上がりの約束ではありません。方向性を早く合わせるための手がかりです。",
+      "button": "AI ヘアシミュレーションを始める",
+      "lightbox": "このスタイルを自分の写真で試す"
+    },
+    "notFound": "このスタイリストの公開ページは見つかりません",
+    "backToDemo": "サンプルページを見る",
+    "loading": "公開ページを読み込み中..."
+  },
   "stylistProfilePage": {
     "title": "表参道スタイリスト小川泰明プロフィール",
     "description": "表参道スタイリスト小川泰明のプロフィールページ。22年の経験、マンツーマン施術、ボブやレイヤーカット、サロン基本情報、レビュー、AIシミュレーション用テンプレートを紹介します。",

+ 52 - 5
frontend/src/i18n/locales/zh/translation.json

@@ -44,18 +44,30 @@
   "profileManagePage": {
     "title": "个人主页管理",
     "badge": "个人主页",
-    "lead": "每个美发师账号都有一个公开个人主页。顾客可以在这里查看资料、预约服务,并进入 AI 发型模拟。",
+    "lead": "每个美发师账号都有一个公开个人主页。顾客可以从这里查看资料、预约剪发,并进入 AI 发型模拟。",
     "back": "返回工作台",
     "viewPublic": "查看公开主页",
     "viewOwn": "查看自己的主页",
     "copyCode": "复制顾客模拟代码",
     "inviteLoadFailed": "获取失败",
-    "preview": { "badge": "公开预览", "photoAlt": "{{name}} 的主页照片", "published": "公开中", "experience": "经验", "rating": "评分", "reviews": "评价" },
+    "errors": { "loadFailed": "无法加载个人主页信息", "saveFailed": "保存失败,请稍后重试" },
+    "preview": { "badge": "公开预览", "photoAlt": "{{name}} 的主页照片", "published": "公开中", "experience": "经验", "rating": "评分", "reviews": "评价", "yearUnit": "年", "untitled": "未命名发型师", "emptyLead": "补充短介绍后,会显示在公开主页首屏。" },
     "summary": { "publicUrl": "公开 URL", "inviteCode": "顾客模拟代码", "booking": "预约入口", "configured": "已设置", "notConfigured": "未设置", "templates": "公开模板", "templateCount": "{{count}} 件" },
     "form": {
       "badge": "编辑信息", "title": "公开资料", "saved": "个人主页信息已保存。", "photoPreview": "主页照片预览", "changePhoto": "更换主页照片",
-      "fields": { "photo": "主页照片", "displayName": "显示姓名", "slug": "公开主页路径", "years": "经验年数", "rating": "评分", "reviewCount": "评价数", "salonName": "沙龙名", "lead": "短介绍", "bio": "详细介绍", "bookingUrl": "预约链接", "address": "地址", "hours": "营业时间", "specialties": "擅长提案" },
-      "submitTitle": "保存个人主页信息", "submitLead": "保存后,当前语言的公开主页会使用最新资料;顾客可从主页进入预约和 AI 发型模拟。"
+      "fields": { "photo": "主页照片", "displayName": "显示姓名", "slug": "公开主页路径", "years": "经验年数", "rating": "评分", "reviewCount": "评价数", "lead": "短介绍", "bio": "详细介绍", "bookingUrl": "预约链接", "address": "地址", "hours": "营业时间", "specialties": "擅长提案" },
+      "sections": {
+        "hero": { "title": "主页首屏", "lead": "顾客打开公开页时最先看到的照片、姓名和介绍。" },
+        "booking": { "title": "预约按钮", "lead": "公开页首屏中独立显示的预约入口。" },
+        "stats": { "title": "首屏数字", "lead": "公开页姓名介绍下方展示的可信度信息。" },
+        "salon": { "title": "沙龙信息", "lead": "公开页下方的沙龙信息与擅长提案。" }
+      },
+      "slugHint": "顾客会通过 /{{locale}}/profile/路径 访问你的公开主页。",
+      "bookingHint": "填写后,公开页会显示预约按钮。按钮文字由系统按当前语言自动生成。",
+      "yearsPlaceholder": "请选择",
+      "yearsHint": "公开页会按语言自动显示单位「{{unit}}」。",
+      "submitTitle": "保存个人主页信息",
+      "submitLead": "保存后,公开主页会使用最新资料;顾客可从主页进入预约和 AI 发型模拟。"
     }
   },
   "landing": {
@@ -209,7 +221,8 @@
       "profile": "美发师主页",
       "templates": "发型参考",
       "simulation": "AI发型试试看",
-      "reviews": "评价"
+      "reviews": "评价",
+      "booking": "预约咨询"
     },
     "footer": "Aitori Stylist | 发型师模板主页"
   },
@@ -1033,6 +1046,40 @@
       "lead": "根据顾客信息、上传照片和所选模板生成模拟结果。"
     }
   },
+  "stylistProfileLive": {
+    "title": "{{name}} 的公开发型主页",
+    "description": "查看发型师资料、已公开的发型模板,并进入预约或 AI 发型模拟。",
+    "photoAlt": "{{name}} 的主页照片",
+    "badgeSuffix": "的发型参考",
+    "book": "预约咨询",
+    "tryAi": "用我的照片试发型",
+    "browse": "先看发型模板",
+    "yearUnit": "年",
+    "untitled": "发型师",
+    "stats": { "experience": "经验", "rating": "评分", "reviews": "评价" },
+    "actions": [
+      { "title": "选择发型模板", "body": "先看看想尝试的长度、轮廓和氛围。" },
+      { "title": "上传照片试发型", "body": "用你的照片生成 AI 模拟效果。" },
+      { "title": "了解发型师", "body": "查看擅长风格和咨询特点。" }
+    ],
+    "templates": {
+      "badge": "发型参考",
+      "title": "预约前,可以先试试这些发型",
+      "lead": "选择喜欢的发型参考后,可以上传自己的照片,用 AI 看看大致效果。结果可以作为到店咨询时的沟通参考。",
+      "empty": "这位发型师还没有公开的发型模板。"
+    },
+    "salon": { "badge": "沙龙信息", "title": "沙龙信息", "address": "地址", "hours": "营业时间" },
+    "cta": {
+      "badge": "AI Try-on",
+      "title": "预约前,先用自己的照片试试看。",
+      "body": "AI 模拟是咨询前的参考,不是最终效果承诺。它可以帮助你和发型师更快对齐想要的方向。",
+      "button": "开始 AI 试发型",
+      "lightbox": "用我的照片试这款"
+    },
+    "notFound": "该发型师主页不存在",
+    "backToDemo": "查看示例主页",
+    "loading": "正在加载公开主页..."
+  },
   "stylistProfilePage": {
     "title": "表参道美发师小川泰明个人主页",
     "description": "表参道美发师小川泰明的个人主页。介绍22年美发经验、一对一施术、波波头与层次剪发、沙龙基本信息、顾客评价和可用于 AI 发型模拟的发型模板。",

+ 33 - 8
frontend/src/layouts/PublicLayout.tsx

@@ -5,12 +5,26 @@ import { useLegacyThemePage, usePageContext } from '../shared/pageContext'
 import { AppLogo } from '../components/common/AppLogo'
 import { LocaleSwitcher } from '../components/common/LocaleSwitcher'
 
-function PublicHeader() {
-  const { locale, path, copy } = usePageContext()
+type PublicLayoutProps = {
+  children: ReactNode
+  footer?: boolean
+  variant?: 'demo' | 'live'
+  profilePath?: string
+  simulationPath?: string
+  bookingUrl?: string
+}
+
+function PublicHeader({
+  variant = 'demo',
+  profilePath: profilePathOverride,
+  simulationPath,
+  bookingUrl,
+}: Omit<PublicLayoutProps, 'children' | 'footer'>) {
+  const { path, copy } = usePageContext()
   const location = useLocation()
-  const profilePath = path('/profile')
-  const customerSimulationPath = path('/customer-simulation')
-  const isCustomerSimulation = location.pathname.startsWith(customerSimulationPath)
+  const profilePath = profilePathOverride || path('/profile')
+  const customerSimulationPath = simulationPath || path('/customer-simulation')
+  const isCustomerSimulation = location.pathname.startsWith(path('/customer-simulation'))
   const pageCopy = copy.publicLayout
   const navCopy = pageCopy.nav
 
@@ -42,7 +56,11 @@ function PublicHeader() {
                 <li className="nav-item"><a href={`${profilePath}#profile`} className="nav-link">{navCopy.profile}</a></li>
                 <li className="nav-item"><a href={`${profilePath}#templates`} className="nav-link">{navCopy.templates}</a></li>
                 <li className="nav-item"><Link to={customerSimulationPath} className={`nav-link${isCustomerSimulation ? ' active' : ''}`}>{navCopy.simulation}</Link></li>
-                <li className="nav-item"><a href={`${profilePath}#reviews`} className="nav-link">{navCopy.reviews}</a></li>
+                {variant === 'live' ? (
+                  bookingUrl ? <li className="nav-item"><a href={bookingUrl} className="nav-link" target="_blank" rel="noreferrer">{navCopy.booking}</a></li> : null
+                ) : (
+                  <li className="nav-item"><a href={`${profilePath}#reviews`} className="nav-link">{navCopy.reviews}</a></li>
+                )}
               </ul>
             </div>
           </div>
@@ -55,13 +73,20 @@ function PublicHeader() {
   )
 }
 
-export function PublicLayout({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
+export function PublicLayout({
+  children,
+  footer = true,
+  variant = 'demo',
+  profilePath,
+  simulationPath,
+  bookingUrl,
+}: PublicLayoutProps) {
   useLegacyThemePage({ bodyClassName: '', mobileNavStyle: 'classic' })
   const { copy } = usePageContext()
 
   return (
     <>
-      <PublicHeader />
+      <PublicHeader variant={variant} profilePath={profilePath} simulationPath={simulationPath} bookingUrl={bookingUrl} />
       {children}
       {footer ? (
         <footer className="p-0 footer-light position-relative">

+ 16 - 5
frontend/src/pages/customer/CustomerSimulationPage.tsx

@@ -1,5 +1,5 @@
-import { useState, type FormEvent } from 'react'
-import { Link } from 'react-router-dom'
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, useSearchParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
 import { TEMPLATE_LIMIT, usePageContext, usePreviewUrl } from '../../shared/pageContext'
@@ -16,8 +16,9 @@ export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
 
 export function CustomerSimulationPage() {
   const { locale, navigate, path, copy } = usePageContext()
+  const [searchParams] = useSearchParams()
   const pageCopy = copy.customerSimulationFlow
-  const [code, setCode] = useState('')
+  const [code, setCode] = useState(() => (searchParams.get('code') || '').trim().toUpperCase())
   const [codeChecked, setCodeChecked] = useState(false)
   const [stylistName, setStylistName] = useState('Aitori Stylist')
   const [file, setFile] = useState<File | null>(null)
@@ -37,8 +38,8 @@ export function CustomerSimulationPage() {
       : current.length >= TEMPLATE_LIMIT ? current : [...current, templateId])
   }
 
-  const handleApplyCode = async () => {
-    const normalizedCode = code.trim().toUpperCase()
+  const handleApplyCode = async (nextCode = code) => {
+    const normalizedCode = nextCode.trim().toUpperCase()
     if (normalizedCode.length !== 8) {
       setError(pageCopy.errors.codeLength)
       return
@@ -60,6 +61,7 @@ export function CustomerSimulationPage() {
       })))
       setSource('stylist')
       setSelectedTemplates([])
+      window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, normalizedCode)
     } catch (err) {
       setCodeChecked(false)
       setStylistName('Aitori Stylist')
@@ -72,6 +74,15 @@ export function CustomerSimulationPage() {
     }
   }
 
+  useEffect(() => {
+    const initialCode = (searchParams.get('code') || '').trim().toUpperCase()
+    if (initialCode.length === 8) {
+      void handleApplyCode(initialCode)
+    }
+    // Apply invite code from the public profile once on mount.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
     if (!codeChecked) return setError(pageCopy.errors.applyCode)

+ 183 - 0
frontend/src/pages/customer/StylistProfileLivePage.tsx

@@ -0,0 +1,183 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Helmet } from 'react-helmet-async'
+import { PublicLayout } from '../../layouts/PublicLayout'
+import { ImageLightbox } from '../../components/common/ImageLightbox'
+import { usePageContext } from '../../shared/pageContext'
+import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
+import { resolveLocalizedTemplateName } from '../../shared/demoContent'
+import {
+  getPublicStylistProfile,
+  getStylistMe,
+  type PublicStylistProfile,
+  type StylistPublicTemplate,
+} from '../../services/api/stylist'
+import { ApiError } from '../../services/http/client'
+import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
+
+type LiveProfile = Omit<PublicStylistProfile, 'templates'> & {
+  templates: StylistPublicTemplate[]
+}
+
+export function StylistProfileLivePage({
+  slug,
+  onAuthExpired,
+}: {
+  slug?: string
+  onAuthExpired?: () => void
+}) {
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.stylistProfileLive
+  const [profile, setProfile] = useState<LiveProfile | null>(null)
+  const [error, setError] = useState('')
+  const [preview, setPreview] = useState<StylistPublicTemplate | null>(null)
+
+  useEffect(() => {
+    let cancelled = false
+    const load = slug ? getPublicStylistProfile(slug) : getStylistMe()
+    load
+      .then((data) => {
+        if (cancelled) return
+        setProfile(data)
+        setError('')
+        if (data.inviteCode) {
+          window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, data.inviteCode)
+        }
+      })
+      .catch((err) => {
+        if (cancelled) return
+        if (!slug && err instanceof ApiError && err.status === 401) {
+          onAuthExpired?.()
+          return
+        }
+        setProfile(null)
+        setError(err instanceof Error ? err.message : pageCopy.notFound)
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [onAuthExpired, pageCopy.notFound, slug])
+
+  const simulationPath = profile?.inviteCode
+    ? path(`/customer-simulation?code=${encodeURIComponent(profile.inviteCode)}`)
+    : path('/customer-simulation')
+  const profilePath = slug ? path(`/profile/${slug}`) : path('/profile')
+  const templates = useMemo(
+    () => (profile?.templates ?? []).map((item) => ({
+      ...item,
+      templateName: resolveLocalizedTemplateName(item.templateName, item.templateNameI18n, locale),
+    })),
+    [locale, profile],
+  )
+
+  const experience = profile?.experienceYears
+    ? `${profile.experienceYears}${pageCopy.yearUnit}`
+    : '—'
+  const displayName = profile?.displayName || pageCopy.untitled
+  const photo = profile?.photoUrl || DEFAULT_USER_AVATAR_URL
+
+  if (error) {
+    return (
+      <PublicLayout>
+        <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.notFound}`}</title></Helmet>
+        <section className="half-section ipad-top-space-margin"><div className="container"><div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="alert"><p className="mb-20px">{error}</p><a href={`${path('/profile')}?demo=1`} className="btn btn-large btn-rounded btn-base-color fw-700">{pageCopy.backToDemo}</a></div></div></section>
+      </PublicLayout>
+    )
+  }
+
+  if (!profile) {
+    return (
+      <PublicLayout>
+        <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.loading}`}</title></Helmet>
+        <section className="half-section ipad-top-space-margin"><div className="container"><div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="status">{pageCopy.loading}</div></div></section>
+      </PublicLayout>
+    )
+  }
+
+  return (
+    <PublicLayout
+      variant="live"
+      profilePath={profilePath}
+      simulationPath={simulationPath}
+      bookingUrl={profile.bookingUrl || undefined}
+    >
+      <Helmet>
+        <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
+        <title>{pageCopy.title.replace('{{name}}', displayName)}</title>
+        <meta name="description" content={profile.lead || pageCopy.description} />
+      </Helmet>
+
+      <section id="profile" className="stylist-profile-hero public-profile-hero cover-background ipad-top-space-margin">
+        <div className="container"><div className="row align-items-center justify-content-center">
+          <div className="col-lg-5 col-md-8 md-mb-40px"><div className="stylist-profile-photo public-profile-photo bg-white border-radius-8px box-shadow-quadruple-large p-15px"><img src={photo} alt={pageCopy.photoAlt.replace('{{name}}', displayName)} className="border-radius-6px w-100" /></div></div>
+          <div className="col-lg-6 offset-lg-1">
+            <span className="bg-base-color d-inline-block mb-20px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{displayName}{pageCopy.badgeSuffix}</span>
+            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{displayName}</h1>
+            {profile.lead ? <p className="fs-20 lh-32 text-dark-gray fw-600 mb-20px">{profile.lead}</p> : null}
+            {profile.bio ? <p className="fs-17 lh-30 mb-30px">{profile.bio}</p> : null}
+            <div className="mb-30px d-flex flex-wrap gap-2">
+              {profile.bookingUrl ? <a href={profile.bookingUrl} target="_blank" rel="noreferrer" className="btn btn-extra-large btn-rounded btn-dark-gray btn-box-shadow fw-700"><i className="feather icon-feather-calendar me-10px"></i>{pageCopy.book}</a> : null}
+              <Link to={simulationPath} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-camera me-10px"></i>{pageCopy.tryAi}</Link>
+              <a href="#templates" className="btn btn-extra-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.browse}</a>
+            </div>
+            <div className="row row-cols-3 g-3 text-center public-profile-stats">
+              <Stat value={experience} label={pageCopy.stats.experience} />
+              <Stat value={profile.rating || '—'} label={pageCopy.stats.rating} />
+              <Stat value={profile.reviewCount ? String(profile.reviewCount) : '—'} label={pageCopy.stats.reviews} />
+            </div>
+          </div>
+        </div></div>
+      </section>
+
+      <section className="half-section public-profile-actions pt-0"><div className="container"><div className="row row-cols-1 row-cols-lg-3 row-cols-md-3 justify-content-center g-4">
+        <div className="col"><a href="#templates" className="public-profile-action-card public-profile-action-gallery"><i className="feather icon-feather-grid"></i><span><strong>{pageCopy.actions[0].title}</strong><small>{pageCopy.actions[0].body}</small></span></a></div>
+        <div className="col"><Link to={simulationPath} className="public-profile-action-card public-profile-action-simulation"><i className="feather icon-feather-camera"></i><span><strong>{pageCopy.actions[1].title}</strong><small>{pageCopy.actions[1].body}</small></span></Link></div>
+        <div className="col"><a href="#stylist" className="public-profile-action-card public-profile-action-booking"><i className="feather icon-feather-user"></i><span><strong>{pageCopy.actions[2].title}</strong><small>{pageCopy.actions[2].body}</small></span></a></div>
+      </div></div></section>
+
+      <section id="templates" className="bg-gradient-very-light-gray"><div className="container">
+        <div className="row justify-content-center mb-5"><div className="col-lg-8 text-center"><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.templates.badge}</span><h2 className="fw-700 text-dark-gray ls-minus-1px">{pageCopy.templates.title}</h2><p className="w-80 lg-w-100 mx-auto">{pageCopy.templates.lead}</p></div></div>
+        {templates.length ? (
+          <ul className="stylist-live-template-grid">
+            {templates.map((item) => (
+              <li key={item.id}>
+                <button type="button" className="stylist-live-template-card" onClick={() => setPreview(item)}>
+                  <img src={item.coverUrl || '/images/aitori-simulation-result.png'} alt={item.templateName} />
+                  <div>
+                    <h3>{item.templateName}</h3>
+                    {item.description ? <p>{item.description}</p> : null}
+                  </div>
+                </button>
+              </li>
+            ))}
+          </ul>
+        ) : (
+          <p className="text-center text-medium-gray mb-0">{pageCopy.templates.empty}</p>
+        )}
+      </div></section>
+
+      <section id="stylist" className="half-section"><div className="container"><div className="row align-items-center justify-content-center">
+        <div className="col-lg-5 md-mb-35px">
+          <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.salon.badge}</span>
+          <h2 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{pageCopy.salon.title}</h2>
+          {profile.specialties ? <p className="fs-18 lh-30 mb-0">{profile.specialties}</p> : null}
+        </div>
+        <div className="col-lg-5 offset-lg-1"><div className="row row-cols-1 g-3">
+          {profile.address ? <InfoCard label={pageCopy.salon.address} value={profile.address} /> : null}
+          {profile.hours ? <InfoCard label={pageCopy.salon.hours} value={profile.hours} /> : null}
+        </div></div>
+      </div></div></section>
+
+      <section className="half-section"><div className="container"><div className="public-profile-bottom-cta"><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.cta.badge}</span><h2 className="fw-700 text-dark-gray fs-32 mb-10px">{pageCopy.cta.title}</h2><p className="mb-0">{pageCopy.cta.body}</p></div><Link to={simulationPath} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.button}</Link></div></div></section>
+      <ImageLightbox image={preview?.coverUrl || null} alt={preview?.templateName} onClose={() => setPreview(null)} action={<Link to={simulationPath} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.lightbox}</Link>} />
+    </PublicLayout>
+  )
+}
+
+function Stat({ value, label }: { value: string; label: string }) {
+  return <div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">{value}</span><span className="fs-14">{label}</span></div></div>
+}
+
+function InfoCard({ label, value }: { label: string; value: string }) {
+  return <div className="col"><div className="bg-white border-radius-8px box-shadow-extra-large p-25px"><strong className="text-dark-gray d-block mb-5px">{label}</strong><span className="ws-pre-line">{value}</span></div></div>
+}

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

@@ -1,18 +1,32 @@
-import { useMemo, useState } from 'react'
-import { Link } from 'react-router-dom'
+import { useCallback, useMemo, useState } from 'react'
+import { Link, useParams, useSearchParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
 import { ImageLightbox } from '../../components/common/ImageLightbox'
 import { publicHairstyleTemplates, type PublicHairstyleTemplate } from '../../shared/demoContent'
-import { getStoredStylistProfile } from '../../shared/stylistProfileStore'
+import { defaultStylistProfiles } from '../../shared/stylistProfileStore'
+import { isAuthenticated } from '../../services/http/session'
 import { usePageContext } from '../../shared/pageContext'
+import { StylistProfileLivePage } from './StylistProfileLivePage'
 
 type TemplateFilter = '*' | PublicHairstyleTemplate['category']
 
 export function StylistProfilePage() {
+  const { slug } = useParams<{ slug?: string }>()
+  const [searchParams] = useSearchParams()
+  const [demoFallback, setDemoFallback] = useState(false)
+  const handleAuthExpired = useCallback(() => setDemoFallback(true), [])
+  if (slug) return <StylistProfileLivePage slug={slug} />
+  if (searchParams.get('demo') === '1' || demoFallback || !isAuthenticated()) {
+    return <StylistProfileDemoPage />
+  }
+  return <StylistProfileLivePage onAuthExpired={handleAuthExpired} />
+}
+
+function StylistProfileDemoPage() {
   const { locale, path, copy } = usePageContext()
   const pageCopy = copy.publicStylistProfileDemo
-  const profile = getStoredStylistProfile(locale)
+  const profile = defaultStylistProfiles[locale]
   const [filter, setFilter] = useState<TemplateFilter>('*')
   const [preview, setPreview] = useState<PublicHairstyleTemplate | null>(null)
   const localizedTemplates = useMemo(() => publicHairstyleTemplates.map((item) => ({

+ 176 - 0
frontend/src/pages/customer/__tests__/StylistProfilePage.test.tsx

@@ -0,0 +1,176 @@
+import { HelmetProvider } from 'react-helmet-async'
+import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import '../../../i18n'
+import { ApiError } from '../../../services/http/client'
+import { StylistProfilePage } from '../StylistProfilePage'
+
+const { isAuthenticated, getStylistMe, getPublicStylistProfile } = vi.hoisted(() => ({
+  isAuthenticated: vi.fn(),
+  getStylistMe: vi.fn(),
+  getPublicStylistProfile: vi.fn(),
+}))
+
+vi.mock('../../../services/http/session', () => ({
+  isAuthenticated,
+}))
+
+vi.mock('../../../services/api/stylist', () => ({
+  getStylistMe,
+  getPublicStylistProfile,
+}))
+
+vi.mock('../../../layouts/PublicLayout', () => ({
+  PublicLayout: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    path: (value = '') => `/zh${value}`,
+    copy: {
+      publicStylistProfileDemo: {
+        title: 'Aitori Stylist | 发型模板',
+        description: 'demo description',
+        photoAlt: '发型师头像',
+        templateSuffix: '的发型模板',
+        heroTitle: '用你的照片,试试这位发型师设计的发型。',
+        heroLead: '这里收录了 {{name}} 常用于咨询的发型模板。',
+        heroBody: '适合在预约前确认长度。',
+        try: '用我的照片试发型',
+        browse: '先看发型模板',
+        stats: ['经验', '公开模板', '评分'],
+        actions: [
+          { title: '选择发型模板', body: '浏览推荐发型。' },
+          { title: '上传照片试发型', body: '用你的照片生成效果。' },
+          { title: '了解发型师', body: '查看擅长风格。' },
+        ],
+        templates: { badge: 'Style Templates', title: '选择你想尝试的发型', lead: '点击图片可以放大查看。', filters: ['全部', '长发', '中长发', '短发'] },
+        templateNames: {},
+        stylist: { badge: 'Stylist', lead: '可以先用模板模拟。', info: ['沙龙', '营业时间', '擅长'], specialty: '脸周层次' },
+        reviews: { badge: 'Voice', title: '顾客反馈', items: [{ title: '到店前就比较安心', body: '先看过模拟图。' }] },
+        cta: { badge: 'AI Try-on', title: '选好发型后试试看。', body: '模拟结果是参考。', button: '开始 AI 试发型', lightbox: '用我的照片试这款' },
+      },
+      stylistProfileLive: {
+        title: '{{name}} 的公开发型主页',
+        description: 'live description',
+        photoAlt: '{{name}} 的主页照片',
+        badgeSuffix: '的发型参考',
+        book: '预约咨询',
+        tryAi: '用我的照片试发型',
+        browse: '先看发型模板',
+        yearUnit: '年',
+        untitled: '发型师',
+        stats: { experience: '经验', rating: '评分', reviews: '评价' },
+        actions: [
+          { title: '选择发型模板', body: '先看看想尝试的长度。' },
+          { title: '上传照片试发型', body: '用你的照片生成效果。' },
+          { title: '了解发型师', body: '查看擅长风格。' },
+        ],
+        templates: { badge: '发型参考', title: '预约前,可以先试试这些发型', lead: '选择喜欢的发型。', empty: '还没有公开模板。' },
+        salon: { badge: '沙龙信息', title: '沙龙信息', address: '地址', hours: '营业时间' },
+        cta: { badge: 'AI Try-on', title: '预约前先试试看。', body: 'AI 模拟是参考。', button: '开始 AI 试发型', lightbox: '用我的照片试这款' },
+        notFound: '该发型师主页不存在',
+        backToDemo: '查看示例主页',
+        loading: '正在加载公开主页...',
+      },
+    },
+  }),
+}))
+
+const liveProfile = {
+  id: '7',
+  username: 'Yasuaki',
+  email: 'yasuaki@example.com',
+  displayName: 'YASUAKI OGAWA',
+  slug: 'yasuaki-ogawa',
+  photoUrl: '/images/stylist-profile/demo-stylist-ai.png',
+  lead: '一对一美发师',
+  bio: '详细介绍',
+  bookingUrl: 'https://example.com/booking',
+  experienceYears: 22,
+  rating: '5.0',
+  reviewCount: 124,
+  specialties: '层次剪发',
+  address: '东京',
+  hours: '预约制',
+  inviteCode: 'ABCD1234',
+  publishedTemplateCount: 1,
+  templates: [
+    {
+      id: '11',
+      templateName: '柔和长发',
+      templateNameI18n: { zh: '柔和长发', ja: '柔らかいロング', en: 'Soft long hair' },
+      coverUrl: '/images/stylist-profile/hairstyle/long/User1-1.jpg',
+      category: 'LONG' as const,
+      description: '自然长发风格',
+    },
+  ],
+}
+
+function renderProfile(path: string) {
+  return render(
+    <HelmetProvider>
+      <MemoryRouter initialEntries={[path]}>
+        <Routes>
+          <Route path="/:locale/profile" element={<StylistProfilePage />} />
+          <Route path="/:locale/profile/:slug" element={<StylistProfilePage />} />
+        </Routes>
+      </MemoryRouter>
+    </HelmetProvider>,
+  )
+}
+
+describe('StylistProfilePage', () => {
+  beforeEach(() => {
+    isAuthenticated.mockReset()
+    getStylistMe.mockReset()
+    getPublicStylistProfile.mockReset()
+  })
+
+  it('keeps the demo public page when the visitor is not logged in', async () => {
+    isAuthenticated.mockReturnValue(false)
+    renderProfile('/zh/profile')
+    expect(screen.getByRole('heading', { name: '用你的照片,试试这位发型师设计的发型。' })).toBeInTheDocument()
+    expect(screen.getByText('顾客反馈')).toBeInTheDocument()
+    expect(getStylistMe).not.toHaveBeenCalled()
+  })
+
+  it('shows the logged-in stylist live profile without review cards', async () => {
+    isAuthenticated.mockReturnValue(true)
+    getStylistMe.mockResolvedValue(liveProfile)
+    renderProfile('/zh/profile')
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'YASUAKI OGAWA' })).toBeInTheDocument()
+    })
+    expect(screen.getByRole('link', { name: '预约咨询' })).toHaveAttribute('href', 'https://example.com/booking')
+    expect(screen.getByText('22年')).toBeInTheDocument()
+    expect(screen.getByText('柔和长发')).toBeInTheDocument()
+    expect(screen.queryByText('顾客反馈')).not.toBeInTheDocument()
+  })
+
+  it('loads a public live profile by slug without requiring login', async () => {
+    isAuthenticated.mockReturnValue(false)
+    getPublicStylistProfile.mockResolvedValue(liveProfile)
+    renderProfile('/zh/profile/yasuaki-ogawa')
+
+    await waitFor(() => {
+      expect(getPublicStylistProfile).toHaveBeenCalledWith('yasuaki-ogawa')
+    })
+    expect(screen.getByText('层次剪发')).toBeInTheDocument()
+    expect(screen.queryByText('使用次数')).not.toBeInTheDocument()
+  })
+
+  it('falls back to the demo page when the logged-in session has expired', async () => {
+    isAuthenticated.mockReturnValue(true)
+    getStylistMe.mockRejectedValue(new ApiError('UNAUTHORIZED', '登录已过期', 401))
+    renderProfile('/zh/profile')
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: '用你的照片,试试这位发型师设计的发型。' })).toBeInTheDocument()
+    })
+    expect(screen.getByText('顾客反馈')).toBeInTheDocument()
+  })
+})

+ 209 - 49
frontend/src/pages/dashboard/StylistProfileManagePage.tsx

@@ -2,45 +2,110 @@ import { useEffect, useState, type FormEvent } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { DashboardLayout } from '../../layouts/DashboardLayout'
-import { usePageContext } from '../../shared/pageContext'
-import { getStoredStylistProfile, saveStoredStylistProfile, type StylistProfileDraft } from '../../shared/stylistProfileStore'
-import { getSimulationInviteCode } from '../../services/api/simulations'
+import { usePageContext, usePreviewUrl } from '../../shared/pageContext'
+import { uploadMedia } from '../../services/api/media'
+import {
+  getStylistMe,
+  updateStylistMe,
+  type StylistProfile,
+} from '../../services/api/stylist'
+import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
+
+const EXPERIENCE_YEARS = Array.from({ length: 30 }, (_, index) => index + 1)
+
+const emptyProfile: StylistProfile = {
+  id: '',
+  username: '',
+  email: '',
+  displayName: '',
+  slug: '',
+  photoUrl: '',
+  lead: '',
+  bio: '',
+  bookingUrl: '',
+  experienceYears: null,
+  rating: '',
+  reviewCount: 0,
+  specialties: '',
+  address: '',
+  hours: '',
+  inviteCode: '',
+  publishedTemplateCount: 0,
+  templates: [],
+}
 
 export function StylistProfileManagePage() {
   const { locale, navigate, path, copy } = usePageContext()
   const pageCopy = copy.profileManagePage
-  const [profile, setProfile] = useState<StylistProfileDraft>(() => getStoredStylistProfile(locale))
+  const [profile, setProfile] = useState<StylistProfile>(emptyProfile)
+  const [photoFile, setPhotoFile] = useState<File | null>(null)
   const [saved, setSaved] = useState(false)
-  const [inviteCode, setInviteCode] = useState('--------')
+  const [saving, setSaving] = useState(false)
+  const [error, setError] = useState('')
+  const photoPreview = usePreviewUrl(photoFile, profile.photoUrl || DEFAULT_USER_AVATAR_URL)
+  const publicPath = profile.slug ? `/${locale}/profile/${profile.slug}` : `/${locale}/profile`
+  const yearUnit = pageCopy.preview.yearUnit
+  const inviteCode = profile.inviteCode || '--------'
 
   useEffect(() => {
-    getSimulationInviteCode()
-      .then((data) => setInviteCode(data.inviteCode))
-      .catch(() => setInviteCode(pageCopy.inviteLoadFailed))
-  }, [pageCopy.inviteLoadFailed])
+    let cancelled = false
+    getStylistMe()
+      .then((data) => {
+        if (!cancelled) setProfile(data)
+      })
+      .catch(() => {
+        if (!cancelled) setError(pageCopy.errors.loadFailed)
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [pageCopy.errors.loadFailed])
 
-  useEffect(() => {
-    setProfile(getStoredStylistProfile(locale))
-    setSaved(false)
-  }, [locale])
-  const patch = (part: Partial<StylistProfileDraft>) => {
+  const patch = (part: Partial<StylistProfile>) => {
     setSaved(false)
+    setError('')
     setProfile((current) => ({ ...current, ...part }))
   }
 
-  const handlePhoto = (file?: File) => {
-    if (!file) return
-    const reader = new FileReader()
-    reader.onload = () => patch({ photo: String(reader.result || '') })
-    reader.readAsDataURL(file)
-  }
-
-  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
+  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
-    saveStoredStylistProfile(locale, profile)
-    setSaved(true)
+    if (!profile.displayName.trim() || saving) return
+    setSaving(true)
+    setError('')
+    try {
+      let photoUrl = profile.photoUrl
+      if (photoFile) {
+        photoUrl = (await uploadMedia(photoFile, 'avatar')).url
+      }
+      const updated = await updateStylistMe({
+        displayName: profile.displayName.trim(),
+        slug: profile.slug.trim(),
+        photoUrl,
+        lead: profile.lead,
+        bio: profile.bio,
+        bookingUrl: profile.bookingUrl.trim(),
+        experienceYears: profile.experienceYears,
+        rating: profile.rating.trim(),
+        reviewCount: Number(profile.reviewCount) || 0,
+        specialties: profile.specialties,
+        address: profile.address,
+        hours: profile.hours,
+      })
+      setProfile(updated)
+      setPhotoFile(null)
+      setSaved(true)
+    } catch (err) {
+      setSaved(false)
+      setError(err instanceof Error ? err.message : pageCopy.errors.saveFailed)
+    } finally {
+      setSaving(false)
+    }
   }
 
+  const experienceLabel = profile.experienceYears ? `${profile.experienceYears}${yearUnit}` : '—'
+  const fieldCopy = pageCopy.form.fields
+  const sections = pageCopy.form.sections
+
   return (
     <DashboardLayout>
       <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
@@ -49,8 +114,8 @@ export function StylistProfileManagePage() {
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
             <Link to={path('/dashboard')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link>
             <div className="d-flex flex-wrap gap-2">
-              <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-external-link me-8px"></i>{pageCopy.viewPublic}</Link>
-              <button type="submit" form="stylist-profile-form" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{copy.common.save}</button>
+              <Link to={path(profile.slug ? `/profile/${profile.slug}` : '/profile')} target="_blank" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-external-link me-8px"></i>{pageCopy.viewPublic}</Link>
+              <button type="submit" form="stylist-profile-form" disabled={saving} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-8px"></i>{saving ? copy.common.sending : copy.common.save}</button>
             </div>
           </div>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
@@ -67,33 +132,97 @@ export function StylistProfileManagePage() {
             <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
               <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.preview.badge}</span>
               <div className="stylist-profile-manage-card mt-20px">
-                <img src={profile.photo} alt={pageCopy.preview.photoAlt.replace('{{name}}', profile.displayName)} />
-                <div><span className="template-status-badge">{pageCopy.preview.published}</span><h2>{profile.displayName}</h2><p>{profile.lead}</p><div className="stylist-profile-manage-stats"><span><strong>{profile.years}</strong>{pageCopy.preview.experience}</span><span><strong>{profile.rating}</strong>{pageCopy.preview.rating}</span><span><strong>{profile.reviewCount}</strong>{pageCopy.preview.reviews}</span></div></div>
+                <img src={photoPreview} alt={pageCopy.preview.photoAlt.replace('{{name}}', profile.displayName || pageCopy.preview.untitled)} />
+                <div>
+                  <span className="template-status-badge">{pageCopy.preview.published}</span>
+                  <h2>{profile.displayName || pageCopy.preview.untitled}</h2>
+                  <p>{profile.lead || pageCopy.preview.emptyLead}</p>
+                  <div className="stylist-profile-manage-stats">
+                    <span><strong>{experienceLabel}</strong>{pageCopy.preview.experience}</span>
+                    <span><strong>{profile.rating || '—'}</strong>{pageCopy.preview.rating}</span>
+                    <span><strong>{profile.reviewCount || '—'}</strong>{pageCopy.preview.reviews}</span>
+                  </div>
+                </div>
+              </div>
+              <div className="template-detail-summary mt-25px">
+                <div><span>{pageCopy.summary.publicUrl}</span><strong>{publicPath}</strong></div>
+                <div><span>{pageCopy.summary.inviteCode}</span><strong>{inviteCode}</strong></div>
+                <div><span>{pageCopy.summary.booking}</span><strong>{profile.bookingUrl ? pageCopy.summary.configured : pageCopy.summary.notConfigured}</strong></div>
+                <div><span>{pageCopy.summary.templates}</span><strong>{pageCopy.summary.templateCount.replace('{{count}}', String(profile.publishedTemplateCount))}</strong></div>
               </div>
-              <div className="template-detail-summary mt-25px"><div><span>{pageCopy.summary.publicUrl}</span><strong>/{locale}/profile</strong></div><div><span>{pageCopy.summary.inviteCode}</span><strong>{inviteCode}</strong></div><div><span>{pageCopy.summary.booking}</span><strong>{profile.bookingUrl ? pageCopy.summary.configured : pageCopy.summary.notConfigured}</strong></div><div><span>{pageCopy.summary.templates}</span><strong>{pageCopy.summary.templateCount.replace('{{count}}', '8')}</strong></div></div>
               <button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 w-100 mt-15px" disabled={inviteCode.length !== 8} onClick={() => void navigator.clipboard.writeText(inviteCode)}>{pageCopy.copyCode}</button>
               <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100 mt-25px">{pageCopy.viewOwn}</Link>
             </aside>
 
             <form id="stylist-profile-form" className="customer-edit-form workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" onSubmit={handleSubmit}>
-              <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.form.badge}</span><h2 className="fw-700 text-dark-gray fs-26 mb-25px mt-10px">{pageCopy.form.title}</h2>
+              <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.form.badge}</span>
+              <h2 className="fw-700 text-dark-gray fs-26 mb-25px mt-10px">{pageCopy.form.title}</h2>
               {saved ? <div className="alert alert-success" role="status">{pageCopy.form.saved}</div> : null}
-              <div className="row g-3">
-                <div className="col-12"><label htmlFor="profile-photo-upload" className="customer-edit-label">{pageCopy.form.fields.photo}</label><label htmlFor="profile-photo-upload" className="customer-photo-upload"><input id="profile-photo-upload" type="file" accept="image/*" onChange={(event) => handlePhoto(event.target.files?.[0])} /><span className="customer-photo-preview"><img src={profile.photo} alt={pageCopy.form.photoPreview} /></span><span>{pageCopy.form.changePhoto}</span></label></div>
-                <ProfileInput id="profile-display-name" label={pageCopy.form.fields.displayName} value={profile.displayName} onChange={(displayName) => patch({ displayName })} />
-                <ProfileInput id="profile-slug" label={pageCopy.form.fields.slug} value={profile.slug} onChange={(slug) => patch({ slug })} />
-                <ProfileInput id="profile-years" label={pageCopy.form.fields.years} value={profile.years} col="col-md-4" onChange={(years) => patch({ years })} />
-                <ProfileInput id="profile-rating" label={pageCopy.form.fields.rating} value={profile.rating} col="col-md-4" onChange={(rating) => patch({ rating })} />
-                <ProfileInput id="profile-review-count" label={pageCopy.form.fields.reviewCount} value={profile.reviewCount} col="col-md-4" onChange={(reviewCount) => patch({ reviewCount })} />
-                <ProfileInput id="profile-salon" label={pageCopy.form.fields.salonName} value={profile.salonName} col="col-12" onChange={(salonName) => patch({ salonName })} />
-                <ProfileTextarea id="profile-lead" label={pageCopy.form.fields.lead} value={profile.lead} rows={3} onChange={(lead) => patch({ lead })} />
-                <ProfileTextarea id="profile-bio" label={pageCopy.form.fields.bio} value={profile.bio} rows={5} onChange={(bio) => patch({ bio })} />
-                <ProfileInput id="profile-booking-url" label={pageCopy.form.fields.bookingUrl} value={profile.bookingUrl} type="url" col="col-12" onChange={(bookingUrl) => patch({ bookingUrl })} />
-                <ProfileTextarea id="profile-address" label={pageCopy.form.fields.address} value={profile.address} rows={3} onChange={(address) => patch({ address })} />
-                <ProfileTextarea id="profile-hours" label={pageCopy.form.fields.hours} value={profile.hours} rows={4} col="col-md-6" onChange={(hours) => patch({ hours })} />
-                <ProfileTextarea id="profile-specialties" label={pageCopy.form.fields.specialties} value={profile.specialties} rows={4} col="col-md-6" onChange={(specialties) => patch({ specialties })} />
+              {error ? <div className="alert alert-danger" role="alert">{error}</div> : null}
+
+              <section className="stylist-profile-form-section">
+                <h3>{sections.hero.title}</h3>
+                <p>{sections.hero.lead}</p>
+                <div className="row g-3">
+                  <div className="col-12">
+                    <label htmlFor="profile-photo-upload" className="customer-edit-label">{fieldCopy.photo}</label>
+                    <label htmlFor="profile-photo-upload" className="customer-photo-upload">
+                      <input id="profile-photo-upload" type="file" accept="image/*" onChange={(event) => { setPhotoFile(event.target.files?.[0] ?? null); setSaved(false) }} />
+                      <span className="customer-photo-preview"><img src={photoPreview} alt={pageCopy.form.photoPreview} /></span>
+                      <span>{pageCopy.form.changePhoto}</span>
+                    </label>
+                  </div>
+                  <ProfileInput id="profile-display-name" label={fieldCopy.displayName} value={profile.displayName} onChange={(displayName) => patch({ displayName })} required />
+                  <ProfileInput id="profile-slug" label={fieldCopy.slug} value={profile.slug} onChange={(slug) => patch({ slug: slug.toLowerCase() })} hint={pageCopy.form.slugHint.replace('{{locale}}', locale)} required />
+                  <ProfileTextarea id="profile-lead" label={fieldCopy.lead} value={profile.lead} rows={3} onChange={(lead) => patch({ lead })} />
+                  <ProfileTextarea id="profile-bio" label={fieldCopy.bio} value={profile.bio} rows={5} onChange={(bio) => patch({ bio })} />
+                </div>
+              </section>
+
+              <section className="stylist-profile-form-section">
+                <h3>{sections.booking.title}</h3>
+                <p>{sections.booking.lead}</p>
+                <div className="row g-3">
+                  <ProfileInput id="profile-booking-url" label={fieldCopy.bookingUrl} value={profile.bookingUrl} type="url" col="col-12" onChange={(bookingUrl) => patch({ bookingUrl })} hint={pageCopy.form.bookingHint} />
+                </div>
+              </section>
+
+              <section className="stylist-profile-form-section">
+                <h3>{sections.stats.title}</h3>
+                <p>{sections.stats.lead}</p>
+                <div className="row g-3">
+                  <div className="col-md-4">
+                    <label htmlFor="profile-years" className="customer-edit-label">{fieldCopy.years}</label>
+                    <select id="profile-years" className="form-control customer-edit-control" value={profile.experienceYears ?? ''} onChange={(event) => patch({ experienceYears: event.target.value ? Number(event.target.value) : null })}>
+                      <option value="">{pageCopy.form.yearsPlaceholder}</option>
+                      {EXPERIENCE_YEARS.map((year) => (
+                        <option key={year} value={year}>{year}</option>
+                      ))}
+                    </select>
+                    <span className="stylist-profile-field-hint">{pageCopy.form.yearsHint.replace('{{unit}}', yearUnit)}</span>
+                  </div>
+                  <ProfileInput id="profile-rating" label={fieldCopy.rating} value={profile.rating} col="col-md-4" onChange={(rating) => patch({ rating })} />
+                  <ProfileInput id="profile-review-count" label={fieldCopy.reviewCount} value={String(profile.reviewCount || '')} col="col-md-4" onChange={(reviewCount) => patch({ reviewCount: Number(reviewCount.replace(/\D/g, '')) || 0 })} />
+                </div>
+              </section>
+
+              <section className="stylist-profile-form-section">
+                <h3>{sections.salon.title}</h3>
+                <p>{sections.salon.lead}</p>
+                <div className="row g-3">
+                  <ProfileTextarea id="profile-specialties" label={fieldCopy.specialties} value={profile.specialties} rows={4} onChange={(specialties) => patch({ specialties })} />
+                  <ProfileTextarea id="profile-address" label={fieldCopy.address} value={profile.address} rows={3} onChange={(address) => patch({ address })} />
+                  <ProfileTextarea id="profile-hours" label={fieldCopy.hours} value={profile.hours} rows={4} onChange={(hours) => patch({ hours })} />
+                </div>
+              </section>
+
+              <div className="customer-edit-actions border-radius-8px p-25px mt-4">
+                <div><strong>{pageCopy.form.submitTitle}</strong><span>{pageCopy.form.submitLead}</span></div>
+                <div className="d-flex flex-wrap gap-2">
+                  <button type="button" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700" onClick={() => navigate(path('/dashboard'))}>{copy.common.cancel}</button>
+                  <button type="submit" disabled={saving} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{saving ? copy.common.sending : copy.common.save}</button>
+                </div>
               </div>
-              <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>{pageCopy.form.submitTitle}</strong><span>{pageCopy.form.submitLead}</span></div><div className="d-flex flex-wrap gap-2"><button type="button" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700" onClick={() => navigate(path('/dashboard'))}>{copy.common.cancel}</button><button type="submit" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{copy.common.save}</button></div></div>
             </form>
           </div>
         </div>
@@ -102,10 +231,41 @@ export function StylistProfileManagePage() {
   )
 }
 
-function ProfileInput({ id, label, value, onChange, type = 'text', col = 'col-md-6' }: { id: string; label: string; value: string; onChange: (value: string) => void; type?: string; col?: string }) {
-  return <div className={col}><label htmlFor={id} className="customer-edit-label">{label}</label><input id={id} className="form-control customer-edit-control" type={type} value={value} onChange={(event) => onChange(event.target.value)} /></div>
+function ProfileInput({
+  id, label, value, onChange, type = 'text', col = 'col-md-6', required = false, hint,
+}: {
+  id: string
+  label: string
+  value: string
+  onChange: (value: string) => void
+  type?: string
+  col?: string
+  required?: boolean
+  hint?: string
+}) {
+  return (
+    <div className={col}>
+      <label htmlFor={id} className="customer-edit-label">{label}</label>
+      <input id={id} className="form-control customer-edit-control" type={type} value={value} required={required} onChange={(event) => onChange(event.target.value)} />
+      {hint ? <span className="stylist-profile-field-hint">{hint}</span> : null}
+    </div>
+  )
 }
 
-function ProfileTextarea({ id, label, value, onChange, rows, col = 'col-12' }: { id: string; label: string; value: string; onChange: (value: string) => void; rows: number; col?: string }) {
-  return <div className={col}><label htmlFor={id} className="customer-edit-label">{label}</label><textarea id={id} className="form-control customer-edit-control" rows={rows} value={value} onChange={(event) => onChange(event.target.value)} /></div>
+function ProfileTextarea({
+  id, label, value, onChange, rows, col = 'col-12',
+}: {
+  id: string
+  label: string
+  value: string
+  onChange: (value: string) => void
+  rows: number
+  col?: string
+}) {
+  return (
+    <div className={col}>
+      <label htmlFor={id} className="customer-edit-label">{label}</label>
+      <textarea id={id} className="form-control customer-edit-control" rows={rows} value={value} onChange={(event) => onChange(event.target.value)} />
+    </div>
+  )
 }

+ 164 - 0
frontend/src/pages/dashboard/__tests__/StylistProfileManagePage.test.tsx

@@ -0,0 +1,164 @@
+import { HelmetProvider } from 'react-helmet-async'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { StylistProfileManagePage } from '../StylistProfileManagePage'
+
+const { getStylistMe, updateStylistMe, uploadMedia, navigate } = vi.hoisted(() => ({
+  getStylistMe: vi.fn(),
+  updateStylistMe: vi.fn(),
+  uploadMedia: vi.fn(),
+  navigate: vi.fn(),
+}))
+
+vi.mock('../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../services/api/stylist', () => ({
+  getStylistMe,
+  updateStylistMe,
+}))
+
+vi.mock('../../../services/api/media', () => ({
+  uploadMedia,
+}))
+
+vi.mock('../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate,
+    path: (value = '') => `/zh${value}`,
+    copy: {
+      common: { save: '保存', cancel: '取消', sending: '发送中...' },
+      profileManagePage: {
+        title: '个人主页管理',
+        badge: '个人主页',
+        lead: '每个美发师账号都有一个公开个人主页。',
+        back: '返回工作台',
+        viewPublic: '查看公开主页',
+        viewOwn: '查看自己的主页',
+        copyCode: '复制顾客模拟代码',
+        errors: { loadFailed: '无法加载个人主页信息', saveFailed: '保存失败' },
+        preview: {
+          badge: '公开预览',
+          photoAlt: '{{name}} 的主页照片',
+          published: '公开中',
+          experience: '经验',
+          rating: '评分',
+          reviews: '评价',
+          yearUnit: '年',
+          untitled: '未命名发型师',
+          emptyLead: '补充短介绍',
+        },
+        summary: {
+          publicUrl: '公开 URL',
+          inviteCode: '顾客模拟代码',
+          booking: '预约入口',
+          configured: '已设置',
+          notConfigured: '未设置',
+          templates: '公开模板',
+          templateCount: '{{count}} 件',
+        },
+        form: {
+          badge: '编辑信息',
+          title: '公开资料',
+          saved: '个人主页信息已保存。',
+          photoPreview: '主页照片预览',
+          changePhoto: '更换主页照片',
+          fields: {
+            photo: '主页照片',
+            displayName: '显示姓名',
+            slug: '公开主页路径',
+            years: '经验年数',
+            rating: '评分',
+            reviewCount: '评价数',
+            lead: '短介绍',
+            bio: '详细介绍',
+            bookingUrl: '预约链接',
+            address: '地址',
+            hours: '营业时间',
+            specialties: '擅长提案',
+          },
+          sections: {
+            hero: { title: '主页首屏', lead: '顾客打开公开页时最先看到的照片。' },
+            booking: { title: '预约按钮', lead: '公开页预约入口。' },
+            stats: { title: '首屏数字', lead: '可信度信息。' },
+            salon: { title: '沙龙信息', lead: '沙龙信息与擅长提案。' },
+          },
+          slugHint: '顾客会通过 /{{locale}}/profile/路径 访问你的公开主页。',
+          bookingHint: '按钮文字由系统按当前语言自动生成。',
+          yearsPlaceholder: '请选择',
+          yearsHint: '公开页会按语言自动显示单位「{{unit}}」。',
+          submitTitle: '保存个人主页信息',
+          submitLead: '保存后公开主页会更新。',
+        },
+      },
+    },
+  }),
+  usePreviewUrl: (_file: File | null, fallback: string) => fallback,
+}))
+
+const profile = {
+  id: '7',
+  username: 'Yasuaki',
+  email: 'yasuaki@example.com',
+  displayName: 'YASUAKI OGAWA',
+  slug: 'yasuaki-ogawa',
+  photoUrl: '/images/stylist-profile/demo-stylist-ai.png',
+  lead: '短介绍',
+  bio: '详细介绍',
+  bookingUrl: 'https://example.com/booking',
+  experienceYears: 22,
+  rating: '5.0',
+  reviewCount: 124,
+  specialties: '层次',
+  address: '东京',
+  hours: '预约制',
+  inviteCode: 'ABCD1234',
+  publishedTemplateCount: 3,
+  templates: [],
+}
+
+describe('StylistProfileManagePage', () => {
+  beforeEach(() => {
+    getStylistMe.mockReset()
+    updateStylistMe.mockReset()
+    getStylistMe.mockResolvedValue(profile)
+    updateStylistMe.mockResolvedValue(profile)
+  })
+
+  it('loads profile data and saves without a booking-button label field', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/profile/manage']}>
+          <Routes>
+            <Route path="/:locale/profile/manage" element={<StylistProfileManagePage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByDisplayValue('YASUAKI OGAWA')).toBeInTheDocument()
+    })
+
+    expect(screen.getByLabelText('经验年数')).toHaveDisplayValue('22')
+    expect(screen.getByText('按钮文字由系统按当前语言自动生成。')).toBeInTheDocument()
+    expect(screen.queryByLabelText('沙龙名')).not.toBeInTheDocument()
+
+    fireEvent.change(screen.getByLabelText('短介绍'), { target: { value: '新的短介绍' } })
+    fireEvent.click(screen.getAllByRole('button', { name: '保存' })[0])
+
+    await waitFor(() => {
+      expect(updateStylistMe).toHaveBeenCalledWith(expect.objectContaining({
+        displayName: 'YASUAKI OGAWA',
+        slug: 'yasuaki-ogawa',
+        lead: '新的短介绍',
+        experienceYears: 22,
+        bookingUrl: 'https://example.com/booking',
+      }))
+    })
+  })
+})

+ 57 - 8
frontend/src/services/api/stylist.ts

@@ -1,14 +1,63 @@
 import { http } from '../http/client'
+import type { TemplateNameI18n } from '../../shared/demoContent'
 
-export type StylistMe = {
-  id: number
-  name: string
-  displayName: string
+export type StylistProfile = {
+  id: string
+  username: string
   email: string
-  publicProfileSlug: string
-  profilePhoto: string | null
+  displayName: string
+  slug: string
+  photoUrl: string
+  lead: string
+  bio: string
+  bookingUrl: string
+  experienceYears: number | null
+  rating: string
+  reviewCount: number
+  specialties: string
+  address: string
+  hours: string
+  inviteCode: string
+  publishedTemplateCount: number
+  templates: StylistPublicTemplate[]
+}
+
+export type StylistPublicTemplate = {
+  id: string
+  templateName: string
+  templateNameI18n?: TemplateNameI18n | null
+  coverUrl: string
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  description: string
+}
+
+export type PublicStylistProfile = Omit<StylistProfile, 'email' | 'username'> & {
+  templates: StylistPublicTemplate[]
+}
+
+export type UpdateStylistProfileInput = {
+  displayName: string
+  slug: string
+  photoUrl?: string
+  lead?: string
+  bio?: string
+  bookingUrl?: string
+  experienceYears?: number | null
+  rating?: string
+  reviewCount?: number
+  specialties?: string
+  address?: string
+  hours?: string
+}
+
+export async function getStylistMe(): Promise<StylistProfile> {
+  return http.get<StylistProfile>('/stylist/me')
+}
+
+export async function updateStylistMe(input: UpdateStylistProfileInput): Promise<StylistProfile> {
+  return http.patch<StylistProfile>('/stylist/me', input)
 }
 
-export async function getStylistMe(): Promise<StylistMe> {
-  return http.get<StylistMe>('/stylist/me')
+export async function getPublicStylistProfile(slug: string): Promise<PublicStylistProfile> {
+  return http.get<PublicStylistProfile>(`/stylist/public/${encodeURIComponent(slug)}`)
 }