diff --git a/src/app.config.ts b/src/app.config.ts index 588cbe9..8103ce5 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,9 +1,11 @@ +// 注意:由于小程序的限制,tabBar文本和页面标题需要在运行时动态设置 +// 这里保留中文作为默认值,实际的国际化会在运行时处理 export default defineAppConfig({ pages: [ 'pages/home/index', // 新的模板卡片首页 'pages/history/index', // 历史记录页面 - 'pages/result/index', 'pages/friends-photo/index', // 好友合照页面 + 'pages/result/index', ], tabBar: { color: '#8E9BAE', @@ -13,13 +15,13 @@ export default defineAppConfig({ list: [ { pagePath: 'pages/home/index', - text: '游乐场', + text: '游乐场', // 运行时会被替换为对应语言 iconPath: './assets/icons/playground.png', selectedIconPath: './assets/icons/playground-selected.png', }, { pagePath: 'pages/history/index', - text: '我的', + text: '我的', // 运行时会被替换为对应语言 iconPath: './assets/icons/user.png', selectedIconPath: './assets/icons/user-selected.png', }, @@ -28,12 +30,12 @@ export default defineAppConfig({ window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', - navigationBarTitleText: '图生视频', + navigationBarTitleText: '图生视频', // 运行时会被替换为对应语言 navigationBarTextStyle: 'black', }, permission: { 'scope.album': { - desc: '用于保存生成的图片和视频到相册', + desc: '用于保存生成的图片和视频到相册', // 运行时会被替换为对应语言 }, }, }); diff --git a/src/app.tsx b/src/app.tsx index 088394c..9859727 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -5,11 +5,21 @@ import configStore from './store' import './app.css' import { createPlatformFactory } from './platforms' +import { initLanguageFromStorage } from './i18n/utils' +import { i18nManager } from './i18n/manager' const store = configStore() function App({ children }: PropsWithChildren) { useLaunch(async () => { + // 初始化国际化 + try { + await initLanguageFromStorage() + await i18nManager.initializeApp() + } catch (error) { + console.error('i18n初始化失败:', error) + } + const authorize = createPlatformFactory().createAuthorize() const payment = createPlatformFactory().createPayment() try { diff --git a/src/assets/icons/photo.png b/src/assets/icons/photo.png new file mode 100644 index 0000000..9bf8b34 Binary files /dev/null and b/src/assets/icons/photo.png differ diff --git a/src/components/LanguageSwitcher/index.css b/src/components/LanguageSwitcher/index.css new file mode 100644 index 0000000..c080040 --- /dev/null +++ b/src/components/LanguageSwitcher/index.css @@ -0,0 +1,76 @@ +/* 语言切换组件样式 */ +.language-switcher { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + background-color: #f8f9fa; + border-radius: 20px; + border: 1px solid #e9ecef; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.language-switcher:hover { + background-color: #e9ecef; + border-color: #dee2e6; +} + +.language-switcher:active { + background-color: #dee2e6; + transform: scale(0.98); +} + +.language-switcher-content { + display: flex; + align-items: center; + gap: 6px; +} + +.language-icon { + font-size: 16px; +} + +.language-text { + font-size: 14px; + color: #495057; + font-weight: 500; +} + +.language-arrow { + font-size: 10px; + color: #6c757d; + transition: transform 0.2s ease; +} + +.language-switcher:active .language-arrow { + transform: rotate(180deg); +} + +/* 小尺寸变体 */ +.language-switcher.small { + padding: 6px 12px; +} + +.language-switcher.small .language-text { + font-size: 12px; +} + +.language-switcher.small .language-icon { + font-size: 14px; +} + +/* 紧凑变体 */ +.language-switcher.compact { + padding: 4px 8px; + border-radius: 12px; +} + +.language-switcher.compact .language-text { + display: none; +} + +.language-switcher.compact .language-arrow { + display: none; +} diff --git a/src/components/LanguageSwitcher/index.tsx b/src/components/LanguageSwitcher/index.tsx new file mode 100644 index 0000000..a18c8a7 --- /dev/null +++ b/src/components/LanguageSwitcher/index.tsx @@ -0,0 +1,89 @@ +/** + * 语言切换组件 + */ + +import { View, Text } from '@tarojs/components'; +import { useState } from 'react'; +import Taro from '@tarojs/taro'; +import { useI18n } from '../../hooks/useI18n'; +import { Language } from '../../i18n'; +import { i18nManager } from '../../i18n/manager'; +import './index.css'; + +interface LanguageSwitcherProps { + className?: string; +} + +const LanguageSwitcher: React.FC = ({ className = '' }) => { + const { currentLanguage, changeLanguage, supportedLanguages, languageNames, t } = useI18n(); + const [switching, setSwitching] = useState(false); + + const handleLanguageSwitch = async () => { + if (switching) return; + + try { + setSwitching(true); + + // 显示语言选择菜单 + const languageOptions = supportedLanguages.map(lang => languageNames[lang]); + const currentIndex = supportedLanguages.indexOf(currentLanguage); + + const result = await Taro.showActionSheet({ + itemList: languageOptions, + }); + + const selectedLanguage = supportedLanguages[result.tapIndex]; + + if (selectedLanguage && selectedLanguage !== currentLanguage) { + // 显示切换中的提示 + Taro.showLoading({ + title: t('common.loading'), + mask: true, + }); + + // 切换语言 + await changeLanguage(selectedLanguage); + + // 更新应用级别的i18n设置 + await i18nManager.onLanguageChange(); + + Taro.hideLoading(); + + // 显示成功提示 + Taro.showToast({ + title: t('common.operationSuccess'), + icon: 'success', + duration: 1500, + }); + } + } catch (error: any) { + Taro.hideLoading(); + + // 用户取消选择不显示错误 + if (error.errMsg && error.errMsg.includes('cancel')) { + return; + } + + console.error('Language switch failed:', error); + Taro.showToast({ + title: t('common.operationFailed'), + icon: 'error', + duration: 2000, + }); + } finally { + setSwitching(false); + } + }; + + return ( + + + 🌐 + {languageNames[currentLanguage]} + + + + ); +}; + +export default LanguageSwitcher; diff --git a/src/components/LoadingOverlay/index.tsx b/src/components/LoadingOverlay/index.tsx index 91ec81c..8696f06 100644 --- a/src/components/LoadingOverlay/index.tsx +++ b/src/components/LoadingOverlay/index.tsx @@ -1,4 +1,5 @@ import { View, Text } from '@tarojs/components'; +import { useI18n } from '../../hooks/useI18n'; import './index.css'; interface LoadingOverlayProps { @@ -6,13 +7,15 @@ interface LoadingOverlayProps { } const LoadingOverlay: React.FC = ({ children }) => { + const { t } = useI18n(); + return ( - AI正在生成中... - 请耐心等待,正在为您精心制作 + {t('loading.aiGenerating')} + {t('loading.pleaseWaitDesc')} {children} diff --git a/src/components/TemplateCard/index.tsx b/src/components/TemplateCard/index.tsx index f921966..8aa2df1 100644 --- a/src/components/TemplateCard/index.tsx +++ b/src/components/TemplateCard/index.tsx @@ -2,6 +2,7 @@ import { View, Text, Image, Video } from '@tarojs/components'; import { useState, useRef, useMemo } from 'react'; import Taro from '@tarojs/taro'; import { Template } from '../../store/types'; +import { useI18n } from '../../hooks/useI18n'; import './index.css'; interface TemplateCardProps { @@ -10,6 +11,7 @@ interface TemplateCardProps { } export default function TemplateCard({ template, onClick }: TemplateCardProps) { + const { t } = useI18n(); const [splitPosition, setSplitPosition] = useState(50); // 分割线位置百分比 const [isDragging, setIsDragging] = useState(false); const [containerInfo, setContainerInfo] = useState(null); @@ -104,7 +106,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) { ✨{template.name} - 内容由AI生成 + {t('templates.aiGeneratedContent')} ) : ( @@ -159,7 +161,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) { ✨{template.name} - 内容由AI生成 + {t('templates.aiGeneratedContent')} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d96ff5b..4b40f09 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,4 +2,5 @@ export { useAd } from './useAd' export { useSdk, useServerSdk } from './useSdk' -export { useUploadVideo } from './useUploadVideo' \ No newline at end of file +export { useUploadVideo } from './useUploadVideo' +export { useI18n } from './useI18n' diff --git a/src/hooks/useI18n.ts b/src/hooks/useI18n.ts new file mode 100644 index 0000000..47f65a5 --- /dev/null +++ b/src/hooks/useI18n.ts @@ -0,0 +1,55 @@ +/** + * i18n React Hook + */ + +import { useState, useCallback } from 'react'; +import { Language, languageNames } from '../i18n'; +import { + t, + formatNumber, + formatDate +} from '../i18n/utils'; + +export interface UseI18nReturn { + /** 当前语言 */ + currentLanguage: Language; + /** 翻译函数 */ + t: typeof t; + /** 切换语言 */ + changeLanguage: (language: Language) => Promise; + /** 支持的语言列表 */ + supportedLanguages: Language[]; + /** 语言显示名称 */ + languageNames: typeof languageNames; + /** 格式化数字 */ + formatNumber: typeof formatNumber; + /** 格式化日期 */ + formatDate: typeof formatDate; + /** 是否正在加载 */ + loading: boolean; +} + +/** + * i18n Hook - 固定为英语 + */ +export const useI18n = (): UseI18nReturn => { + const [currentLanguage] = useState('en-US'); + const [loading] = useState(false); // 不需要加载,直接设为false + + // 切换语言 - 空实现,因为只支持英语 + const changeLanguage = useCallback(async () => { + // 不执行任何操作,因为只支持英语 + console.log('Language switching is disabled, only English is supported'); + }, []); + + return { + currentLanguage, + t, + changeLanguage, + supportedLanguages: ['en-US'], // 只支持英语 + languageNames, + formatNumber, + formatDate, + loading, + }; +}; diff --git a/src/i18n/example.tsx b/src/i18n/example.tsx new file mode 100644 index 0000000..6e94b50 --- /dev/null +++ b/src/i18n/example.tsx @@ -0,0 +1,135 @@ +/** + * i18n使用示例 + * 这个文件展示了如何在不同场景下使用国际化功能 + */ + +import { View, Text, Button } from '@tarojs/components'; +import { useState } from 'react'; +import { useI18n } from '../hooks/useI18n'; +import LanguageSwitcher from '../components/LanguageSwitcher'; + +// 示例1: 基本使用 +function BasicExample() { + const { t } = useI18n(); + + return ( + + {t('common.confirm')} + {t('home.title')} + {t('navigation.playground')} + + ); +} + +// 示例2: 带参数的翻译 +function ParameterExample() { + const { t } = useI18n(); + const userName = '张三'; + const count = 42; + + return ( + + {/* 注意:需要在语言文件中定义支持参数的翻译键 */} + {t('welcome.message', { name: userName })} + {t('notification.count', { count: count.toString() })} + + ); +} + +// 示例3: 语言切换 +function LanguageSwitchExample() { + const { currentLanguage, changeLanguage, supportedLanguages, languageNames } = useI18n(); + + const handleLanguageChange = async (language: string) => { + try { + await changeLanguage(language as any); + } catch (error) { + console.error('Language change failed:', error); + } + }; + + return ( + + 当前语言: {languageNames[currentLanguage]} + + {/* 方式1: 使用内置的语言切换组件 */} + + + {/* 方式2: 自定义语言切换按钮 */} + {supportedLanguages.map(lang => ( + + ))} + + ); +} + +// 示例4: 数字和日期格式化 +function FormattingExample() { + const { formatNumber, formatDate } = useI18n(); + + return ( + + 数字: {formatNumber(1234.56)} + 日期: {formatDate(new Date())} + 自定义日期: {formatDate(new Date(), { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + + ); +} + +// 示例5: 条件渲染基于语言 +function ConditionalExample() { + const { currentLanguage, t } = useI18n(); + + return ( + + {t('common.loading')} + + {/* 根据语言显示不同内容 */} + {currentLanguage === 'zh-CN' && ( + 这是中文特有的内容 + )} + + {currentLanguage === 'en-US' && ( + This is English-specific content + )} + + ); +} + +// 示例6: 在类组件中使用(如果需要) +import { Component } from 'react'; +import { getCurrentLanguage, t } from '../i18n/utils'; + +class ClassComponentExample extends Component { + state = { + currentLanguage: getCurrentLanguage(), + }; + + render() { + return ( + + {t('common.confirm')} + 当前语言: {this.state.currentLanguage} + + ); + } +} + +export { + BasicExample, + ParameterExample, + LanguageSwitchExample, + FormattingExample, + ConditionalExample, + ClassComponentExample, +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..3cf7de3 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,122 @@ +/** + * 国际化配置文件 + * 支持多语言切换和动态加载 + */ + +export type Language = 'zh-CN' | 'en-US' | 'ja-JP' | 'ko-KR'; + +export interface I18nConfig { + defaultLanguage: Language; + fallbackLanguage: Language; + supportedLanguages: Language[]; + storageKey: string; +} + +export const i18nConfig: I18nConfig = { + defaultLanguage: 'en-US', + fallbackLanguage: 'en-US', + supportedLanguages: ['en-US'], + storageKey: 'app_language', +}; + +// 语言显示名称映射 +export const languageNames: Record = { + 'zh-CN': '简体中文', + 'en-US': 'English', + 'ja-JP': '日本語', + 'ko-KR': '한국어', +}; + +// 语言资源类型定义 +export interface LanguageResources { + common: { + confirm: string; + cancel: string; + ok: string; + loading: string; + retry: string; + success: string; + error: string; + warning: string; + tips: string; + pleaseWait: string; + networkError: string; + unknownError: string; + operationSuccess: string; + operationFailed: string; + }; + navigation: { + playground: string; + mine: string; + history: string; + home: string; + }; + home: { + title: string; + loadTemplatesFailed: string; + refreshSuccess: string; + refreshFailed: string; + imageAuditFailed: string; + imageAuditNotPassed: string; + imageContentNotCompliant: string; + imageNeedsManualReview: string; + imageAuditUncertain: string; + imageAuditPassed: string; + imageAuditFailedRetry: string; + waitForTaskCompletion: string; + userConfirmedAuditFailure: string; + }; + friendsPhoto: { + title: string; + usageTips: string; + usageTipsContent: string; + iKnow: string; + selectingImage: string; + uploading: string; + uploadSuccess: string; + uploadFailed: string; + needAlbumPermission: string; + uploadTimeout: string; + networkConnectionFailed: string; + uploadFailedRetry: string; + uploadTwoImages: string; + aiGenerating: string; + waitForTaskCompletion: string; + firstPhoto: string; + selectYourPhoto: string; + secondPhoto: string; + selectFriendPhoto: string; + clickToChange: string; + clickToUpload: string; + friendsPhotoGeneration: string; + uploadTwoPhotosDesc: string; + photoPreview: string; + confirmAndGenerate: string; + photo1: string; + photo2: string; + generating: string; + startGenerating: string; + }; + templates: { + aiGeneratedContent: string; + }; + loading: { + aiGenerating: string; + pleaseWaitDesc: string; + }; + audit: { + imageContentNotCompliant: string; + imageNeedsManualReview: string; + imageAuditUncertain: string; + imageAuditPassed: string; + imageAuditFailed: string; + imageAuditNotPassed: string; + iKnow: string; + }; + history: { + title: string; + noRecords: string; + loadFailed: string; + processingFailed: string; + }; +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts new file mode 100644 index 0000000..e14fea9 --- /dev/null +++ b/src/i18n/locales/en-US.ts @@ -0,0 +1,94 @@ +import { LanguageResources } from '../index'; + +export const enUS: LanguageResources = { + common: { + confirm: 'Confirm', + cancel: 'Cancel', + ok: 'OK', + loading: 'Loading...', + retry: 'Retry', + success: 'Success', + error: 'Error', + warning: 'Warning', + tips: 'Tips', + pleaseWait: 'Please wait...', + networkError: 'Network connection failed', + unknownError: 'Unknown error', + operationSuccess: 'Operation successful', + operationFailed: 'Operation failed', + }, + navigation: { + playground: 'Playground', + mine: 'Mine', + history: 'History', + home: 'Home', + }, + home: { + title: 'Image to Video', + loadTemplatesFailed: 'Failed to load templates', + refreshSuccess: 'Refresh successful', + refreshFailed: 'Refresh failed', + imageAuditFailed: 'Image audit failed, please retry', + imageAuditNotPassed: 'Image audit not passed', + imageContentNotCompliant: 'Image content does not comply with regulations, please select a compliant image', + imageNeedsManualReview: 'Image needs manual review, please try again later or select another image', + imageAuditUncertain: 'Image audit result is uncertain, please select another image', + imageAuditPassed: 'Image audit passed', + imageAuditFailedRetry: 'Image audit failed, please select another image', + waitForTaskCompletion: 'Please wait for the current task to complete before trying again', + userConfirmedAuditFailure: 'User confirmed audit failure information', + }, + friendsPhoto: { + title: 'Friends Photo', + usageTips: 'Usage Tips', + usageTipsContent: '• Clear face photos work better\n• Choose photos with good lighting\n• Supports JPG, PNG formats', + iKnow: 'I Know', + selectingImage: 'Selecting image...', + uploading: 'Uploading...', + uploadSuccess: 'Upload successful', + uploadFailed: 'Image upload failed', + needAlbumPermission: 'Need permission to access album', + uploadTimeout: 'Upload timeout, please retry', + networkConnectionFailed: 'Network connection failed', + uploadFailedRetry: 'Image upload failed, please retry', + uploadTwoImages: 'Please upload two images first', + aiGenerating: 'AI is generating...', + waitForTaskCompletion: 'Please wait for the current task to complete before trying again', + firstPhoto: 'First Photo', + selectYourPhoto: 'Select your photo', + secondPhoto: 'Second Photo', + selectFriendPhoto: 'Select friend\'s photo', + clickToChange: 'Click to change', + clickToUpload: 'Click to upload', + friendsPhotoGeneration: 'Friends Photo Generation', + uploadTwoPhotosDesc: 'Upload two photos, AI will generate an amazing photo video for you', + photoPreview: 'Photo Preview', + confirmAndGenerate: 'Confirm and start generation', + photo1: 'Photo 1', + photo2: 'Photo 2', + generating: 'Generating...', + startGenerating: 'Start Generating Photo', + }, + templates: { + aiGeneratedContent: 'Content generated by AI', + }, + loading: { + aiGenerating: 'AI is generating...', + pleaseWaitDesc: 'Please be patient, we are crafting something special for you', + }, + audit: { + imageContentNotCompliant: 'Image content does not comply with regulations, please select a compliant image', + imageNeedsManualReview: 'Image needs manual review, please try again later or select another image', + imageAuditUncertain: 'Image audit result is uncertain, please select another image', + imageAuditPassed: 'Image audit passed', + imageAuditFailed: 'Image audit failed, please select another image', + imageAuditNotPassed: 'Image audit not passed', + iKnow: 'I Know', + }, + history: { + title: 'My Works', + noRecords: 'No creation history, go create', + loadFailed: 'Failed to load records', + processingFailed: 'Processing failed', + }, +}; diff --git a/src/i18n/locales/index.ts b/src/i18n/locales/index.ts new file mode 100644 index 0000000..eedc29d --- /dev/null +++ b/src/i18n/locales/index.ts @@ -0,0 +1,18 @@ +/** + * 语言资源导出文件 + */ + +import { Language, LanguageResources } from '../index'; +import { zhCN } from './zh-CN'; +import { enUS } from './en-US'; +import { jaJP } from './ja-JP'; +import { koKR } from './ko-KR'; + +export const locales: Record = { + 'zh-CN': zhCN, + 'en-US': enUS, + 'ja-JP': jaJP, + 'ko-KR': koKR, +}; + +export { zhCN, enUS, jaJP, koKR }; diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts new file mode 100644 index 0000000..15315bb --- /dev/null +++ b/src/i18n/locales/ja-JP.ts @@ -0,0 +1,94 @@ +import { LanguageResources } from '../index'; + +export const jaJP: LanguageResources = { + common: { + confirm: '確認', + cancel: 'キャンセル', + ok: 'わかりました', + loading: '読み込み中...', + retry: '再試行', + success: '成功', + error: 'エラー', + warning: '警告', + tips: 'ヒント', + pleaseWait: 'しばらくお待ちください...', + networkError: 'ネットワーク接続に失敗しました', + unknownError: '不明なエラー', + operationSuccess: '操作が成功しました', + operationFailed: '操作に失敗しました', + }, + navigation: { + playground: 'プレイグラウンド', + mine: 'マイページ', + history: '履歴', + home: 'ホーム', + }, + home: { + title: '画像から動画', + loadTemplatesFailed: 'テンプレートの読み込みに失敗しました', + refreshSuccess: '更新が成功しました', + refreshFailed: '更新に失敗しました', + imageAuditFailed: '画像審査に失敗しました。再試行してください', + imageAuditNotPassed: '画像審査に通りませんでした', + imageContentNotCompliant: '画像内容が規範に適合していません。規範に適合した画像を再選択してください', + imageNeedsManualReview: '画像は人工審査が必要です。しばらく後に再試行するか、他の画像に変更してください', + imageAuditUncertain: '画像審査結果が不確定です。画像を再選択してください', + imageAuditPassed: '画像審査に通りました', + imageAuditFailedRetry: '画像審査に失敗しました。画像を再選択してください', + waitForTaskCompletion: '生成中のタスクが完了するまでお待ちください', + userConfirmedAuditFailure: 'ユーザーが審査失敗情報を確認しました', + }, + friendsPhoto: { + title: '友達の写真', + usageTips: '使用のヒント', + usageTipsContent: '• 鮮明な顔写真の方が効果的です\n• 明るい写真を選択することをお勧めします\n• JPG、PNG形式をサポートしています', + iKnow: 'わかりました', + selectingImage: '画像を選択中...', + uploading: 'アップロード中...', + uploadSuccess: 'アップロード成功', + uploadFailed: '画像のアップロードに失敗しました', + needAlbumPermission: 'アルバムへのアクセス許可が必要です', + uploadTimeout: 'アップロードがタイムアウトしました。再試行してください', + networkConnectionFailed: 'ネットワーク接続に失敗しました', + uploadFailedRetry: '画像のアップロードに失敗しました。再試行してください', + uploadTwoImages: 'まず2枚の画像をアップロードしてください', + aiGenerating: 'AIが生成中...', + waitForTaskCompletion: '生成中のタスクが完了するまでお待ちください', + firstPhoto: '1枚目の写真', + selectYourPhoto: 'あなたの写真を選択', + secondPhoto: '2枚目の写真', + selectFriendPhoto: '友達の写真を選択', + clickToChange: 'クリックして変更', + clickToUpload: 'クリックしてアップロード', + friendsPhotoGeneration: '友達の写真生成', + uploadTwoPhotosDesc: '2枚の写真をアップロードすると、AIが素晴らしい合成動画を生成します', + photoPreview: '写真プレビュー', + confirmAndGenerate: '確認後、生成を開始できます', + photo1: '写真 1', + photo2: '写真 2', + generating: '生成中...', + startGenerating: '写真生成を開始', + }, + templates: { + aiGeneratedContent: 'AI生成コンテンツ', + }, + loading: { + aiGenerating: 'AIが生成中...', + pleaseWaitDesc: 'お待ちください。特別なものを作成しています', + }, + audit: { + imageContentNotCompliant: '画像内容が規範に適合していません。規範に適合した画像を再選択してください', + imageNeedsManualReview: '画像は人工審査が必要です。しばらく後に再試行するか、他の画像に変更してください', + imageAuditUncertain: '画像審査結果が不確定です。画像を再選択してください', + imageAuditPassed: '画像審査に通りました', + imageAuditFailed: '画像審査に失敗しました。画像を再選択してください', + imageAuditNotPassed: '画像審査に通りませんでした', + iKnow: 'わかりました', + }, + history: { + title: '私の作品', + noRecords: '作成履歴がありません。作成しに行きましょう', + loadFailed: 'レコードの読み込みに失敗しました', + processingFailed: '処理に失敗しました', + }, +}; diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts new file mode 100644 index 0000000..4049801 --- /dev/null +++ b/src/i18n/locales/ko-KR.ts @@ -0,0 +1,94 @@ +import { LanguageResources } from '../index'; + +export const koKR: LanguageResources = { + common: { + confirm: '확인', + cancel: '취소', + ok: '알겠습니다', + loading: '로딩 중...', + retry: '재시도', + success: '성공', + error: '오류', + warning: '경고', + tips: '팁', + pleaseWait: '잠시만 기다려주세요...', + networkError: '네트워크 연결 실패', + unknownError: '알 수 없는 오류', + operationSuccess: '작업 성공', + operationFailed: '작업 실패', + }, + navigation: { + playground: '플레이그라운드', + mine: '내 정보', + history: '기록', + home: '홈', + }, + home: { + title: '이미지에서 비디오로', + loadTemplatesFailed: '템플릿 로드 실패', + refreshSuccess: '새로고침 성공', + refreshFailed: '새로고침 실패', + imageAuditFailed: '이미지 심사 실패, 다시 시도해주세요', + imageAuditNotPassed: '이미지 심사 통과하지 못함', + imageContentNotCompliant: '이미지 내용이 규정에 맞지 않습니다. 규정에 맞는 이미지를 다시 선택해주세요', + imageNeedsManualReview: '이미지는 수동 심사가 필요합니다. 나중에 다시 시도하거나 다른 이미지로 변경해주세요', + imageAuditUncertain: '이미지 심사 결과가 불확실합니다. 이미지를 다시 선택해주세요', + imageAuditPassed: '이미지 심사 통과', + imageAuditFailedRetry: '이미지 심사 실패, 이미지를 다시 선택해주세요', + waitForTaskCompletion: '생성 중인 작업이 완료될 때까지 기다려주세요', + userConfirmedAuditFailure: '사용자가 심사 실패 정보를 확인했습니다', + }, + friendsPhoto: { + title: '친구 사진', + usageTips: '사용 팁', + usageTipsContent: '• 선명한 얼굴 사진이 더 좋은 효과를 냅니다\n• 밝은 사진을 선택하는 것을 권장합니다\n• JPG, PNG 형식을 지원합니다', + iKnow: '알겠습니다', + selectingImage: '이미지 선택 중...', + uploading: '업로드 중...', + uploadSuccess: '업로드 성공', + uploadFailed: '이미지 업로드 실패', + needAlbumPermission: '앨범 접근 권한이 필요합니다', + uploadTimeout: '업로드 시간 초과, 다시 시도해주세요', + networkConnectionFailed: '네트워크 연결 실패', + uploadFailedRetry: '이미지 업로드 실패, 다시 시도해주세요', + uploadTwoImages: '먼저 두 장의 이미지를 업로드해주세요', + aiGenerating: 'AI가 생성 중...', + waitForTaskCompletion: '생성 중인 작업이 완료될 때까지 기다려주세요', + firstPhoto: '첫 번째 사진', + selectYourPhoto: '당신의 사진을 선택하세요', + secondPhoto: '두 번째 사진', + selectFriendPhoto: '친구의 사진을 선택하세요', + clickToChange: '클릭하여 변경', + clickToUpload: '클릭하여 업로드', + friendsPhotoGeneration: '친구 사진 생성', + uploadTwoPhotosDesc: '두 장의 사진을 업로드하면 AI가 멋진 합성 비디오를 생성합니다', + photoPreview: '사진 미리보기', + confirmAndGenerate: '확인 후 생성을 시작할 수 있습니다', + photo1: '사진 1', + photo2: '사진 2', + generating: '생성 중...', + startGenerating: '사진 생성 시작', + }, + templates: { + aiGeneratedContent: 'AI 생성 콘텐츠', + }, + loading: { + aiGenerating: 'AI가 생성 중...', + pleaseWaitDesc: '잠시만 기다려주세요. 특별한 것을 만들고 있습니다', + }, + audit: { + imageContentNotCompliant: '이미지 내용이 규정에 맞지 않습니다. 규정에 맞는 이미지를 다시 선택해주세요', + imageNeedsManualReview: '이미지는 수동 심사가 필요합니다. 나중에 다시 시도하거나 다른 이미지로 변경해주세요', + imageAuditUncertain: '이미지 심사 결과가 불확실합니다. 이미지를 다시 선택해주세요', + imageAuditPassed: '이미지 심사 통과', + imageAuditFailed: '이미지 심사 실패, 이미지를 다시 선택해주세요', + imageAuditNotPassed: '이미지 심사 통과하지 못함', + iKnow: '알겠습니다', + }, + history: { + title: '내 작품', + noRecords: '생성 기록이 없습니다. 만들어 보세요', + loadFailed: '레코드 로드 실패', + processingFailed: '처리 실패', + }, +}; diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..90de63f --- /dev/null +++ b/src/i18n/locales/zh-CN.ts @@ -0,0 +1,94 @@ +import { LanguageResources } from '../index'; + +export const zhCN: LanguageResources = { + common: { + confirm: '确定', + cancel: '取消', + ok: '我知道了', + loading: '加载中...', + retry: '重试', + success: '成功', + error: '错误', + warning: '警告', + tips: '提示', + pleaseWait: '请稍后...', + networkError: '网络连接失败', + unknownError: '未知错误', + operationSuccess: '操作成功', + operationFailed: '操作失败', + }, + navigation: { + playground: '游乐场', + mine: '我的', + history: '历史记录', + home: '首页', + }, + home: { + title: '图生视频', + loadTemplatesFailed: '加载模板失败', + refreshSuccess: '刷新成功', + refreshFailed: '刷新失败', + imageAuditFailed: '图片审核失败,请重试', + imageAuditNotPassed: '图片审核未通过', + imageContentNotCompliant: '图片内容不符合规范,请重新选择符合规范的图片', + imageNeedsManualReview: '图片需要人工审核,请稍后重试或更换其他图片', + imageAuditUncertain: '图片审核结果不确定,请重新选择图片', + imageAuditPassed: '图片审核通过', + imageAuditFailedRetry: '图片审核失败,请重新选择图片', + waitForTaskCompletion: '请等待生成中的任务完成后再尝试', + userConfirmedAuditFailure: '用户确认审核失败信息', + }, + friendsPhoto: { + title: '好友合照', + usageTips: '使用小贴士', + usageTipsContent: '• 选择清晰的人脸照片效果更佳\n• 建议选择光线充足的照片\n• 支持JPG、PNG格式', + iKnow: '我知道了', + selectingImage: '选择图片中...', + uploading: '正在上传...', + uploadSuccess: '上传成功', + uploadFailed: '图片上传失败', + needAlbumPermission: '需要授权访问相册', + uploadTimeout: '上传超时,请重试', + networkConnectionFailed: '网络连接失败', + uploadFailedRetry: '图片上传失败,请重试', + uploadTwoImages: '请先上传两张图片', + aiGenerating: 'AI正在生成中...', + waitForTaskCompletion: '请等待生成中的任务完成后再尝试', + firstPhoto: '第一张照片', + selectYourPhoto: '选择你的照片', + secondPhoto: '第二张照片', + selectFriendPhoto: '选择朋友的照片', + clickToChange: '点击更换', + clickToUpload: '点击上传', + friendsPhotoGeneration: '好友合照生成', + uploadTwoPhotosDesc: '上传两张照片,AI将为你们生成精彩的合照视频', + photoPreview: '照片预览', + confirmAndGenerate: '确认无误后即可开始生成', + photo1: '照片 1', + photo2: '照片 2', + generating: '生成中...', + startGenerating: '开始生成合照', + }, + templates: { + aiGeneratedContent: '内容由AI生成', + }, + loading: { + aiGenerating: 'AI正在生成中...', + pleaseWaitDesc: '请耐心等待,正在为您精心制作', + }, + audit: { + imageContentNotCompliant: '图片内容不符合规范,请重新选择符合规范的图片', + imageNeedsManualReview: '图片需要人工审核,请稍后重试或更换其他图片', + imageAuditUncertain: '图片审核结果不确定,请重新选择图片', + imageAuditPassed: '图片审核通过', + imageAuditFailed: '图片审核失败,请重新选择图片', + imageAuditNotPassed: '图片审核未通过', + iKnow: '我知道了', + }, + history: { + title: '我的作品', + noRecords: '暂无创作历史,去发布', + loadFailed: '加载记录失败', + processingFailed: '处理失败', + }, +}; diff --git a/src/i18n/manager.ts b/src/i18n/manager.ts new file mode 100644 index 0000000..280d493 --- /dev/null +++ b/src/i18n/manager.ts @@ -0,0 +1,110 @@ +/** + * i18n管理器 + * 处理应用级别的国际化设置,包括tabBar、导航栏标题等 + */ + +import Taro from '@tarojs/taro'; +import { Language } from './index'; +import { t } from './utils'; + +export class I18nManager { + private static instance: I18nManager; + + public static getInstance(): I18nManager { + if (!I18nManager.instance) { + I18nManager.instance = new I18nManager(); + } + return I18nManager.instance; + } + + /** + * 更新TabBar文本 + */ + public async updateTabBarTexts(): Promise { + try { + // 更新第一个tab(游乐场) + await Taro.setTabBarItem({ + index: 0, + text: t('navigation.playground'), + }); + + // 更新第二个tab(我的) + await Taro.setTabBarItem({ + index: 1, + text: t('navigation.mine'), + }); + } catch (error) { + console.warn('Failed to update tabBar texts:', error); + } + } + + /** + * 更新导航栏标题 + * @param pageKey 页面键名 + */ + public async updateNavigationBarTitle(pageKey?: string): Promise { + try { + let title = ''; + + // 根据页面设置不同的标题 + switch (pageKey) { + case 'home': + title = t('home.title'); + break; + case 'friends-photo': + title = t('friendsPhoto.title'); + break; + case 'history': + title = t('navigation.history'); + break; + default: + title = t('home.title'); // 默认标题 + } + + await Taro.setNavigationBarTitle({ title }); + } catch (error) { + console.warn('Failed to update navigation bar title:', error); + } + } + + /** + * 初始化应用国际化设置 + */ + public async initializeApp(): Promise { + try { + // 更新TabBar文本 + await this.updateTabBarTexts(); + + // 更新当前页面标题 + const pages = Taro.getCurrentPages(); + const currentPage = pages[pages.length - 1]; + + if (currentPage) { + const route = currentPage.route; + let pageKey = ''; + + if (route?.includes('home')) { + pageKey = 'home'; + } else if (route?.includes('friends-photo')) { + pageKey = 'friends-photo'; + } else if (route?.includes('history')) { + pageKey = 'history'; + } + + await this.updateNavigationBarTitle(pageKey); + } + } catch (error) { + console.error('Failed to initialize app i18n:', error); + } + } + + /** + * 语言切换时的处理 + */ + public async onLanguageChange(): Promise { + await this.initializeApp(); + } +} + +// 导出单例实例 +export const i18nManager = I18nManager.getInstance(); diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts new file mode 100644 index 0000000..ea3eb1f --- /dev/null +++ b/src/i18n/utils.ts @@ -0,0 +1,121 @@ +/** + * 国际化工具函数 + */ + +import Taro from '@tarojs/taro'; +import { Language, i18nConfig } from './index'; +import { locales } from './locales'; + +// 当前语言状态 - 固定为英语 +let currentLanguage: Language = 'en-US'; + +/** + * 获取当前语言 + */ +export const getCurrentLanguage = (): Language => { + return currentLanguage; +}; + +/** + * 设置当前语言 - 固定为英语 + */ +export const setCurrentLanguage = async (): Promise => { + // 始终使用英语 + currentLanguage = 'en-US'; + + // 保存到本地存储 + try { + await Taro.setStorageSync(i18nConfig.storageKey, 'en-US'); + } catch (error) { + console.error('Failed to save language to storage:', error); + } +}; + +/** + * 从本地存储初始化语言 - 固定为英语 + */ +export const initLanguageFromStorage = async (): Promise => { + // 始终返回英语,忽略本地存储 + currentLanguage = 'en-US'; + return currentLanguage; +}; + +/** + * 获取嵌套对象的值 + */ +const getNestedValue = (obj: any, path: string): string => { + return path.split('.').reduce((current, key) => { + return current && current[key] !== undefined ? current[key] : undefined; + }, obj); +}; + +/** + * 翻译函数 + * @param key 翻译键,支持嵌套路径,如 'common.confirm' + * @param params 参数对象,用于字符串插值 + * @returns 翻译后的文本 + */ +export const t = (key: string, params?: Record): string => { + const currentLocale = locales[currentLanguage]; + const fallbackLocale = locales[i18nConfig.fallbackLanguage]; + + // 尝试从当前语言获取翻译 + let translation = getNestedValue(currentLocale, key); + + // 如果当前语言没有找到,尝试从回退语言获取 + if (translation === undefined && currentLanguage !== i18nConfig.fallbackLanguage) { + translation = getNestedValue(fallbackLocale, key); + } + + // 如果仍然没有找到,返回键名 + if (translation === undefined) { + console.warn(`Translation not found for key: ${key}`); + return key; + } + + // 处理参数插值 + if (params && typeof translation === 'string') { + return translation.replace(/\{\{(\w+)\}\}/g, (match, paramKey) => { + return params[paramKey] !== undefined ? String(params[paramKey]) : match; + }); + } + + return translation; +}; + +/** + * 检测系统语言并设置 - 固定为英语 + */ +export const detectAndSetSystemLanguage = async (): Promise => { + // 始终返回英语,忽略系统语言检测 + currentLanguage = 'en-US'; + return 'en-US'; +}; + +/** + * 格式化数字(根据语言环境) - 固定为英语 + */ +export const formatNumber = (num: number): string => { + try { + return new Intl.NumberFormat('en-US').format(num); + } catch (error) { + return num.toString(); + } +}; + +/** + * 格式化日期(根据语言环境) - 固定为英语 + */ +export const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions): string => { + try { + const defaultOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + }; + + return new Intl.DateTimeFormat('en-US', options || defaultOptions).format(date); + } catch (error) { + return date.toLocaleDateString(); + } +}; diff --git a/src/pages/friends-photo/components/UploadCard/index.css b/src/pages/friends-photo/components/UploadCard/index.css new file mode 100644 index 0000000..17061ad --- /dev/null +++ b/src/pages/friends-photo/components/UploadCard/index.css @@ -0,0 +1,60 @@ +/* 上传卡片 */ +.upload-card { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.upload-card:active { + transform: scale(0.98); +} + +/* 上传内容区域 */ +.upload-content { + width: 100%; + aspect-ratio: 3/4; + background: #f6f7f9; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +/* 上传占位符 */ +.upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: 24px; +} + +.upload-icon { + width: 25%; + aspect-ratio: 1; + height: auto; +} + +/* 上传的图片 */ +.upload-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 标题 */ +.upload-title { + font-size: 14px; + font-weight: 500; + color: #666; + text-align: center; + line-height: 1.2; +} diff --git a/src/pages/friends-photo/components/UploadCard/index.tsx b/src/pages/friends-photo/components/UploadCard/index.tsx new file mode 100644 index 0000000..db89c94 --- /dev/null +++ b/src/pages/friends-photo/components/UploadCard/index.tsx @@ -0,0 +1,26 @@ +import { View, Image } from '@tarojs/components'; +import './index.css'; + +interface UploadCardProps { + imageUrl: string; + title: string; + onUpload: () => void; + className?: string; +} + +export default function UploadCard({ imageUrl, title, onUpload, className = '' }: UploadCardProps) { + return ( + + + {imageUrl ? ( + + ) : ( + + + {title} + + )} + + + ); +} diff --git a/src/pages/friends-photo/hooks/index.ts b/src/pages/friends-photo/hooks/index.ts new file mode 100644 index 0000000..8513c14 --- /dev/null +++ b/src/pages/friends-photo/hooks/index.ts @@ -0,0 +1 @@ +export { useImageUpload } from './useImageUpload'; diff --git a/src/pages/friends-photo/hooks/useImageUpload.ts b/src/pages/friends-photo/hooks/useImageUpload.ts new file mode 100644 index 0000000..adb31e3 --- /dev/null +++ b/src/pages/friends-photo/hooks/useImageUpload.ts @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import Taro from '@tarojs/taro'; +import { useSdk } from '../../../hooks/index'; +import { useImageDetectionTaskManager, ImageAuditResult, AuditConclusion } from '../../../hooks/useImageDetectionTaskManager'; +import { useI18n } from '../../../hooks/useI18n'; + +export function useImageUpload() { + const [image1, setImage1] = useState(''); + const [image2, setImage2] = useState(''); + const sdk = useSdk(); + const { submitAuditTask } = useImageDetectionTaskManager(); + const { t } = useI18n(); + + // 处理审核失败 + const handleAuditFailure = (auditResult: ImageAuditResult) => { + const messages: Record = { + [AuditConclusion.REJECT]: t('audit.imageContentNotCompliant'), + [AuditConclusion.REVIEW]: t('audit.imageNeedsManualReview'), + [AuditConclusion.UNCERTAIN]: t('audit.imageAuditUncertain'), + [AuditConclusion.PASS]: t('audit.imageAuditPassed'), + }; + + const message = auditResult.conclusion ? messages[auditResult.conclusion] : t('audit.imageAuditFailed'); + + Taro.showModal({ + title: t('audit.imageAuditNotPassed'), + content: message, + confirmText: t('audit.iKnow'), + showCancel: false, + }); + }; + + // 上传单张图片 + const uploadSingleImage = async (imageIndex: 1 | 2) => { + try { + // 添加触觉反馈 + Taro.vibrateShort(); + + Taro.showLoading({ + title: t('friendsPhoto.selectingImage'), + mask: true, + }); + + const imageUrl = await sdk.chooseAndUploadImage({ + count: 1, + onImageSelected: () => { + Taro.showLoading({ + title: t('friendsPhoto.uploading'), + mask: true, + }); + }, + onProgress: () => { + Taro.showLoading({ + title: t('friendsPhoto.uploading'), + mask: true, + }); + }, + }); + + // 审核图片 + const auditResult = await new Promise((resolve, reject) => { + submitAuditTask( + { + imageUrl, + options: { + enableCache: true, + timeout: 30000, + }, + }, + (status, progress) => { + console.log('审核进度:', status, progress); + }, + result => { + console.log('审核成功:', result); + resolve(result); + }, + error => { + console.error('审核失败:', error); + reject(error); + } + ); + }); + + Taro.hideLoading(); + + if (auditResult.conclusion === AuditConclusion.PASS) { + if (imageIndex === 1) { + setImage1(imageUrl); + } else { + setImage2(imageUrl); + } + + // 成功反馈 + Taro.vibrateShort(); + Taro.showToast({ + title: `${t('friendsPhoto.uploadSuccess')} ${imageIndex}`, + icon: 'success', + duration: 1500, + }); + } else { + handleAuditFailure(auditResult); + } + } catch (error: any) { + Taro.hideLoading(); + + // 用户取消选择图片,不显示错误提示 + if (error.errMsg && error.errMsg.includes('chooseImage:fail cancel')) { + return; + } + + // 提供更详细的错误提示 + let errorTitle = t('friendsPhoto.uploadFailed'); + if (error?.message) { + errorTitle = error.message; + } else if (error?.errMsg) { + if (error.errMsg.includes('chooseImage:fail auth')) { + errorTitle = t('friendsPhoto.needAlbumPermission'); + } else if (error.errMsg.includes('timeout')) { + errorTitle = t('friendsPhoto.uploadTimeout'); + } else if (error.errMsg.includes('network')) { + errorTitle = t('friendsPhoto.networkConnectionFailed'); + } else { + errorTitle = t('friendsPhoto.uploadFailedRetry'); + } + } + + Taro.showToast({ + title: errorTitle, + icon: 'error', + duration: 3000, + }); + + console.error('图片上传详细错误:', error); + } + }; + + return { + image1, + image2, + uploadSingleImage, + setImage1, + setImage2, + }; +} diff --git a/src/pages/friends-photo/index.config.ts b/src/pages/friends-photo/index.config.ts index c2b46d0..532396c 100644 --- a/src/pages/friends-photo/index.config.ts +++ b/src/pages/friends-photo/index.config.ts @@ -1,5 +1,5 @@ export default definePageConfig({ - navigationBarTitleText: '好友合照', + navigationBarTitleText: '好友合照', // 默认标题,运行时会被i18n替换 enableShareAppMessage: true, enableShareTimeline: true, }); diff --git a/src/pages/friends-photo/index.css b/src/pages/friends-photo/index.css index a3e2fd5..41115df 100644 --- a/src/pages/friends-photo/index.css +++ b/src/pages/friends-photo/index.css @@ -1,290 +1,22 @@ /* 页面容器 */ .friends-photo { height: 100vh; - background: #f5f6f8; + background: #f8f9fa; display: flex; flex-direction: column; position: relative; } -/* 滚动容器 */ -.friends-photo-scroll { - flex: 1; - height: 100%; - padding-bottom: 100px; /* 为固定底部按钮留空间 */ -} - -/* 介绍卡片 */ -.intro-card { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - margin: 20px; - border-radius: 20px; - padding: 24px; - box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3); - position: relative; - overflow: hidden; -} - -.intro-card::before { - content: ''; - position: absolute; - top: -50%; - right: -50%; - width: 100%; - height: 100%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); - border-radius: 50%; -} - -.intro-content { - display: flex; - align-items: center; - gap: 16px; - position: relative; - z-index: 1; -} - -.intro-icon { - font-size: 36px; - background: rgba(255, 255, 255, 0.2); - border-radius: 16px; - padding: 12px; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.intro-text { - flex: 1; -} - -.intro-title { - font-size: 20px; - font-weight: 600; - color: white; - margin-bottom: 4px; -} - -.intro-subtitle { - font-size: 14px; - color: rgba(255, 255, 255, 0.9); - line-height: 1.4; -} - /* 上传区域 */ .upload-section { - padding: 0 20px; - display: flex; - flex-direction: column; - gap: 16px; -} - -/* 上传卡片 */ -.upload-card { - background: white; - border-radius: 16px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - overflow: hidden; - transition: all 0.3s ease; -} - -.upload-card:active { - transform: scale(0.98); - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); -} - -.upload-card-content { - padding: 20px; -} - -/* 上传信息 */ -.upload-info { - margin-bottom: 16px; -} - -.upload-title { - font-size: 16px; - font-weight: 600; - color: #1d1f22; - margin-bottom: 4px; -} - -.upload-description { - font-size: 14px; - color: #8e9bae; -} - -/* 上传区域 */ -.upload-area { - width: 100%; - height: 160px; - border: 2px dashed #e5e7eb; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - background: #fafbfc; - transition: all 0.3s ease; - position: relative; - overflow: hidden; -} - -.upload-area:active { - transform: scale(0.98); -} - -.upload-area.has-image { - border: 2px solid #3b82f6; - background: transparent; -} - -/* 上传占位符 */ -.upload-placeholder { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.upload-icon { - font-size: 32px; - color: #8e9bae; -} - -.upload-hint { - font-size: 14px; - color: #8e9bae; - font-weight: 500; -} - -/* 上传的图片 */ -.upload-image { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 10px; -} - -/* 图片覆盖层 */ -.image-overlay { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s ease; - backdrop-filter: blur(4px); -} - -.upload-area.has-image:active .image-overlay { - opacity: 1; -} - -.overlay-content { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.change-icon { - font-size: 24px; - color: white; -} - -.change-text { - color: white; - font-size: 14px; - font-weight: 500; -} - -/* 预览卡片 */ -.preview-card { - background: white; - margin: 24px 20px 0; - border-radius: 16px; - padding: 20px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - animation: slideUp 0.3s ease-out; -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.preview-header { - margin-bottom: 16px; -} - -.preview-title { - font-size: 16px; - font-weight: 600; - color: #1d1f22; - margin-bottom: 4px; -} - -.preview-subtitle { - font-size: 14px; - color: #8e9bae; -} - -/* 预览网格 */ -.preview-grid { - display: flex; - align-items: center; - gap: 16px; -} - -.preview-item { flex: 1; - position: relative; -} - -.preview-image { - width: 100%; - aspect-ratio: 1; - object-fit: cover; - border-radius: 12px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); -} - -.preview-label { - position: absolute; - bottom: 8px; - left: 8px; - background: rgba(0, 0, 0, 0.7); - color: white; - font-size: 12px; - font-weight: 500; - padding: 4px 8px; - border-radius: 6px; - backdrop-filter: blur(4px); -} - -.preview-connector { - font-size: 20px; - font-weight: bold; - color: #3b82f6; - background: rgba(59, 130, 246, 0.1); - width: 32px; - height: 32px; - border-radius: 50%; + padding: 40px 14px 88px; display: flex; + flex-direction: row; align-items: center; - justify-content: center; -} - -/* 底部间距 */ -.bottom-spacer { - height: 20px; + gap: 12px; + min-height: 0; + overflow: hidden; } /* 提交区域 */ @@ -293,74 +25,44 @@ bottom: 0; left: 0; right: 0; - padding: 20px; - background: white; - border-top: 1px solid #e5e7eb; - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.08); - backdrop-filter: blur(10px); + padding: 20px 14px; + padding-bottom: calc(20px + env(safe-area-inset-bottom)); z-index: 10; } /* 提交按钮 */ .submit-button { width: 100%; - height: 56px; - background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); - border-radius: 28px; + height: 96px; + background: #000; + border-radius: 24px; border: none; - color: white; - font-size: 16px; - font-weight: 600; + outline: none; display: flex; align-items: center; justify-content: center; gap: 8px; - transition: all 0.3s ease; - box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4); - position: relative; - overflow: hidden; -} - -.submit-button::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.2), transparent); - transform: rotate(45deg); - transition: transform 0.6s ease; -} - -.submit-button:not(.disabled):active::before { - transform: rotate(45deg) translateX(100%); -} - -.submit-button.disabled { - background: #d1d5db; - box-shadow: none; - cursor: not-allowed; -} - -.submit-button:not(.disabled):active { - transform: translateY(2px); - box-shadow: 0 3px 16px rgba(59, 130, 246, 0.4); -} - -.submit-icon { - font-size: 18px; + transition: all 0.2s ease; } .submit-text { + color: white; + font-size: 24px; font-weight: 600; } +.submit-button.disabled { + background-color: #ccc; + cursor: not-allowed; + border: none; + outline: none; +} + /* 加载动画 */ .loading-spinner { width: 16px; height: 16px; - border: 2px solid rgba(255, 255, 255, 0.3); + border: 2px solid rgb(255 255 255 / 30%); border-top: 2px solid white; border-radius: 50%; animation: spin 1s linear infinite; @@ -370,58 +72,8 @@ 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } - -/* 响应式适配 */ -@media (max-width: 375px) { - .intro-card { - margin: 16px; - padding: 20px; - } - - .upload-section { - padding: 0 16px; - } - - .preview-card { - margin: 20px 16px 0; - } - - .submit-section { - padding: 16px; - } -} - -/* 深色模式适配 */ -@media (prefers-color-scheme: dark) { - .friends-photo { - background: #1a1a1a; - } - - .upload-card, - .preview-card { - background: #2a2a2a; - border: 1px solid #333; - } - - .upload-title { - color: #fff; - } - - .upload-description { - color: #999; - } - - .upload-area { - background: #333; - border-color: #444; - } - - .submit-section { - background: #2a2a2a; - border-color: #333; - } -} diff --git a/src/pages/friends-photo/index.tsx b/src/pages/friends-photo/index.tsx index cef9172..39a1559 100644 --- a/src/pages/friends-photo/index.tsx +++ b/src/pages/friends-photo/index.tsx @@ -1,172 +1,60 @@ -import { View, Image, Button, ScrollView } from '@tarojs/components'; -import { useState, useEffect } from 'react'; -import Taro, { navigateTo } from '@tarojs/taro'; -import { useSdk, useServerSdk } from '../../hooks/index'; -import { useImageDetectionTaskManager, ImageAuditResult, AuditConclusion } from '../../hooks/useImageDetectionTaskManager'; +import { Template } from '@/sdk/sdk-server'; +import { Button, View } from '@tarojs/components'; +import Taro, { navigateTo, useRouter } from '@tarojs/taro'; +import { useEffect, useState } from 'react'; +import { useServerSdk } from '../../hooks/index'; +import { useI18n } from '../../hooks/useI18n'; +import { i18nManager } from '../../i18n/manager'; + +// 导入组件 +import UploadCard from './components/UploadCard'; + +// 导入hooks +import { useImageUpload } from './hooks'; import './index.css'; -// 好友合照模板的固定代码 -const FRIENDS_PHOTO_TEMPLATE_CODE = 'friends_photo'; - export default function FriendsPhoto() { - const sdk = useSdk(); + const { templateCode } = useRouter().params; + const [template, setTemplate] = useState