Explorar el Código

feat: add English locale and refine template UI

chenyu hace 2 meses
padre
commit
ab51ced2cf

+ 24 - 5
frontend/public/css/aitori.css

@@ -2346,17 +2346,24 @@ textarea.customer-edit-control {
 .template-task-edit-section .customer-edit-actions {
     background-color: #f7f7f8;
 }
+.template-delete-modal {
+    position: fixed;
+    z-index: 10020;
+    inset: 0;
+    display: grid;
+    place-items: center;
+    padding: 24px;
+    background-color: rgba(31, 36, 46, 0.42);
+}
 .template-delete-dialog {
     width: min(460px, calc(100vw - 32px));
+    margin: 0;
     padding: 28px;
     border: 0;
     border-radius: 8px;
     background-color: #fff;
     box-shadow: 0 24px 80px rgba(44, 46, 60, 0.22);
 }
-.template-delete-dialog::backdrop {
-    background-color: rgba(31, 36, 46, 0.42);
-}
 .template-delete-dialog .btn-base-color {
     background-color: #b95d52;
     border-color: #b95d52;
@@ -2873,12 +2880,24 @@ footer ul li {
     letter-spacing: 0;
     line-height: 1.02;
 }
-.aitori-invite-heading h1 span {
+.aitori-invite-heading h1 .aitori-invite-headline-main {
+  display: block;
+  color: var(--base-color);
+}
+.aitori-invite-heading h1 .aitori-invite-headline-accent {
     display: block;
     margin-top: 8px;
     color: var(--base-color);
     white-space: nowrap;
 }
+.aitori-invite-page--en .aitori-invite-heading h1 {
+    max-width: 1200px;
+    font-size: clamp(44px, 4.4vw, 72px);
+    line-height: 1.08;
+}
+.aitori-invite-page--en .aitori-invite-heading h1 .aitori-invite-headline-accent {
+    white-space: normal;
+}
 .aitori-invite-side {
     max-width: 430px;
     justify-self: end;
@@ -3101,7 +3120,7 @@ footer ul li {
         font-size: clamp(42px, 14vw, 64px);
         line-height: 1.08;
     }
-    .aitori-invite-heading h1 span {
+    .aitori-invite-heading h1 .aitori-invite-headline-accent {
         white-space: normal;
     }
     .aitori-invite-actions {

+ 6 - 4
frontend/src/components/common/LocaleSwitcher.tsx

@@ -4,12 +4,14 @@ import type { Locale } from '../../shared/pageContext'
 
 export function LocaleSwitcher({ locale }: { locale: Locale }) {
   const location = useLocation()
-  const otherLocale = locale === 'ja' ? 'zh' : 'ja'
-  const targetPath = location.pathname.replace(/^\/(zh|ja)/, `/${otherLocale}`)
+  const locales: Locale[] = ['zh', 'ja', 'en']
+  const nextLocale = locales[(locales.indexOf(locale) + 1) % locales.length]
+  const localeLabel: Record<Locale, string> = { zh: '中文', ja: '日本語', en: 'English' }
+  const targetPath = location.pathname.replace(/^\/(zh|ja|en)/, `/${nextLocale}`)
 
   return (
-    <Link className="locale-switcher" to={targetPath || `/${otherLocale}`}>
-      {otherLocale === 'ja' ? '日本語' : '中文'}
+    <Link className="locale-switcher" to={targetPath || `/${nextLocale}`}>
+      {localeLabel[nextLocale]}
     </Link>
   )
 }

+ 19 - 1
frontend/src/i18n/index.ts

@@ -3,6 +3,21 @@ import LanguageDetector from 'i18next-browser-languagedetector'
 import { initReactI18next } from 'react-i18next'
 import zhTranslation from './locales/zh/translation.json'
 import jaTranslation from './locales/ja/translation.json'
+import enTranslation from './locales/en/translation.json'
+
+function mergeTranslation(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
+  const merged: Record<string, unknown> = { ...base }
+
+  for (const [key, value] of Object.entries(override)) {
+    const baseValue = merged[key]
+    merged[key] = value && baseValue && typeof value === 'object' && !Array.isArray(value)
+      && typeof baseValue === 'object' && !Array.isArray(baseValue)
+      ? mergeTranslation(baseValue as Record<string, unknown>, value as Record<string, unknown>)
+      : value
+  }
+
+  return merged
+}
 
 const resources = {
   zh: {
@@ -11,6 +26,9 @@ const resources = {
   ja: {
     translation: jaTranslation,
   },
+  en: {
+    translation: mergeTranslation(zhTranslation, enTranslation),
+  },
 }
 
 i18n
@@ -19,7 +37,7 @@ i18n
   .init({
     resources,
     fallbackLng: 'zh',
-    supportedLngs: ['zh', 'ja'],
+    supportedLngs: ['zh', 'ja', 'en'],
     defaultNS: 'translation',
     interpolation: {
       escapeValue: false,

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 44 - 0
frontend/src/i18n/locales/en/translation.json


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

@@ -9,12 +9,12 @@ import {
   formValuesToPayload,
   type CustomerFormValues,
 } from '../../../shared/customerForm'
-import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
+import { type Locale, usePageContext, usePreviewUrl } from '../../../shared/pageContext'
 import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
-function getSubmitErrorMessage(error: unknown, locale: 'zh' | 'ja') {
-  const fallback = locale === 'ja' ? '保存に失敗しました' : '保存失败'
+function getSubmitErrorMessage(error: unknown, locale: Locale) {
+  const fallback = locale === 'ja' ? '保存に失敗しました' : locale === 'en' ? 'Unable to save changes' : '保存失败'
 
   if (!(error instanceof Error)) {
     return fallback
@@ -23,7 +23,7 @@ function getSubmitErrorMessage(error: unknown, locale: 'zh' | 'ja') {
   if (error.message === 'Internal server error') {
     return locale === 'ja'
       ? '保存に失敗しました。しばらくしてから再試行してください'
-      : '保存失败,请稍后重试'
+      : locale === 'en' ? 'Unable to save changes. Please try again shortly.' : '保存失败,请稍后重试'
   }
 
   return error.message || fallback

+ 0 - 18
frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx

@@ -22,8 +22,6 @@ const FALLBACK_PAGE_COPY = {
     badge: '预览',
     title: '模板预览',
     imageAlt: '模板封面',
-    downloadBundle: '下载模板包',
-    bundleUnavailable: '模板包尚未生成',
   },
   info: {
     badge: '模板信息',
@@ -212,22 +210,6 @@ export function TemplateDetailPage() {
                     alt={pageCopy.preview.imageAlt}
                   />
                 </div>
-                <div className="d-flex flex-wrap gap-2 mt-20px">
-                  {template.templateUrl ? (
-                    <a
-                      href={template.templateUrl}
-                      target="_blank"
-                      rel="noreferrer"
-                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
-                    >
-                      {pageCopy.preview.downloadBundle}
-                    </a>
-                  ) : (
-                    <span className="fs-14 text-medium-gray">
-                      {pageCopy.preview.bundleUnavailable}
-                    </span>
-                  )}
-                </div>
               </div>
             </div>
 

+ 0 - 16
frontend/src/pages/dashboard/templates/TemplateEditPage.tsx

@@ -262,22 +262,6 @@ export function TemplateEditPage() {
                     alt={pageCopy.preview.imageAlt}
                   />
                 </div>
-                <div className="d-flex flex-wrap gap-2 mt-20px">
-                  {template.templateUrl ? (
-                    <a
-                      href={template.templateUrl}
-                      target="_blank"
-                      rel="noreferrer"
-                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
-                    >
-                      {pageCopy.preview.downloadBundle}
-                    </a>
-                  ) : (
-                    <span className="fs-14 text-medium-gray">
-                      {pageCopy.preview.bundleUnavailable}
-                    </span>
-                  )}
-                </div>
               </div>
             </div>
 

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

@@ -10,7 +10,7 @@ import {
   type TemplateBasicInfo,
   type TemplateMakeTaskDetail,
 } from '../../../services/api/templates'
-import { usePageContext } from '../../../shared/pageContext'
+import { type Locale, usePageContext } from '../../../shared/pageContext'
 
 const FALLBACK_TASK_COPY = {
   title: '模板制作任务', description: '查看模板制作与编辑状态。', badge: '模板任务', pendingName: '正在创建模板...', resourceFallback: '暂无预览',
@@ -33,10 +33,11 @@ const FALLBACK_EDIT_COPY = {
   badge: '模板编辑', info: { title: '发型模板基本信息', name: '模板名称', status: '状态', category: '分类', tags: '标签', tagsPlaceholder: '层次, 空气感', description: '说明' }, preview: { imageAlt: '模板封面预览', downloadBundle: '下载模板包' }, success: { saveSucceeded: '保存成功' }, errors: { saveFailed: '保存失败' }, submitTitle: '保存模板信息', submitLead: '保存模板名称、分类、状态、标签和说明。', statusOptions: { DRAFT: '草稿', PUBLIC: '公开中' }, categoryOptions: { LONG: '长发', MEDIUM: '中长发', SHORT: '短发', DYE: '染发' },
 } as const
 
-function formatTaskTime(locale: 'zh' | 'ja', value?: string) {
+function formatTaskTime(locale: Locale, value?: string) {
   if (!value) return '--'
   const date = new Date(value)
-  return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
+  const language = locale === 'ja' ? 'ja-JP' : locale === 'en' ? 'en-US' : 'zh-CN'
+  return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(language, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
 }
 
 function taskTone(status?: string) {
@@ -188,7 +189,7 @@ export function TemplateMakeTaskPage() {
           {complete && template ? <form id="template-edit-section" className="template-task-edit-section workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4" onSubmit={handleSave}>
             <div className="template-task-edit-head mb-25px"><div><span className="fs-14 fw-700 text-base-color text-uppercase">{editCopy.badge}</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">{editCopy.info.title}</h2></div><span className="template-status-badge">{editCopy.statusOptions[status]}</span></div>
             <div className="template-task-edit-layout">
-              <div><img className="template-task-cover-preview mt-0" src={template.coverUrl || task.step2.resultImage || task.refHairUrl} alt={editCopy.preview.imageAlt} />{template.templateUrl ? <a href={template.templateUrl} target="_blank" rel="noreferrer" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 w-100 mt-15px">{editCopy.preview.downloadBundle}</a> : null}</div>
+              <div><img className="template-task-cover-preview mt-0" src={template.coverUrl || task.step2.resultImage || task.refHairUrl} alt={editCopy.preview.imageAlt} /></div>
               <div className="row g-3">
                 <div className="col-md-7"><label htmlFor="task-template-name" className="customer-edit-label">{editCopy.info.name}</label><input id="task-template-name" className="form-control customer-edit-control" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required /></div>
                 <div className="col-md-5"><label htmlFor="task-template-status" className="customer-edit-label">{editCopy.info.status}</label><select id="task-template-status" className="form-select customer-edit-control" value={status} onChange={(event) => setStatus(event.target.value as TemplateBasicInfo['status'])}><option value="DRAFT">{editCopy.statusOptions.DRAFT}</option><option value="PUBLIC">{editCopy.statusOptions.PUBLIC}</option></select></div>
@@ -202,7 +203,7 @@ export function TemplateMakeTaskPage() {
         </div>
       </section>
 
-      {deleteOpen ? <dialog className="template-delete-dialog" open aria-modal="true"><h2 className="fw-700 text-dark-gray fs-24">{pageCopy.delete?.title || '确认删除模板?'}</h2><p>{pageCopy.delete?.body || '删除后模板和生成任务将不再显示,且无法恢复。'}</p><div className="d-flex justify-content-end gap-2"><button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border" onClick={() => setDeleteOpen(false)}>{commonCopy.cancel}</button><button type="button" className="btn btn-small btn-rounded btn-base-color" onClick={() => void handleDelete()}>{pageCopy.delete?.confirm || '确认删除'}</button></div></dialog> : null}
+      {deleteOpen ? <div className="template-delete-modal" role="presentation"><section className="template-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="template-delete-title"><h2 id="template-delete-title" className="fw-700 text-dark-gray fs-24">{pageCopy.delete?.title || '确认删除模板?'}</h2><p>{pageCopy.delete?.body || '删除后模板和生成任务将不再显示,且无法恢复。'}</p><div className="d-flex justify-content-end gap-2"><button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border" onClick={() => setDeleteOpen(false)}>{commonCopy.cancel}</button><button type="button" className="btn btn-small btn-rounded btn-base-color" onClick={() => void handleDelete()}>{pageCopy.delete?.confirm || '确认删除'}</button></div></section></div> : null}
     </DashboardLayout>
   )
 }

+ 1 - 4
frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx

@@ -117,10 +117,7 @@ describe('TemplateEditPage', () => {
     )
 
     expect(await screen.findByDisplayValue('柔和层次长发')).toBeInTheDocument()
-    expect(screen.getByRole('link', { name: '下载模板包' })).toHaveAttribute(
-      'href',
-      'https://cdn.example.com/template.zip',
-    )
+    expect(screen.queryByRole('link', { name: '下载模板包' })).not.toBeInTheDocument()
     expect(screen.getByRole('link', { name: '返回上一页' })).toHaveAttribute(
       'href',
       '/zh/templates/tasks/88',

+ 3 - 3
frontend/src/pages/public/LandingPage.tsx

@@ -49,7 +49,7 @@ export function LandingPage() {
       </Helmet>
       <main
         ref={pageRef}
-        className={`aitori-invite-page${menuOpen ? ' is-menu-open' : ''}`}
+        className={`aitori-invite-page aitori-invite-page--${locale}${menuOpen ? ' is-menu-open' : ''}`}
         onPointerMove={(event) => updateVideoShift(event.clientX, event.clientY)}
         onPointerLeave={resetVideoShift}
       >
@@ -57,7 +57,7 @@ export function LandingPage() {
           <source src="/images/aitori-hairstyle-three-style-loop-final-web.mp4" type="video/mp4" />
         </video>
         <div className="aitori-invite-shell">
-          <nav className="aitori-invite-nav" aria-label={locale === 'ja' ? 'メインナビゲーション' : '主导航'}>
+          <nav className="aitori-invite-nav" aria-label={locale === 'ja' ? 'メインナビゲーション' : locale === 'en' ? 'Main navigation' : '主导航'}>
             <Link className="aitori-invite-logo" to={path('')} aria-label="Aitori Stylist">
               <img src="/images/logo_color.svg" alt="Aitori Stylist" />
             </Link>
@@ -85,7 +85,7 @@ export function LandingPage() {
           <section className="aitori-invite-bottom" aria-labelledby="invite-heading">
             <div className="aitori-invite-heading">
               <span className="aitori-invite-kicker">{pageCopy.badge}</span>
-              <h1 id="invite-heading">{pageCopy.headline}<span>{pageCopy.headlineEmphasis}</span></h1>
+              <h1 id="invite-heading"><span className="aitori-invite-headline-main">{pageCopy.headline}</span><span className="aitori-invite-headline-accent">{pageCopy.headlineEmphasis}</span></h1>
             </div>
             <div className="aitori-invite-side">
               <p>{pageCopy.body}</p>

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

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
 import { useNavigate, useParams } from 'react-router-dom'
 import { useTranslation } from 'react-i18next'
 
-export type Locale = 'zh' | 'ja'
+export type Locale = 'zh' | 'ja' | 'en'
 
 export const DEFAULT_LOCALE: Locale = 'zh'
 export const TEMPLATE_LIMIT = 5
@@ -10,7 +10,7 @@ export const TEMPLATE_LIMIT = 5
 let legacyScriptsPromise: Promise<void> | undefined
 
 export function normalizeLocale(locale?: string): Locale {
-  return locale === 'ja' ? 'ja' : 'zh'
+  return locale === 'ja' || locale === 'en' ? locale : 'zh'
 }
 
 export function withLocale(locale: string, path = '') {
@@ -72,7 +72,7 @@ export function usePageContext() {
     if (i18n.language !== locale) {
       i18n.changeLanguage(locale)
     }
-    document.documentElement.lang = locale === 'ja' ? 'ja' : 'zh-CN'
+    document.documentElement.lang = locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'
   }, [i18n, locale])
 
   const copy = i18n.getResourceBundle(locale, 'translation')

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio