Procházet zdrojové kódy

feat: localize public template names from stored translations

Store zh/ja/en cover names on templates so simulation and list UIs follow the current locale, and make customer phone optional with Japanese as the default language.

Co-authored-by: Cursor <cursoragent@cursor.com>
chenyu před 1 měsícem
rodič
revize
945fdd9632
25 změnil soubory, kde provedl 170 přidání a 33 odebrání
  1. 31 0
      backend/prisma/migrations/20260915_add_template_name_i18n/migration.sql
  2. 1 0
      backend/prisma/schema.prisma
  3. 2 2
      backend/src/modules/customers/customers.service.ts
  4. 2 1
      backend/src/modules/customers/dto/customer-upsert.dto.ts
  5. 3 0
      backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts
  6. 3 0
      backend/src/modules/simulations/dto/simulation-detail.dto.ts
  7. 3 0
      backend/src/modules/simulations/dto/simulation-template-option.dto.ts
  8. 9 4
      backend/src/modules/simulations/simulations.service.ts
  9. 6 1
      backend/src/modules/templates/dto/create-template-make-task.dto.ts
  10. 3 0
      backend/src/modules/templates/dto/template-basic-info.dto.ts
  11. 6 1
      backend/src/modules/templates/dto/update-template-basic-info.dto.ts
  12. 13 0
      backend/src/modules/templates/template-name-i18n.ts
  13. 9 0
      backend/src/modules/templates/templates.service.ts
  14. 6 3
      frontend/src/pages/customer/CustomerSimulationPage.tsx
  15. 4 4
      frontend/src/pages/customer/CustomerSimulationResultPage.tsx
  16. 2 2
      frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
  17. 2 3
      frontend/src/pages/dashboard/customers/CustomerFormPage.tsx
  18. 22 0
      frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx
  19. 3 3
      frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
  20. 13 5
      frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx
  21. 6 2
      frontend/src/pages/dashboard/templates/TemplateListPage.tsx
  22. 8 0
      frontend/src/services/api/contracts.ts
  23. 1 1
      frontend/src/services/api/customers.ts
  24. 11 0
      frontend/src/shared/demoContent.ts
  25. 1 1
      frontend/src/shared/pageContext.tsx

+ 31 - 0
backend/prisma/migrations/20260915_add_template_name_i18n/migration.sql

@@ -0,0 +1,31 @@
+ALTER TABLE `hair_template`
+ADD COLUMN `template_name_i18n` JSON NULL AFTER `template_name`;
+
+UPDATE `hair_template`
+SET `template_name_i18n` = JSON_OBJECT('zh', `template_name`)
+WHERE `status` = 'PUBLIC'
+  AND `is_deleted` = 0
+  AND `template_name_i18n` IS NULL;
+
+UPDATE `hair_template`
+SET `template_name_i18n` = CASE `template_name`
+  WHEN '居家造型' THEN JSON_OBJECT(
+    'zh', '居家造型',
+    'ja', 'ホームスタイリング',
+    'en', 'Home styling'
+  )
+  WHEN '居家造型30天后造型' THEN JSON_OBJECT(
+    'zh', '居家造型30天后造型',
+    'ja', '30日後のホームスタイリング',
+    'en', 'Home styling after 30 days'
+  )
+  WHEN '居家造型60天后造型' THEN JSON_OBJECT(
+    'zh', '居家造型60天后造型',
+    'ja', '60日後のホームスタイリング',
+    'en', 'Home styling after 60 days'
+  )
+  ELSE `template_name_i18n`
+END
+WHERE `is_system_template` = 'YES'
+  AND `is_deleted` = 0
+  AND `template_name` IN ('居家造型', '居家造型30天后造型', '居家造型60天后造型');

+ 1 - 0
backend/prisma/schema.prisma

@@ -181,6 +181,7 @@ model AiHairSimulationTask {
 model HairTemplate {
   id               BigInt                 @id @default(autoincrement()) @db.UnsignedBigInt
   templateName     String                 @map("template_name") @db.VarChar(100)
+  templateNameI18n Json?                  @map("template_name_i18n")
   coverUrl         String                 @default("") @map("cover_url") @db.VarChar(500)
   status           HairTemplateStatus
   category         HairTemplateCategory

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

@@ -87,7 +87,7 @@ export class CustomersService {
         hairstylistId: stylistId,
         name: body.name.trim(),
         namePronunciation: body.namePronunciation?.trim() ?? '',
-        phone: body.phone.trim(),
+        phone: body.phone?.trim() ?? '',
         email: body.email?.trim() ?? '',
         birthday: parseDateOnly(body.birthday),
         avatar:
@@ -214,7 +214,7 @@ export class CustomersService {
       data: {
         name: body.name.trim(),
         namePronunciation: body.namePronunciation?.trim() ?? '',
-        phone: body.phone.trim(),
+        phone: body.phone?.trim() ?? '',
         email: body.email?.trim() ?? '',
         birthday: parseDateOnly(body.birthday),
         avatar:

+ 2 - 1
backend/src/modules/customers/dto/customer-upsert.dto.ts

@@ -8,8 +8,9 @@ export class CustomerUpsertDto {
   @IsString()
   namePronunciation?: string;
 
+  @IsOptional()
   @IsString()
-  phone!: string;
+  phone?: string;
 
   @IsOptional()
   @IsEmail()

+ 3 - 0
backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts

@@ -1,8 +1,11 @@
+import type { TemplateNameI18n } from '../../templates/template-name-i18n';
+
 export type CustomerSimulationGalleryItemDto = {
   simulationId: string;
   taskId: string;
   imageUrl: string;
   templateName: string;
+  templateNameI18n: TemplateNameI18n | null;
   createdAt: string;
   taskStatus: 'WAIT' | 'PROCESSING' | 'SUCCESSES' | 'FAILED';
 };

+ 3 - 0
backend/src/modules/simulations/dto/simulation-detail.dto.ts

@@ -1,7 +1,10 @@
+import type { TemplateNameI18n } from '../../templates/template-name-i18n';
+
 export type AiHairSimulationTaskDetailDto = {
   id: string;
   templateId: string;
   templateName: string;
+  templateNameI18n: TemplateNameI18n | null;
   templateCoverUrl: string;
   templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
   templateSource: 'STYLIST' | 'SYSTEM';

+ 3 - 0
backend/src/modules/simulations/dto/simulation-template-option.dto.ts

@@ -1,6 +1,9 @@
+import type { TemplateNameI18n } from '../../templates/template-name-i18n';
+
 export type SimulationTemplateOptionDto = {
   id: string;
   templateName: string;
+  templateNameI18n: TemplateNameI18n | null;
   coverUrl: string;
   category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
   tags: string[];

+ 9 - 4
backend/src/modules/simulations/simulations.service.ts

@@ -14,6 +14,7 @@ import { AiHairSimulationDetailDto } from './dto/simulation-detail.dto';
 import { SimulationTemplateOptionDto } from './dto/simulation-template-option.dto';
 import { CreatePublicSimulationDto } from './dto/public-simulation.dto';
 import { randomBytes } from 'node:crypto';
+import { normalizeTemplateNameI18n } from '../templates/template-name-i18n';
 
 @Injectable()
 export class SimulationsService {
@@ -178,6 +179,7 @@ export class SimulationsService {
     const mappedItems = items.map((item) => ({
         id: item.id.toString(),
         templateName: item.templateName,
+        templateNameI18n: normalizeTemplateNameI18n(item.templateNameI18n),
         coverUrl: this.resolveTemplateCoverUrl(item),
         category: item.category,
         tags: item.tags
@@ -482,6 +484,7 @@ export class SimulationsService {
           id: task.id.toString(),
           templateId: task.templateId,
           templateName: template?.templateName ?? 'Unknown Template',
+          templateNameI18n: normalizeTemplateNameI18n(template?.templateNameI18n),
           templateCoverUrl: template?.coverUrl ?? '',
           templateCategory: template?.category ?? 'LONG',
           templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
@@ -568,14 +571,13 @@ export class SimulationsService {
           select: {
             id: true,
             templateName: true,
+            templateNameI18n: true,
             coverUrl: true,
           },
         })
       : [];
 
-    const templateMap = new Map(
-      templates.map((item) => [item.id.toString(), item.templateName]),
-    );
+    const templateMap = new Map(templates.map((item) => [item.id.toString(), item]));
     const templateCoverMap = new Map(
       templates.map((item) => [item.id.toString(), item.coverUrl]),
     );
@@ -598,7 +600,10 @@ export class SimulationsService {
           simulationId: task.simulationId.toString(),
           taskId: task.taskId,
           imageUrl,
-          templateName: templateMap.get(task.templateId) ?? 'Unknown Template',
+          templateName: templateMap.get(task.templateId)?.templateName ?? 'Unknown Template',
+          templateNameI18n: normalizeTemplateNameI18n(
+            templateMap.get(task.templateId)?.templateNameI18n,
+          ),
           createdAt: simulation.createTime.toISOString(),
           taskStatus: task.taskStatus,
         }));

+ 6 - 1
backend/src/modules/templates/dto/create-template-make-task.dto.ts

@@ -1,9 +1,14 @@
-import { IsArray, IsIn, IsOptional, IsString, IsUrl } from 'class-validator';
+import { IsArray, IsIn, IsObject, IsOptional, IsString, IsUrl } from 'class-validator';
+import type { TemplateNameI18n } from '../template-name-i18n';
 
 export class CreateTemplateMakeTaskDto {
   @IsString()
   templateName!: string;
 
+  @IsOptional()
+  @IsObject()
+  templateNameI18n?: TemplateNameI18n;
+
   @IsIn(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
   category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
 

+ 3 - 0
backend/src/modules/templates/dto/template-basic-info.dto.ts

@@ -1,6 +1,9 @@
+import type { TemplateNameI18n } from '../template-name-i18n';
+
 export class TemplateBasicInfoDto {
   id!: string;
   templateName!: string;
+  templateNameI18n!: TemplateNameI18n | null;
   status!: 'DRAFT' | 'PUBLIC';
   category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
   tags!: string[];

+ 6 - 1
backend/src/modules/templates/dto/update-template-basic-info.dto.ts

@@ -1,9 +1,14 @@
-import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
+import { IsArray, IsIn, IsObject, IsOptional, IsString } from 'class-validator';
+import type { TemplateNameI18n } from '../template-name-i18n';
 
 export class UpdateTemplateBasicInfoDto {
   @IsString()
   templateName!: string;
 
+  @IsOptional()
+  @IsObject()
+  templateNameI18n?: TemplateNameI18n;
+
   @IsIn(['DRAFT', 'PUBLIC'])
   status!: 'DRAFT' | 'PUBLIC';
 

+ 13 - 0
backend/src/modules/templates/template-name-i18n.ts

@@ -0,0 +1,13 @@
+export type TemplateNameI18n = Partial<Record<'zh' | 'ja' | 'en', string>>;
+
+export function normalizeTemplateNameI18n(value: unknown): TemplateNameI18n | null {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+
+  const source = value as Record<string, unknown>;
+  const result: TemplateNameI18n = {};
+  for (const locale of ['zh', 'ja', 'en'] as const) {
+    const name = source[locale];
+    if (typeof name === 'string' && name.trim()) result[locale] = name.trim();
+  }
+  return Object.keys(result).length ? result : null;
+}

+ 9 - 0
backend/src/modules/templates/templates.service.ts

@@ -9,6 +9,7 @@ import { TemplateBasicInfoDto } from './dto/template-basic-info.dto';
 import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto';
 import { TemplateAiClient } from './template-ai.client';
 import { TemplateTaskRunner } from './template-task-runner';
+import { normalizeTemplateNameI18n, type TemplateNameI18n } from './template-name-i18n';
 
 type TemplateListQuery = ListTemplatesQueryDto & {
   stylistId: bigint;
@@ -72,6 +73,7 @@ export class TemplatesService {
   private toBasicInfoDto(template: {
     id: bigint;
     templateName: string;
+    templateNameI18n: unknown;
     status: 'DRAFT' | 'PUBLIC';
     category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
     tags: string;
@@ -82,6 +84,7 @@ export class TemplatesService {
     return {
       id: template.id.toString(),
       templateName: template.templateName,
+      templateNameI18n: normalizeTemplateNameI18n(template.templateNameI18n),
       status: template.status,
       category: template.category,
       tags: template.tags
@@ -100,6 +103,7 @@ export class TemplatesService {
     template: {
       id: bigint;
       templateName: string;
+      templateNameI18n: unknown;
       category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
       status: 'DRAFT' | 'PUBLIC';
       description: string | null;
@@ -114,6 +118,7 @@ export class TemplatesService {
     return {
       id: template.id.toString(),
       name: template.templateName,
+      nameI18n: normalizeTemplateNameI18n(template.templateNameI18n),
       category: template.category,
       status: template.status,
       description: template.description ?? '',
@@ -199,6 +204,7 @@ export class TemplatesService {
       const template = await tx.hairTemplate.create({
         data: {
           templateName: dto.templateName,
+          templateNameI18n: normalizeTemplateNameI18n(dto.templateNameI18n) ?? undefined,
           coverUrl: '',
           status: dto.status,
           category: dto.category,
@@ -288,6 +294,7 @@ export class TemplatesService {
       where: { id: template.id },
       data: {
         templateName: dto.templateName,
+        templateNameI18n: normalizeTemplateNameI18n(dto.templateNameI18n) ?? undefined,
         status: dto.status,
         category: dto.category,
         tags: (dto.tags ?? []).join(','),
@@ -487,6 +494,7 @@ export class TemplatesService {
       makeTaskId?: string;
       makeTaskStatus?: string;
       usedCount: number;
+      nameI18n: TemplateNameI18n | null;
     }>;
     total: number;
     page: number;
@@ -524,6 +532,7 @@ export class TemplatesService {
         select: {
           id: true,
           templateName: true,
+          templateNameI18n: true,
           category: true,
           status: true,
           description: true,

+ 6 - 3
frontend/src/pages/customer/CustomerSimulationPage.tsx

@@ -10,7 +10,7 @@ import {
   uploadMedia,
 } from '../../services/api/simulations'
 import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
-import { localizeSystemTemplateName } from '../../shared/demoContent'
+import { resolveLocalizedTemplateName } from '../../shared/demoContent'
 
 export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
 
@@ -50,10 +50,13 @@ export function CustomerSimulationPage() {
       setCode(normalizedCode)
       setCodeChecked(true)
       setStylistName(data.stylist.name)
-      setStylistTemplates(data.stylistTemplates)
+      setStylistTemplates(data.stylistTemplates.map((template) => ({
+        ...template,
+        templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale),
+      })))
       setSystemTemplates(data.systemTemplates.map((template) => ({
         ...template,
-        templateName: localizeSystemTemplateName(template.templateName, copy.systemTemplateNames),
+        templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale, copy.systemTemplateNames),
       })))
       setSource('stylist')
       setSelectedTemplates([])

+ 4 - 4
frontend/src/pages/customer/CustomerSimulationResultPage.tsx

@@ -7,7 +7,7 @@ import type { AiHairSimulationDetail } from '../../services/api/contracts'
 import { getPublicSimulationById } from '../../services/api/simulations'
 import { usePageContext } from '../../shared/pageContext'
 import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
-import { localizeSystemTemplateName } from '../../shared/demoContent'
+import { resolveLocalizedTemplateName } from '../../shared/demoContent'
 
 export function CustomerSimulationResultPage() {
   const { locale, path, copy } = usePageContext()
@@ -51,11 +51,11 @@ export function CustomerSimulationResultPage() {
       id: `${task.id}-${index}`,
       image,
       title: task.templateSource === 'SYSTEM'
-        ? localizeSystemTemplateName(task.templateName, copy.systemTemplateNames)
-        : task.templateName,
+        ? resolveLocalizedTemplateName(task.templateName, task.templateNameI18n, locale, copy.systemTemplateNames)
+        : resolveLocalizedTemplateName(task.templateName, task.templateNameI18n, locale),
       source: task.templateSource,
     })),
-  ) ?? [], [copy.systemTemplateNames, detail])
+  ) ?? [], [copy.systemTemplateNames, detail, locale])
 
   const createdAt = detail?.createdAt
     ? new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : locale === 'en' ? 'en-US' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(detail.createdAt))

+ 2 - 2
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -6,7 +6,7 @@ import type { CustomerSimulationGalleryItem } from '../../../services/api/contra
 import { listCustomerSimulationGallery } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
-import { localizeSystemTemplateName } from '../../../shared/demoContent'
+import { resolveLocalizedTemplateName } from '../../../shared/demoContent'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 function getDisplayValue(value?: string) {
@@ -160,7 +160,7 @@ export function CustomerDetailPage() {
                       <div className="customer-simulation-batch-heading"><div><span>{formatDisplayDateTime(batch.createdAt, locale)}</span><strong>{batch.title}</strong></div><Link to={path(`/simulation/result/${batch.id}`)}>{pageCopy.history.action}</Link></div>
                       <div className="customer-photo-grid customer-simulation-history">
                         {batch.items.map((item) => {
-                          const templateName = localizeSystemTemplateName(item.templateName, copy.systemTemplateNames)
+                          const templateName = resolveLocalizedTemplateName(item.templateName, item.templateNameI18n, locale, copy.systemTemplateNames)
                           return <Link key={`${item.taskId}-${item.imageUrl}`} to={path(`/simulation/result/${batch.id}`)} className="customer-photo-card customer-photo-card-link"><img src={item.imageUrl} alt={templateName} /><div><span>{taskStatusLabel(item.taskStatus)}</span><strong>{templateName}</strong></div></Link>
                         })}
                       </div>

+ 2 - 3
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -57,7 +57,7 @@ export function CustomerFormPage() {
 
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
-    if (!form.name || !form.phone || submitting) return
+    if (!form.name.trim() || submitting) return
     setError('')
     setSubmitting(true)
     try {
@@ -117,8 +117,7 @@ export function CustomerFormPage() {
                   <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.basic.title}</h2>
                   <div className="row g-3">
                     <div className="col-md-6"><label htmlFor="customer-name" className="customer-edit-label">{pageCopy.basic.name} <span className="text-base-color">*</span></label><input id="customer-name" type="text" className="form-control customer-edit-control" value={form.name} onChange={(event) => patch({ name: event.target.value })} placeholder={pageCopy.basic.namePlaceholder} required /></div>
-                    <div className="col-md-6"><label htmlFor="customer-kana" className="customer-edit-label">{pageCopy.basic.kana}</label><input id="customer-kana" type="text" className="form-control customer-edit-control" value={form.namePronunciation} onChange={(event) => patch({ namePronunciation: event.target.value })} placeholder={pageCopy.basic.kanaPlaceholder} /></div>
-                    <div className="col-md-6"><label htmlFor="customer-phone" className="customer-edit-label">{pageCopy.basic.phone} <span className="text-base-color">*</span></label><input id="customer-phone" type="tel" className="form-control customer-edit-control" value={form.phone} onChange={(event) => patch({ phone: event.target.value })} placeholder="090-0000-0000" required /></div>
+                    <div className="col-md-6"><label htmlFor="customer-phone" className="customer-edit-label">{pageCopy.basic.phone}</label><input id="customer-phone" type="tel" className="form-control customer-edit-control" value={form.phone} onChange={(event) => patch({ phone: event.target.value })} placeholder="090-0000-0000" /></div>
                     <div className="col-md-6"><label htmlFor="customer-email" className="customer-edit-label">{pageCopy.basic.email}</label><input id="customer-email" type="email" className="form-control customer-edit-control" value={form.email} onChange={(event) => patch({ email: event.target.value })} placeholder="name@example.com" /></div>
                     <div className="col-md-6"><label htmlFor="customer-birthday" className="customer-edit-label">{pageCopy.basic.birthday}</label><input id="customer-birthday" type="date" className="form-control customer-edit-control" value={form.birthday} onChange={(event) => patch({ birthday: event.target.value })} /></div>
                     <div className="col-md-6">

+ 22 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -137,6 +137,28 @@ vi.mock('../../../../shared/pageContext', () => ({
 }))
 
 describe('CustomerFormPage', () => {
+  it('creates a customer with only a name and no pronunciation field', async () => {
+    createCustomer.mockReset()
+    createCustomer.mockResolvedValue({ id: '1' })
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/new']}>
+          <Routes><Route path="/:locale/customers/new" element={<CustomerFormPage />} /></Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(screen.queryByLabelText('片假名')).not.toBeInTheDocument()
+    expect(screen.getByLabelText('电话号码')).not.toBeRequired()
+    fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } })
+    fireEvent.click(screen.getAllByRole('button', { name: /登记顾客/i })[0])
+
+    await waitFor(() => expect(createCustomer).toHaveBeenCalledWith(
+      expect.objectContaining({ name: 'Sato Misaki', phone: '' }),
+    ))
+    createCustomer.mockClear()
+  })
+
   it('submits the redesigned customer payload', async () => {
     uploadMedia.mockResolvedValue({ url: 'https://example.com/avatar.png' })
     createCustomer.mockResolvedValue({ id: '1' })

+ 3 - 3
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx

@@ -5,7 +5,7 @@ import type { AiHairSimulationDetail } from '../../../services/api/contracts'
 import { getSimulationById } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL, LOADING_ANIMATION_URL } from '../../../shared/uiAssets'
-import { demoSimulationResults, localizeSystemTemplateName } from '../../../shared/demoContent'
+import { demoSimulationResults, resolveLocalizedTemplateName } from '../../../shared/demoContent'
 import { ImageLightbox } from '../../../components/common/ImageLightbox'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
@@ -101,8 +101,8 @@ export function SimulationResultPage() {
             <div className="simulation-result-grid">{detail.tasks.map((task) => {
               const image = task.resultImages[0] || task.templateCoverUrl || '/images/aitori-simulation-result.png'
               const templateName = task.templateSource === 'SYSTEM'
-                ? localizeSystemTemplateName(task.templateName, copy.systemTemplateNames)
-                : task.templateName
+                ? resolveLocalizedTemplateName(task.templateName, task.templateNameI18n, locale, copy.systemTemplateNames)
+                : resolveLocalizedTemplateName(task.templateName, task.templateNameI18n, locale)
               return <article key={task.id} className="simulation-result-card"><button type="button" className="aitori-image-button" disabled={task.taskStatus === 'PROCESSING'} onClick={() => setPreview({ image, alt: templateName })}><img className="simulation-result-image" src={image} alt={templateName} /></button><div className="simulation-result-body"><h3>{templateName}</h3><span className="template-status-badge">{task.templateSource === 'SYSTEM' ? pageCopy.source.system : pageCopy.source.stylist}</span>{task.taskStatus === 'PROCESSING' ? <img src={LOADING_ANIMATION_URL} alt={pageCopy.card.loadingAlt || '生成中'} className="simulation-result-card-loading" /> : null}<p>{task.taskStatus === 'FAILED' ? task.errorMsg || pageCopy.card.failed : task.taskStatus === 'PROCESSING' ? pageCopy.card.processing : pageCopy.card.success}</p></div></article>
             })}</div>
           </div>

+ 13 - 5
frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx

@@ -16,7 +16,7 @@ import {
   usePreviewUrl,
 } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
-import { localizeSystemTemplateName } from '../../../shared/demoContent'
+import { resolveLocalizedTemplateName } from '../../../shared/demoContent'
 
 export function StylistSimulationPage() {
   const [searchParams] = useSearchParams()
@@ -57,21 +57,29 @@ export function StylistSimulationPage() {
       pageSize: 12,
     })
       .then((result) => {
+        const localizedItems = result.items.map((item) => ({
+          ...item,
+          templateName: resolveLocalizedTemplateName(
+            item.templateName,
+            item.templateNameI18n,
+            locale,
+            templateSource === 'SYSTEM' ? copy.systemTemplateNames : undefined,
+          ),
+        }))
         if (templateSource === 'SYSTEM') {
           const images = ['/images/mvp-simulation-result-10/after-30-days.jpg', '/images/mvp-simulation-result-10/home-styling.jpg']
-          setTemplates(result.items.slice(0, 2).map((item, index) => ({
+          setTemplates(localizedItems.slice(0, 2).map((item, index) => ({
             ...item,
             coverUrl: images[index],
-            templateName: localizeSystemTemplateName(item.templateName, copy.systemTemplateNames),
           })))
         } else {
-          setTemplates(result.items)
+          setTemplates(localizedItems)
         }
       })
       .catch(() => {
         setTemplates([])
       })
-  }, [copy.systemTemplateNames, templateSource])
+  }, [copy.systemTemplateNames, locale, templateSource])
 
   const toggleTemplate = (templateId: string) => {
     setSelectedTemplates((current) => {

+ 6 - 2
frontend/src/pages/dashboard/templates/TemplateListPage.tsx

@@ -11,6 +11,7 @@ import {
 } from '../../../services/api/templates'
 import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
+import { resolveLocalizedTemplateName } from '../../../shared/demoContent'
 
 const initialSummary: TemplateDashboardSummary = {
   total: 0,
@@ -60,7 +61,10 @@ export function TemplateListPage() {
         })
 
         if (cancelled) return
-        setItems(result.items)
+        setItems(result.items.map((item) => ({
+          ...item,
+          name: resolveLocalizedTemplateName(item.name, item.nameI18n, locale, copy.systemTemplateNames),
+        })))
         setTotal(result.total)
         setSelectedIds((current) => current.filter((id) => result.items.some((item) => item.id === id)))
       } catch {
@@ -80,7 +84,7 @@ export function TemplateListPage() {
     return () => {
       cancelled = true
     }
-  }, [category, page, pageSize, query, status])
+  }, [category, copy.systemTemplateNames, locale, page, pageSize, query, status])
 
   const toggleSelect = (templateId: string) => {
     setSelectedIds((current) =>

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

@@ -5,6 +5,7 @@ import type {
   MockTemplateUsageResult,
   MockWorkbench,
 } from '../../mocks/db'
+import type { TemplateNameI18n } from '../../shared/demoContent'
 
 export type CustomerEntity = {
   id: string
@@ -39,6 +40,7 @@ export type CustomerDetailEntity = CustomerEntity & {
 export type TemplateEntity = {
   id: string
   name: string
+  nameI18n?: TemplateNameI18n | null
   category: string
   purpose?: string
   status: string
@@ -82,6 +84,7 @@ export type PaginatedTemplateList = {
 export type TemplateBasicInfo = {
   id: string
   templateName: string
+  templateNameI18n?: TemplateNameI18n | null
   status: 'DRAFT' | 'PUBLIC'
   category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
   tags: string[]
@@ -92,6 +95,7 @@ export type TemplateBasicInfo = {
 
 export type UpdateTemplateBasicInfoInput = {
   templateName: string
+  templateNameI18n?: TemplateNameI18n
   status: 'DRAFT' | 'PUBLIC'
   category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
   tags?: string[]
@@ -100,6 +104,7 @@ export type UpdateTemplateBasicInfoInput = {
 
 export type CreateTemplateMakeTaskInput = {
   templateName: string
+  templateNameI18n?: TemplateNameI18n
   category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
   status: 'DRAFT' | 'PUBLIC'
   description?: string
@@ -170,6 +175,7 @@ export type SimulationTemplateEntity = {
 export type SimulationTemplateOption = {
   id: string
   templateName: string
+  templateNameI18n?: TemplateNameI18n | null
   coverUrl: string
   category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
   tags: string[]
@@ -181,6 +187,7 @@ export type AiHairSimulationTaskDetail = {
   id: string
   templateId: string
   templateName: string
+  templateNameI18n?: TemplateNameI18n | null
   templateCoverUrl: string
   templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
   templateSource: 'STYLIST' | 'SYSTEM'
@@ -215,6 +222,7 @@ export type CustomerSimulationGalleryItem = {
   taskId: string
   imageUrl: string
   templateName: string
+  templateNameI18n?: TemplateNameI18n | null
   createdAt: string
   taskStatus: 'WAIT' | 'PROCESSING' | 'SUCCESSES' | 'FAILED'
 }

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

@@ -15,7 +15,7 @@ export type {
 export type CustomerUpsertInput = {
   name: string
   namePronunciation?: string
-  phone: string
+  phone?: string
   email?: string
   birthday?: string
   avatar?: string

+ 11 - 0
frontend/src/shared/demoContent.ts

@@ -52,6 +52,8 @@ export type SystemTemplateNames = {
   after60Days: string
 }
 
+export type TemplateNameI18n = Partial<Record<'zh' | 'ja' | 'en', string>>
+
 export function localizeSystemTemplateName(templateName: string, names?: SystemTemplateNames) {
   if (!names) return templateName
   const normalized = templateName.toLowerCase()
@@ -60,3 +62,12 @@ export function localizeSystemTemplateName(templateName: string, names?: SystemT
   if (/居家|ホーム|home/.test(normalized)) return names.homeStyling
   return templateName
 }
+
+export function resolveLocalizedTemplateName(
+  templateName: string,
+  templateNameI18n: TemplateNameI18n | null | undefined,
+  locale: 'zh' | 'ja' | 'en',
+  systemNames?: SystemTemplateNames,
+) {
+  return templateNameI18n?.[locale]?.trim() || localizeSystemTemplateName(templateName, systemNames)
+}

+ 1 - 1
frontend/src/shared/pageContext.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
 
 export type Locale = 'zh' | 'ja' | 'en'
 
-export const DEFAULT_LOCALE: Locale = 'zh'
+export const DEFAULT_LOCALE: Locale = 'ja'
 export const TEMPLATE_LIMIT = 5
 
 let legacyScriptsPromise: Promise<void> | undefined