Merge branch 'master' of ssh://gitea.bowongai.com:222/bowong/bw-mini-app

This commit is contained in:
imeepos
2025-09-28 13:31:27 +08:00
32 changed files with 1518 additions and 748 deletions

View File

@@ -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: '用于保存生成的图片和视频到相册', // 运行时会被替换为对应语言
},
},
});

View File

@@ -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<any>) {
useLaunch(async () => {
// 初始化国际化
try {
await initLanguageFromStorage()
await i18nManager.initializeApp()
} catch (error) {
console.error('i18n初始化失败:', error)
}
const authorize = createPlatformFactory().createAuthorize()
const payment = createPlatformFactory().createPayment()
try {

BIN
src/assets/icons/photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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;
}

View File

@@ -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<LanguageSwitcherProps> = ({ 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 (
<View className={`language-switcher ${className}`} onClick={handleLanguageSwitch}>
<View className="language-switcher-content">
<Text className="language-icon">🌐</Text>
<Text className="language-text">{languageNames[currentLanguage]}</Text>
<Text className="language-arrow"></Text>
</View>
</View>
);
};
export default LanguageSwitcher;

View File

@@ -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<LoadingOverlayProps> = ({ children }) => {
const { t } = useI18n();
return (
<View className="loading-overlay">
<View className="loading-container">
<View className="loading-spinner" />
<View className="loading-text">
<Text className="loading-title">AI正在生成中...</Text>
<Text className="loading-desc"></Text>
<Text className="loading-title">{t('loading.aiGenerating')}</Text>
<Text className="loading-desc">{t('loading.pleaseWaitDesc')}</Text>
</View>
{children}
</View>

View File

@@ -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<any>(null);
@@ -104,7 +106,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
<Text className="name-badge">{template.name}</Text>
</View>
<View className="watermark">
<Text className="watermark-text">AI生成</Text>
<Text className="watermark-text">{t('templates.aiGeneratedContent')}</Text>
</View>
</View>
) : (
@@ -159,7 +161,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
<Text className="name-badge">{template.name}</Text>
</View>
<View className="watermark">
<Text className="watermark-text">AI生成</Text>
<Text className="watermark-text">{t('templates.aiGeneratedContent')}</Text>
</View>
</View>
</View>

View File

@@ -2,4 +2,5 @@
export { useAd } from './useAd'
export { useSdk, useServerSdk } from './useSdk'
export { useUploadVideo } from './useUploadVideo'
export { useUploadVideo } from './useUploadVideo'
export { useI18n } from './useI18n'

55
src/hooks/useI18n.ts Normal file
View File

@@ -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<void>;
/** 支持的语言列表 */
supportedLanguages: Language[];
/** 语言显示名称 */
languageNames: typeof languageNames;
/** 格式化数字 */
formatNumber: typeof formatNumber;
/** 格式化日期 */
formatDate: typeof formatDate;
/** 是否正在加载 */
loading: boolean;
}
/**
* i18n Hook - 固定为英语
*/
export const useI18n = (): UseI18nReturn => {
const [currentLanguage] = useState<Language>('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,
};
};

135
src/i18n/example.tsx Normal file
View File

@@ -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 (
<View>
<Text>{t('common.confirm')}</Text>
<Text>{t('home.title')}</Text>
<Text>{t('navigation.playground')}</Text>
</View>
);
}
// 示例2: 带参数的翻译
function ParameterExample() {
const { t } = useI18n();
const userName = '张三';
const count = 42;
return (
<View>
{/* 注意:需要在语言文件中定义支持参数的翻译键 */}
<Text>{t('welcome.message', { name: userName })}</Text>
<Text>{t('notification.count', { count: count.toString() })}</Text>
</View>
);
}
// 示例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 (
<View>
<Text>: {languageNames[currentLanguage]}</Text>
{/* 方式1: 使用内置的语言切换组件 */}
<LanguageSwitcher />
{/* 方式2: 自定义语言切换按钮 */}
{supportedLanguages.map(lang => (
<Button
key={lang}
onClick={() => handleLanguageChange(lang)}
disabled={lang === currentLanguage}
>
{languageNames[lang]}
</Button>
))}
</View>
);
}
// 示例4: 数字和日期格式化
function FormattingExample() {
const { formatNumber, formatDate } = useI18n();
return (
<View>
<Text>: {formatNumber(1234.56)}</Text>
<Text>: {formatDate(new Date())}</Text>
<Text>: {formatDate(new Date(), {
year: 'numeric',
month: 'long',
day: 'numeric'
})}</Text>
</View>
);
}
// 示例5: 条件渲染基于语言
function ConditionalExample() {
const { currentLanguage, t } = useI18n();
return (
<View>
<Text>{t('common.loading')}</Text>
{/* 根据语言显示不同内容 */}
{currentLanguage === 'zh-CN' && (
<Text></Text>
)}
{currentLanguage === 'en-US' && (
<Text>This is English-specific content</Text>
)}
</View>
);
}
// 示例6: 在类组件中使用(如果需要)
import { Component } from 'react';
import { getCurrentLanguage, t } from '../i18n/utils';
class ClassComponentExample extends Component {
state = {
currentLanguage: getCurrentLanguage(),
};
render() {
return (
<View>
<Text>{t('common.confirm')}</Text>
<Text>: {this.state.currentLanguage}</Text>
</View>
);
}
}
export {
BasicExample,
ParameterExample,
LanguageSwitchExample,
FormattingExample,
ConditionalExample,
ClassComponentExample,
};

122
src/i18n/index.ts Normal file
View File

@@ -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<Language, string> = {
'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;
};
}

94
src/i18n/locales/en-US.ts Normal file
View File

@@ -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',
},
};

18
src/i18n/locales/index.ts Normal file
View File

@@ -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<Language, LanguageResources> = {
'zh-CN': zhCN,
'en-US': enUS,
'ja-JP': jaJP,
'ko-KR': koKR,
};
export { zhCN, enUS, jaJP, koKR };

94
src/i18n/locales/ja-JP.ts Normal file
View File

@@ -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: '処理に失敗しました',
},
};

94
src/i18n/locales/ko-KR.ts Normal file
View File

@@ -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: '처리 실패',
},
};

94
src/i18n/locales/zh-CN.ts Normal file
View File

@@ -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: '处理失败',
},
};

110
src/i18n/manager.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
await this.initializeApp();
}
}
// 导出单例实例
export const i18nManager = I18nManager.getInstance();

121
src/i18n/utils.ts Normal file
View File

@@ -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<void> => {
// 始终使用英语
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<Language> => {
// 始终返回英语,忽略本地存储
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, string | number>): 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<Language> => {
// 始终返回英语,忽略系统语言检测
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();
}
};

View File

@@ -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;
}

View File

@@ -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 (
<View className={`upload-card ${className}`} onClick={onUpload}>
<View className="upload-content">
{imageUrl ? (
<Image src={imageUrl} className="upload-image" mode="aspectFill" />
) : (
<View className="upload-placeholder">
<Image src="/assets/icons/photo.png" className="upload-icon" mode="aspectFit" />
<View className="upload-title">{title}</View>
</View>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1 @@
export { useImageUpload } from './useImageUpload';

View File

@@ -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<string>('');
const [image2, setImage2] = useState<string>('');
const sdk = useSdk();
const { submitAuditTask } = useImageDetectionTaskManager();
const { t } = useI18n();
// 处理审核失败
const handleAuditFailure = (auditResult: ImageAuditResult) => {
const messages: Record<AuditConclusion, string> = {
[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<ImageAuditResult>((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,
};
}

View File

@@ -1,5 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '好友合照',
navigationBarTitleText: '好友合照', // 默认标题运行时会被i18n替换
enableShareAppMessage: true,
enableShareTimeline: true,
});

View File

@@ -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;
}
}

View File

@@ -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<Template | null>(null);
console.log(template);
const { t } = useI18n();
const serverSdk = useServerSdk();
const { submitAuditTask } = useImageDetectionTaskManager();
const [image1, setImage1] = useState<string>('');
const [image2, setImage2] = useState<string>('');
const [loading, setLoading] = useState(false);
const [showGuide, setShowGuide] = useState(true);
// 显示使用指南
const { image1, image2, uploadSingleImage } = useImageUpload();
// 设置页面标题
useEffect(() => {
if (!showGuide) return;
i18nManager.updateNavigationBarTitle('friends-photo');
}, []);
const timer = setTimeout(() => {
Taro.showModal({
title: '使用小贴士',
content: '• 选择清晰的人脸照片效果更佳\n• 建议选择光线充足的照片\n• 支持JPG、PNG格式',
confirmText: '我知道了',
showCancel: false,
success: () => {
setShowGuide(false);
}
});
}, 1000);
return () => clearTimeout(timer);
}, [showGuide]);
// 处理审核失败
const handleAuditFailure = (auditResult: ImageAuditResult) => {
const messages: Record<AuditConclusion, string> = {
[AuditConclusion.REJECT]: '图片内容不符合规范,请重新选择符合规范的图片',
[AuditConclusion.REVIEW]: '图片需要人工审核,请稍后重试或更换其他图片',
[AuditConclusion.UNCERTAIN]: '图片审核结果不确定,请重新选择图片',
[AuditConclusion.PASS]: '图片审核通过',
useEffect(() => {
const fetchTemplate = async () => {
if (!templateCode) return;
const template = await serverSdk.getTemplate(templateCode);
setTemplate(template);
};
const message = auditResult.conclusion ? messages[auditResult.conclusion] : '图片审核失败,请重新选择图片';
Taro.showModal({
title: '图片审核未通过',
content: message,
confirmText: '我知道了',
showCancel: false,
});
};
// 上传单张图片
const uploadSingleImage = async (imageIndex: 1 | 2) => {
try {
// 添加触觉反馈
Taro.vibrateShort();
Taro.showLoading({
title: '选择图片中...',
mask: true,
});
const imageUrl = await sdk.chooseAndUploadImage({
count: 1,
onImageSelected: () => {
Taro.showLoading({
title: '正在上传...',
mask: true,
});
},
onProgress: () => {
Taro.showLoading({
title: '上传中...',
mask: true,
});
},
});
// 审核图片
const auditResult = await new Promise<ImageAuditResult>((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: `图片${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 = '图片上传失败';
if (error?.message) {
errorTitle = error.message;
} else if (error?.errMsg) {
if (error.errMsg.includes('chooseImage:fail auth')) {
errorTitle = '需要授权访问相册';
} else if (error.errMsg.includes('timeout')) {
errorTitle = '上传超时,请重试';
} else if (error.errMsg.includes('network')) {
errorTitle = '网络连接失败';
} else {
errorTitle = '图片上传失败,请重试';
}
}
Taro.showToast({
title: errorTitle,
icon: 'error',
duration: 3000,
});
console.error('图片上传详细错误:', error);
}
};
fetchTemplate();
}, [templateCode]);
// 提交处理
const handleSubmit = async () => {
if (!templateCode) {
Taro.showToast({
title: t('friendsPhoto.templateCodeNotSet'),
icon: 'error',
duration: 2000,
});
return;
}
if (!image1 || !image2) {
Taro.vibrateShort();
Taro.showToast({
title: '请先上传两张图片',
title: t('friendsPhoto.uploadTwoImages'),
icon: 'none',
duration: 2000,
});
@@ -179,7 +67,7 @@ export default function FriendsPhoto() {
try {
Taro.showLoading({
title: 'AI正在生成中...',
title: t('friendsPhoto.aiGenerating'),
mask: true,
});
@@ -188,21 +76,21 @@ export default function FriendsPhoto() {
const taskId = await serverSdk.executeTemplate({
imageUrl: combinedImageUrl,
templateCode: FRIENDS_PHOTO_TEMPLATE_CODE,
templateCode: templateCode,
});
Taro.hideLoading();
// 跳转到结果页面
navigateTo({
url: `/pages/result/index?taskId=${taskId}&templateCode=${FRIENDS_PHOTO_TEMPLATE_CODE}`,
url: `/pages/result/index?taskId=${taskId}&templateCode=${templateCode}`,
});
} catch (error) {
Taro.hideLoading();
console.error('提交失败:', error);
Taro.showToast({
title: '请等待生成中的任务完成后再尝试',
title: t('friendsPhoto.waitForTaskCompletion'),
icon: 'none',
duration: 2000,
});
@@ -211,109 +99,30 @@ export default function FriendsPhoto() {
}
};
const renderUploadCard = (imageIndex: 1 | 2, imageUrl: string, title: string, description: string) => (
<View className="upload-card">
<View className="upload-card-content">
<View className="upload-info">
<View className="upload-title">{title}</View>
<View className="upload-description">{description}</View>
</View>
<View
className={`upload-area ${imageUrl ? 'has-image' : ''}`}
onClick={() => uploadSingleImage(imageIndex)}
>
{imageUrl ? (
<>
<Image src={imageUrl} className="upload-image" mode="aspectFill" />
<View className="image-overlay">
<View className="overlay-content">
<View className="change-icon">🔄</View>
<View className="change-text"></View>
</View>
</View>
</>
) : (
<View className="upload-placeholder">
<View className="upload-icon">📷</View>
<View className="upload-hint"></View>
</View>
)}
</View>
</View>
</View>
);
return (
<View className="friends-photo">
<ScrollView
className="friends-photo-scroll"
scrollY
enhanced
showScrollbar={false}
enablePassive
bounces
>
{/* 顶部介绍卡片 */}
<View className="intro-card">
<View className="intro-content">
<View className="intro-icon">👫</View>
<View className="intro-text">
<View className="intro-title"></View>
<View className="intro-subtitle">
AI将为你们生成精彩的合照视频
</View>
</View>
</View>
</View>
{/* 上传区域 */}
<View className="upload-section">
{renderUploadCard(1, image1, "第一张照片", "选择你的照片")}
{renderUploadCard(2, image2, "第二张照片", "选择朋友的照片")}
</View>
{/* 预览区域 */}
{image1 && image2 && (
<View className="preview-card">
<View className="preview-header">
<View className="preview-title">📸 </View>
<View className="preview-subtitle"></View>
</View>
<View className="preview-grid">
<View className="preview-item">
<Image src={image1} className="preview-image" mode="aspectFill" />
<View className="preview-label"> 1</View>
</View>
<View className="preview-connector">+</View>
<View className="preview-item">
<Image src={image2} className="preview-image" mode="aspectFill" />
<View className="preview-label"> 2</View>
</View>
</View>
</View>
)}
{/* 底部间距 */}
<View className="bottom-spacer" />
</ScrollView>
{/* 上传区域 */}
<View className="upload-section">
<UploadCard imageUrl={image1} title={t('friendsPhoto.selectYourPhoto')} onUpload={() => uploadSingleImage(1)} />
<UploadCard imageUrl={image2} title={t('friendsPhoto.selectFriendPhoto')} onUpload={() => uploadSingleImage(2)} />
</View>
{/* 固定底部按钮 */}
<View className="submit-section">
<Button
className={`submit-button ${(!image1 || !image2 || loading) ? 'disabled' : ''}`}
className={`submit-button ${!image1 || !image2 || loading ? 'disabled' : ''}`}
disabled={!image1 || !image2 || loading}
onClick={handleSubmit}
>
{loading ? (
<>
<View className="loading-spinner" />
<View className="submit-text">...</View>
<View className="submit-text">{t('friendsPhoto.generating')}</View>
</>
) : (
<>
<View className="submit-icon"></View>
<View className="submit-text"></View>
</>
<View className="submit-text">
${template?.creditCost} {t('friendsPhoto.startGenerating')}
</View>
)}
</Button>
</View>

View File

@@ -9,11 +9,21 @@
padding: 32px 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32px;
}
.history-title {
font-size: 48px;
font-weight: 500;
color: #1d1f22;
padding: 0 32px;
}
.language-switcher-compact {
flex-shrink: 0;
}
.history-list {

View File

@@ -2,8 +2,12 @@ import { Image, ScrollView, Text, View } from '@tarojs/components';
import Taro, { navigateTo } from '@tarojs/taro';
import { useEffect, useState } from 'react';
import { useServerSdk } from '../../hooks/index';
import { useI18n } from '../../hooks/useI18n';
import { i18nManager } from '../../i18n/manager';
// import LanguageSwitcher from '../../components/LanguageSwitcher'; // 已隐藏 - 只支持英语
import './index.css';
export default function History() {
const { t } = useI18n();
const [records, setRecords] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
@@ -33,7 +37,7 @@ export default function History() {
} catch (error) {
console.error('加载记录失败:', error);
Taro.showToast({
title: '加载记录失败',
title: t('common.operationFailed'),
icon: 'error',
duration: 2000,
});
@@ -44,6 +48,8 @@ export default function History() {
useEffect(() => {
loadRecords();
// 设置页面标题
i18nManager.updateNavigationBarTitle('history');
}, []);
// 定时更新生成中任务的进度
@@ -65,14 +71,14 @@ export default function History() {
try {
await loadRecords();
Taro.showToast({
title: '刷新成功',
title: t('home.refreshSuccess'),
icon: 'success',
duration: 1500,
});
} catch (error) {
console.error('刷新失败:', error);
Taro.showToast({
title: '刷新失败',
title: t('home.refreshFailed'),
icon: 'error',
duration: 1500,
});
@@ -91,8 +97,8 @@ export default function History() {
} else if (record.status === 'failed') {
// 显示错误信息
Taro.showModal({
title: '处理失败',
content: record.errorMessage || '图片处理失败,请重试',
title: t('common.operationFailed'),
content: record.errorMessage || t('common.operationFailed'),
showCancel: false,
});
} else if (record.status === 'processing') {
@@ -117,9 +123,14 @@ export default function History() {
return (
<View className="history">
{/* <View className="history-header">
<Text className="history-title">我的作品</Text>
</View> */}
{/* 设置区域 */}
<View className="history-header">
<View className="header-content">
<Text className="history-title">{t('navigation.mine')}</Text>
{/* 语言切换器已隐藏 - 只支持英语 */}
</View>
</View>
<ScrollView
className="history-list"
scrollY
@@ -132,12 +143,12 @@ export default function History() {
<View className="history-grid">
{loading ? (
<View className="loading-state">
<Text className="loading-text">...</Text>
<Text className="loading-text">{t('common.loading')}</Text>
</View>
) : records.length === 0 ? (
<View className="empty-state">
<Image className="empty-icon-image" src="/assets/icons/cloud.png" />
<Text className="empty-text"></Text>
<Text className="empty-text">{t('history.noRecords')}</Text>
</View>
) : (
records.map(record => {
@@ -147,17 +158,17 @@ export default function History() {
<View key={record.id} className="history-item" onClick={() => handleItemClick(record)}>
<View className="item-thumbnail">
{record.status === 'completed' && record.outputResult ? (
<Image className="thumbnail-image" src={record.inputImageUrl} mode="aspectFill" />
<Image className="thumbnail-image" src={record.inputImageUrl.split(',')[0]} mode="aspectFill" />
) : (
<View className={`thumbnail-placeholder ${record.status}`}>
<Image className="thumbnail-image" src={record.inputImageUrl} mode="aspectFill" />
<Image className="thumbnail-image" src={record.inputImageUrl.split(',')[0]} mode="aspectFill" />
{/* 生成中状态的扫描效果 */}
{isGenerating && (
<View className="thumbnail-overlay">
<View className="thumbnail-progress-overlay" />
<View className="thumbnail-scan-light" />
{/* 加载loading 转圈 */}
<View className='thumbnail-loader' />
<View className="thumbnail-loader" />
</View>
)}
</View>

View File

@@ -1,5 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '游乐场',
navigationBarTitleText: '游乐场', // 默认标题运行时会被i18n替换
navigationBarBackgroundColor: '#F5F6F8',
navigationBarTextStyle: 'black',
backgroundColorTop: '#F5F6F8',

View File

@@ -4,6 +4,21 @@
height: 100vh;
display: flex;
flex-direction: column;
position: relative;
}
/* 浮动语言切换器 */
.language-switcher-float {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.language-switcher-float:hover {
opacity: 1;
}
/* ScrollView 容器样式 */

View File

@@ -8,6 +8,9 @@ import { Template } from '../../store/types';
import TemplateCard from '../../components/TemplateCard';
import { useSdk, useServerSdk } from '../../hooks/index';
import { useImageDetectionTaskManager, ImageAuditResult, AuditConclusion } from '../../hooks/useImageDetectionTaskManager';
import { useI18n } from '../../hooks/useI18n';
import { i18nManager } from '../../i18n/manager';
// import LanguageSwitcher from '../../components/LanguageSwitcher'; // 已隐藏 - 只支持英语
import './index.css';
@@ -15,6 +18,7 @@ import './index.css';
type LoadingState = false | 'uploading' | 'auditing' | 'processing';
export default function Home() {
const { t } = useI18n();
const dispatch = useAppDispatch();
const templates = useAppSelector(selectTemplates);
const sdk = useSdk();
@@ -30,7 +34,7 @@ export default function Home() {
} catch (error) {
console.error('加载模板失败:', error);
Taro.showToast({
title: '加载模板失败',
title: t('home.loadTemplatesFailed'),
icon: 'error',
duration: 2000,
});
@@ -43,12 +47,12 @@ export default function Home() {
try {
await loadTemplates();
Taro.showToast({
title: '刷新成功',
title: t('home.refreshSuccess'),
icon: 'success',
duration: 1500,
});
} catch (error) {
console.error('刷新失败:', error);
console.error(t('home.refreshFailed'), error);
} finally {
setRefreshing(false);
}
@@ -57,132 +61,50 @@ export default function Home() {
useEffect(() => {
// 需要先检查是否有模板配置文件
loadTemplates();
// 设置页面标题
i18nManager.updateNavigationBarTitle('home');
}, [dispatch]);
// 获取加载状态的显示文本
const getLoadingText = (state: LoadingState): string => {
switch (state) {
default:
return '请稍后...';
return t('common.pleaseWait');
}
};
// 处理审核失败
const handleAuditFailure = (auditResult: ImageAuditResult) => {
const messages: Record<AuditConclusion, string> = {
[AuditConclusion.REJECT]: '图片内容不符合规范,请重新选择符合规范的图片',
[AuditConclusion.REVIEW]: '图片需要人工审核,请稍后重试或更换其他图片',
[AuditConclusion.UNCERTAIN]: '图片审核结果不确定,请重新选择图片',
[AuditConclusion.PASS]: '图片审核通过', // 虽然不会用到,但为了类型完整性
[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] : '图片审核失败,请重新选择图片';
const message = auditResult.conclusion ? messages[auditResult.conclusion] : t('audit.imageAuditFailed');
Taro.showModal({
title: '图片审核未通过',
title: t('audit.imageAuditNotPassed'),
content: message,
confirmText: '我知道了',
confirmText: t('audit.iKnow'),
showCancel: false,
success: () => {
console.log('用户确认审核失败信息');
console.log(t('home.userConfirmedAuditFailure'));
},
});
};
const handleTemplateClick = async (template: Template) => {
// 如果是好友合照,跳转到专门的页面
if (template.name === '好友合照') {
navigateTo({ url: `/pages/friends-photo/index` });
return;
}
try {
// 第一步:选择并上传图片
const imageCount = template.code === '2image-to-video' ? 2 : 1;
const imageUrl = await sdk.chooseAndUploadImage({
count: imageCount,
onImageSelected: () => {
Taro.showLoading({
title: getLoadingText('uploading'),
mask: true,
});
},
onProgress: () => {
Taro.showLoading({
title: `请稍后...`,
mask: true,
});
},
});
const auditResult = await new Promise<ImageAuditResult>((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);
}
);
});
// 第三步:检查审核结果
if (auditResult.conclusion === AuditConclusion.PASS) {
// 短暂延迟后继续业务处理
await new Promise(resolve => setTimeout(resolve, 1000));
// 第四步:执行业务逻辑
try {
const taskId = await serverSdk.executeTemplate({
imageUrl,
templateCode: template.code,
});
// 隐藏loading
Taro.hideLoading();
// 跳转到生成页面
navigateTo({
url: `/pages/result/index?taskId=${taskId.taskId}&templateCode=${template.code}`,
});
} catch (businessError) {
// 业务处理失败
Taro.hideLoading();
Taro.showToast({
title: '请等待生成中的任务完成后再尝试',
icon: 'none',
duration: 2000,
});
}
} else {
// 审核不通过
handleAuditFailure(auditResult);
}
} catch (error: any) {
if (error.errMsg.includes('chooseImage:fail cancel')) {
return;
}
// 统一错误处理
Taro.hideLoading();
Taro.showToast({
title: '图片审核失败,请重试',
icon: 'error',
duration: 2000,
});
} finally {
}
navigateTo({ url: `/pages/friends-photo/index?templateCode=${template.code}` });
return;
};
return (
<View className="home">
{/* 语言切换按钮已隐藏 - 只支持英语 */}
<ScrollView
className="home-scroll"
scrollY

View File

@@ -1,4 +1,4 @@
import { Image, View,Text } from '@tarojs/components';
import { Image, View, Text } from '@tarojs/components';
import { navigateBack } from '@tarojs/taro';
import { useEffect, useState } from 'react';
@@ -63,7 +63,7 @@ const GeneratingComponent: React.FC<GeneratingComponentProps> = ({ task }) => {
<View className="progress-inner">
{/* 预览图片或占位符 */}
{task?.inputImageUrl ? (
<Image className="preview-image" src={task.inputImageUrl} mode="aspectFill" />
<Image className="preview-image" src={task.inputImageUrl.split(',')[0]} mode="aspectFill" />
) : (
<View className="preview-placeholder">
<View className="placeholder-icon">🎨</View>
@@ -73,7 +73,7 @@ const GeneratingComponent: React.FC<GeneratingComponentProps> = ({ task }) => {
<View className="progress-overlay" />
{/* 光扫描动画 */}
<View className="scan-light" />
<View className="scan-progress" >{Math.round(progress)}%</View>
<View className="scan-progress">{Math.round(progress)}%</View>
</View>
</View>
</View>

View File

@@ -26,4 +26,4 @@ export class H5Payment extends Payment {
templateCode
};
}
}
}