refactor: 转vite

This commit is contained in:
iHeyTang
2025-09-28 15:03:57 +08:00
parent e95db8e321
commit bb7bf19236
143 changed files with 1817 additions and 10638 deletions

82
README.md Normal file
View File

@@ -0,0 +1,82 @@
# 图生视频 H5 应用
这是一个从 Taro 小程序转换而来的 Vite + React H5 应用。
## 功能特性
- 图片风格转换
- 模板选择和预览
- 历史记录管理
- 多语言支持
- 响应式设计
## 技术栈
- **框架**: React 18
- **构建工具**: Vite 5
- **路由**: React Router 6
- **状态管理**: Redux Toolkit
- **样式**: CSS + Tailwind CSS
- **类型检查**: TypeScript
## 开发环境
### 安装依赖
```bash
pnpm install
```
### 启动开发服务器
```bash
pnpm dev
```
应用将在 http://localhost:3000 启动
### 构建生产版本
```bash
pnpm build
```
### 预览生产构建
```bash
pnpm preview
```
## 项目结构
```
src/
├── components/ # 可复用组件
├── pages/ # 页面组件
├── hooks/ # 自定义 Hooks
├── store/ # Redux 状态管理
├── utils/ # 工具函数
├── i18n/ # 国际化
├── platforms/ # 平台适配层
└── sdk/ # API SDK
public/
└── assets/ # 静态资源
```
## 主要变更
从 Taro 小程序转换为 Vite H5 应用的主要变更:
1. **构建工具**: Taro → Vite
2. **组件系统**: Taro 组件 → 标准 HTML 元素
3. **路由系统**: Taro 路由 → React Router
4. **API 调用**: Taro API → Web API
5. **样式系统**: 保持原有 CSS适配标准 HTML
## 注意事项
- 静态资源已迁移到 `public/assets/` 目录
- 所有 Taro 特定的 API 调用已替换为 Web 标准 API
- 保持了原有的业务逻辑和状态管理
- 支持现代浏览器,建议使用 Chrome/Safari/Firefox 最新版本

View File

@@ -1,12 +0,0 @@
// babel-preset-taro 更多选项和默认值:
// https://docs.taro.zone/docs/next/babel-config
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true,
compiler: 'vite',
useBuiltIns: process.env.TARO_ENV === 'h5' ? 'usage' : false
}]
]
}

View File

@@ -1,31 +0,0 @@
import type { UserConfigExport } from "@tarojs/cli"
export default {
mini: {},
h5: {
devServer: {
fs: {
allow: ['..']
},
proxy: {
'/api': {
target: 'https://bowongai-test--text-video-agent-fastapi-app.modal.run',
changeOrigin: true,
secure: true,
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.log('proxy error', err);
});
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('Sending Request to the Target:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req, _res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
});
},
}
}
}
}
} satisfies UserConfigExport<'vite'>

View File

@@ -1,141 +0,0 @@
import { defineConfig, type UserConfigExport } from '@tarojs/cli';
import { UnifiedWebpackPluginV5 } from 'weapp-tailwindcss/webpack';
import devConfig from './dev';
import prodConfig from './prod';
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'vite'>(async merge => {
// 不同平台的 appId 配置
const appIds = {
weapp: process.env.TARO_APP_ID_WEAPP || 'your-weapp-appid',
tt: process.env.TARO_APP_ID_TT || 'your-tt-appid',
alipay: process.env.TARO_APP_ID_ALIPAY || 'your-alipay-appid',
swan: process.env.TARO_APP_ID_SWAN || 'your-swan-appid',
qq: process.env.TARO_APP_ID_QQ || 'your-qq-appid',
jd: process.env.TARO_APP_ID_JD || 'your-jd-appid',
};
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'bw-mini-app',
date: '2025-9-1',
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot: process.env.TARO_ENV ? `dist/${process.env.TARO_ENV}` : 'dist',
plugins: ['@tarojs/plugin-generator'],
defineConstants: {},
copy: {
patterns: [
{
from: 'src/assets/icons/',
to: 'assets/icons/',
},
],
options: {},
},
framework: 'react',
compiler: 'vite',
mini: {
postcss: {
pxtransform: {
enable: true,
config: {},
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
webpackChain(chain) {
chain.merge({
plugin: {
install: {
plugin: UnifiedWebpackPluginV5,
args: [
{
appType: 'taro',
// 下面个配置,会开启 rem -> rpx 的转化
rem2rpx: true,
},
],
},
},
});
},
},
// 小程序平台特定配置
weapp: {
outputRoot: 'dist/weapp',
appId: appIds.weapp,
},
tt: {
outputRoot: 'dist/tt',
appId: appIds.tt,
},
alipay: {
outputRoot: 'dist/alipay',
appId: appIds.alipay,
},
swan: {
outputRoot: 'dist/swan',
appId: appIds.swan,
},
qq: {
outputRoot: 'dist/qq',
appId: appIds.qq,
},
jd: {
outputRoot: 'dist/jd',
appId: appIds.jd,
},
h5: {
publicPath: '/',
staticDirectory: 'static',
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: 'css/[name].[hash].css',
chunkFilename: 'css/[name].[chunkhash].css',
},
postcss: {
autoprefixer: {
enable: true,
config: {},
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
},
rn: {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
},
},
},
};
process.env.BROWSERSLIST_ENV = process.env.NODE_ENV;
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig);
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig);
});

View File

@@ -1,9 +0,0 @@
import type { UserConfigExport } from '@tarojs/cli';
export default {
mini: {},
h5: {
// 确保产物为 es5
legacy: true,
},
} satisfies UserConfigExport<'vite'>;

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/icons/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图生视频</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -12,31 +12,15 @@
"scripts": {
"claude": "claude --dangerously-skip-permissions",
"prepare": "husky",
"postinstall": "npx weapp-tw patch",
"new": "taro new",
"dev": "vite",
"build": "vite build",
"build:check": "tsc && vite build",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx --fix",
"build:weapp": "taro build --type weapp",
"build:swan": "taro build --type swan",
"build:alipay": "taro build --type alipay",
"build:tt": "taro build --type tt",
"build:h5": "taro build --type h5",
"build:rn": "taro build --type rn",
"build:qq": "taro build --type qq",
"build:jd": "taro build --type jd",
"build:harmony-hybrid": "taro build --type harmony-hybrid",
"dev:weapp": "npm run build:weapp -- --watch",
"dev:swan": "npm run build:swan -- --watch",
"dev:alipay": "npm run build:alipay -- --watch",
"dev:tt": "npm run build:tt -- --watch",
"dev:h5": "npm run build:h5 -- --watch",
"dev:rn": "npm run build:rn -- --watch",
"dev:qq": "npm run build:qq -- --watch",
"dev:jd": "npm run build:jd -- --watch",
"dev:harmony-hybrid": "npm run build:harmony-hybrid -- --watch"
"lint": "eslint src --ext .ts,.tsx --fix"
},
"browserslist": {
"development": [
@@ -51,52 +35,30 @@
},
"author": "",
"dependencies": {
"@babel/runtime": "^7.24.4",
"@reduxjs/toolkit": "^2.9.0",
"@stripe/stripe-js": "^7.9.0",
"@tarojs/components": "4.1.6",
"@tarojs/helper": "4.1.6",
"@tarojs/plugin-framework-react": "4.1.6",
"@tarojs/plugin-platform-alipay": "4.1.6",
"@tarojs/plugin-platform-h5": "4.1.6",
"@tarojs/plugin-platform-harmony-hybrid": "4.1.6",
"@tarojs/plugin-platform-jd": "4.1.6",
"@tarojs/plugin-platform-qq": "4.1.6",
"@tarojs/plugin-platform-swan": "4.1.6",
"@tarojs/plugin-platform-tt": "4.1.6",
"@tarojs/plugin-platform-weapp": "4.1.6",
"@tarojs/react": "4.1.6",
"@tarojs/runtime": "4.1.6",
"@tarojs/shared": "4.1.6",
"@tarojs/taro": "4.1.6",
"@types/node": "^24.5.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-redux": "^9.2.0",
"react-router-dom": "^6.26.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0"
},
"devDependencies": {
"@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9",
"@babel/preset-react": "^7.24.1",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@tarojs/cli": "4.1.6",
"@tarojs/plugin-generator": "4.1.6",
"@tarojs/test-utils-react": "^0.1.1",
"@tarojs/vite-runner": "4.1.6",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/minimatch": "^5",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.21",
"babel-jest": "^30.1.2",
"babel-preset-taro": "4.1.6",
"eslint": "^8.57.0",
"eslint-config-taro": "4.1.6",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.4.0",
"husky": "^9.1.7",
@@ -105,14 +67,11 @@
"jest-environment-jsdom": "^30.1.2",
"lint-staged": "^16.1.2",
"postcss": "^8.4.38",
"react-refresh": "^0.14.0",
"stylelint": "^16.4.0",
"stylelint-config-standard": "^38.0.0",
"tailwindcss": "^4.1.12",
"terser": "^5.30.4",
"ts-jest": "^29.4.1",
"typescript": "^5.4.5",
"vite": "^4.2.0",
"weapp-tailwindcss": "^4.2.6"
"vite": "^5.4.0"
}
}

8092
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
{
"miniprogramRoot": "./dist/weapp",
"projectname": "bw-mini-app",
"description": "图生图 风格转换 ",
"appid": "wxb51f0b0c3aad7cdf",
"setting": {
"urlCheck": true,
"es6": true,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}

View File

@@ -1,7 +0,0 @@
{
"setting": {
"urlCheck": true,
"bigPackageSizeSupport": false,
"compileHotReLoad": true
}
}

View File

@@ -1,5 +0,0 @@
{
"miniprogramRoot": "./dist/tt",
"projectname": "bw-mini-app",
"appid": "ttbfd9c96420ec8f8201"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@@ -0,0 +1 @@
# 3D动画缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 动画效果缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 艺术风格缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 卡通风格缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 素描风格缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 视频生成缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 复古滤镜缩略图 - 需要替换为实际图片

View File

@@ -0,0 +1 @@
# 水彩画缩略图 - 需要替换为实际图片

View File

@@ -1,4 +1,4 @@
import Taro from '@tarojs/taro'
// 移除Taro依赖使用Web API
import { Dispatch } from 'redux'
import { HistoryRecord } from '../store/types'
import * as types from '../constants/history'
@@ -84,7 +84,7 @@ export const loadRecords = () => {
return async (dispatch: Dispatch) => {
dispatch(loadRecordsRequest())
try {
const savedRecords = Taro.getStorageSync(STORAGE_KEY) || []
const savedRecords = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
dispatch(loadRecordsSuccess(savedRecords))
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '加载历史记录失败'
@@ -100,7 +100,7 @@ export const addRecord = (recordData: Omit<HistoryRecord, 'id' | 'createTime' |
try {
const id = `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const now = new Date().toISOString()
const newRecord: HistoryRecord = {
...recordData,
id,
@@ -110,12 +110,9 @@ export const addRecord = (recordData: Omit<HistoryRecord, 'id' | 'createTime' |
const currentRecords = getState().history.records
const updatedRecords = [newRecord, ...currentRecords]
await Taro.setStorage({
key: STORAGE_KEY,
data: updatedRecords
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedRecords))
dispatch(addRecordSuccess(newRecord))
return id
} catch (error) {
@@ -133,7 +130,7 @@ export const updateRecord = (id: string, updates: Partial<HistoryRecord>) => {
try {
const currentRecords = getState().history.records
const recordIndex = currentRecords.findIndex((record: HistoryRecord) => record.id === id)
if (recordIndex === -1) {
throw new Error('记录不存在')
}
@@ -147,10 +144,7 @@ export const updateRecord = (id: string, updates: Partial<HistoryRecord>) => {
const updatedRecords = [...currentRecords]
updatedRecords[recordIndex] = updatedRecord
await Taro.setStorage({
key: STORAGE_KEY,
data: updatedRecords
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedRecords))
dispatch(updateRecordSuccess(updatedRecord))
} catch (error) {
@@ -169,10 +163,7 @@ export const deleteRecord = (id: string) => {
const currentRecords = getState().history.records
const updatedRecords = currentRecords.filter((record: HistoryRecord) => record.id !== id)
await Taro.setStorage({
key: STORAGE_KEY,
data: updatedRecords
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedRecords))
dispatch(deleteRecordSuccess(id))
} catch (error) {
@@ -188,7 +179,7 @@ export const clearRecords = () => {
return async (dispatch: Dispatch) => {
dispatch(clearRecordsRequest())
try {
await Taro.removeStorage({ key: STORAGE_KEY })
localStorage.removeItem(STORAGE_KEY)
dispatch(clearRecordsSuccess())
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '清空记录失败'

View File

@@ -1,41 +0,0 @@
// 注意由于小程序的限制tabBar文本和页面标题需要在运行时动态设置
// 这里保留中文作为默认值,实际的国际化会在运行时处理
export default defineAppConfig({
pages: [
'pages/home/index', // 新的模板卡片首页
'pages/history/index', // 历史记录页面
'pages/friends-photo/index', // 好友合照页面
'pages/result/index',
],
tabBar: {
color: '#8E9BAE',
selectedColor: '#1D1F22',
backgroundColor: '#FFFFFF',
borderStyle: 'black',
list: [
{
pagePath: 'pages/home/index',
text: '游乐场', // 运行时会被替换为对应语言
iconPath: './assets/icons/playground.png',
selectedIconPath: './assets/icons/playground-selected.png',
},
{
pagePath: 'pages/history/index',
text: '我的', // 运行时会被替换为对应语言
iconPath: './assets/icons/user.png',
selectedIconPath: './assets/icons/user-selected.png',
},
],
},
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '图生视频', // 运行时会被替换为对应语言
navigationBarTextStyle: 'black',
},
permission: {
'scope.album': {
desc: '用于保存生成的图片和视频到相册', // 运行时会被替换为对应语言
},
},
});

View File

@@ -1,4 +1,66 @@
#app{
width: 100vw;
height: 100vh;
}
/* 全局样式 */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
height: 100%;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-content {
flex: 1;
overflow: hidden;
padding-bottom: 60px; /* 为底部导航留出空间 */
}
/* 加载状态样式 */
.app-loading {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
.loading-container {
text-align: center;
padding: 2rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e5e5;
border-top: 4px solid #000;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
.loading-text {
color: #666;
font-size: 14px;
margin-top: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -1,48 +1,97 @@
import { PropsWithChildren } from 'react'
import { useLaunch } from '@tarojs/taro'
import { Provider } from 'react-redux'
import configStore from './store'
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { authService } from './services';
import { initLanguageFromStorage } from './i18n/utils';
import { i18nManager } from './i18n/manager';
import './app.css'
import { createPlatformFactory } from './platforms'
import { initLanguageFromStorage } from './i18n/utils'
import { i18nManager } from './i18n/manager'
// Page components
import Home from './pages/home';
import History from './pages/history';
import FriendsPhoto from './pages/friends-photo';
import Result from './pages/result';
const store = configStore()
// Bottom navigation component
import BottomNavigation from './components/BottomNavigation';
function App({ children }: PropsWithChildren<any>) {
useLaunch(async () => {
// 初始化国际化
try {
await initLanguageFromStorage()
await i18nManager.initializeApp()
} catch (error) {
console.error('i18n初始化失败:', error)
}
import './app.css';
const authorize = createPlatformFactory().createAuthorize()
// const payment = createPlatformFactory().createPayment()
try {
// 检查登录状态包括OAuth 2.0回调处理
const isLoggedIn = await authorize.checkLogin()
if (!isLoggedIn) {
// 可以根据需要决定是否自动跳转登录
await authorize.login()
function App() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const location = useLocation();
// Pages that don't need to show bottom navigation
const hideBottomNavPages = ['/friends-photo', '/result'];
useEffect(() => {
const initApp = async () => {
try {
// Initialize internationalization
await initLanguageFromStorage();
await i18nManager.initializeApp();
// Check login status, including OAuth 2.0 callback handling
const isLoggedIn = await authService.checkLogin();
setIsAuthenticated(isLoggedIn);
if (!isLoggedIn) {
console.log('User not logged in, preparing to redirect to login page');
// Delay a bit before redirecting to let user see loading state
setTimeout(() => {
authService.login();
}, 1000);
} else {
console.log('User already logged in');
}
} catch (error) {
console.error('Application initialization failed:', error);
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
// const result = await payment.pay(`character_figurine_v1`, ``)
// console.log({ result })
} catch (error) {
console.error('登录检查失败:', error)
}
})
};
initApp();
}, []);
// Show loading state
if (isLoading) {
return (
<div className="app-loading">
<div className="loading-container">
<div className="loading-spinner"></div>
<div className="loading-text">Initializing application...</div>
</div>
</div>
);
}
// If not authenticated, show login prompt
if (isAuthenticated === false) {
return (
<div className="app-loading">
<div className="loading-container">
<div className="loading-spinner"></div>
<div className="loading-text">Redirecting to login page...</div>
</div>
</div>
);
}
return (
<Provider store={store}>
{children}
</Provider>
)
<div className="app">
<div className="app-content">
<Routes>
<Route path="/" element={<Navigate to="/home" replace />} />
<Route path="/home" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/friends-photo" element={<FriendsPhoto />} />
<Route path="/result" element={<Result />} />
</Routes>
</div>
{!hideBottomNavPages.includes(location.pathname) && <BottomNavigation />}
</div>
);
}
export default App
export default App;

View File

@@ -0,0 +1,46 @@
.bottom-navigation {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background-color: #fff;
border-top: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-around;
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-item:hover {
background-color: #f5f5f5;
}
.tab-icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
.tab-text {
font-size: 12px;
color: #8E9BAE;
transition: color 0.2s ease;
}
.tab-item.active .tab-text {
color: #1D1F22;
font-weight: 500;
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useI18n } from '../../hooks/useI18n';
import './index.css';
const BottomNavigation: React.FC = () => {
const { t } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const tabs = [
{
path: '/home',
text: t('navigation.playground'),
icon: '/assets/icons/playground.png',
selectedIcon: '/assets/icons/playground-selected.png',
},
{
path: '/history',
text: t('navigation.mine'),
icon: '/assets/icons/user.png',
selectedIcon: '/assets/icons/user-selected.png',
},
];
const handleTabClick = (path: string) => {
navigate(path);
};
return (
<div className="bottom-navigation">
{tabs.map((tab) => {
const isActive = location.pathname === tab.path;
return (
<div
key={tab.path}
className={`tab-item ${isActive ? 'active' : ''}`}
onClick={() => handleTabClick(tab.path)}
>
<img
className="tab-icon"
src={isActive ? tab.selectedIcon : tab.icon}
alt={tab.text}
/>
<span className="tab-text">{tab.text}</span>
</div>
);
})}
</div>
);
};
export default BottomNavigation;

View File

@@ -2,9 +2,7 @@
* 语言切换组件
*/
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';

View File

@@ -1,25 +1,21 @@
import { View, Text } from '@tarojs/components';
import { useI18n } from '../../hooks/useI18n';
import './index.css';
interface LoadingOverlayProps {
children?: React.ReactNode;
visible: boolean;
text?: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ children }) => {
const { t } = useI18n();
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
visible,
text = '加载中...'
}) => {
if (!visible) return null;
return (
<View className="loading-overlay">
<View className="loading-container">
<View className="loading-spinner" />
<View className="loading-text">
<Text className="loading-title">{t('loading.aiGenerating')}</Text>
<Text className="loading-desc">{t('loading.pleaseWaitDesc')}</Text>
</View>
{children}
</View>
</View>
<div className="loading-overlay">
<div className="loading-content">
<div className="loading-spinner"></div>
<div className="loading-text">{text}</div>
</div>
</div>
);
};

View File

@@ -24,17 +24,17 @@
position: relative;
padding: 0;
flex: 1;
min-height: 480px;
border-radius: 32px;
min-height: 280px;
border-radius: 16px;
}
.merged-image-container {
position: relative;
border-radius: 32px;
border-radius: 16px;
overflow: hidden;
width: 100%;
height: 100%;
min-height: 480px;
min-height: 280px;
display: block;
}
@@ -44,7 +44,7 @@
left: 0;
width: 100%;
height: 100%;
min-height: 480px;
min-height: 280px;
}
.overlay-layer {
@@ -56,7 +56,7 @@
.full-image {
width: 100%;
height: 100%;
min-height: 480px;
min-height: 280px;
object-fit: cover;
display: block;
transform: translateZ(0);
@@ -139,7 +139,7 @@
background: #0000004d;
backdrop-filter: blur(5px);
color: #fff;
font-size: 24px;
font-size: 11px;
font-weight: 500;
border-radius: 100px;
border: 1px solid #ffffff4d;
@@ -147,7 +147,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 12px 16px;
padding: 8px 12px;
text-align: center;
}
@@ -157,18 +157,21 @@
border-radius: 16px;
overflow: hidden;
width: 100%;
min-height: 480px;
min-height: 280px;
display: block;
}
.single-video {
width: 100%;
height: 100%;
min-height: 480px;
min-height: 280px;
object-fit: cover;
object-position: center;
display: block;
border-radius: 16px;
position: relative;
position: absolute;
top: 0;
left: 0;
z-index: 2;
transform: translateZ(0);
backface-visibility: hidden;
@@ -183,6 +186,7 @@
height: 100%;
z-index: 1;
object-fit: cover;
object-position: center;
border-radius: 16px;
}
@@ -190,16 +194,20 @@
.full-video {
width: 100%;
height: 100%;
min-height: 480px;
min-height: 280px;
object-fit: cover;
object-position: center;
display: block;
position: absolute;
top: 0;
left: 0;
transform: translateZ(0);
backface-visibility: hidden;
}
.watermark-text {
color: rgb(255 255 255 / 50%);
font-size: 20px;
font-size: 11px;
font-weight: 400;
text-align: center;
}

View File

@@ -1,6 +1,4 @@
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';
@@ -37,16 +35,12 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
// 获取容器信息
const getContainerInfo = () => {
return new Promise(resolve => {
const query = Taro.createSelectorQuery();
query
.select(`#${containerId}`)
.boundingClientRect(rect => {
if (rect) {
setContainerInfo(rect);
resolve(rect);
}
})
.exec();
const element = document.getElementById(containerId);
if (element) {
const rect = element.getBoundingClientRect();
setContainerInfo(rect);
resolve(rect);
}
});
};
@@ -83,89 +77,69 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
};
return (
<View className="template-card" onClick={handleClick}>
<div className="template-card" onClick={handleClick}>
{/* 根据output类型显示不同的内容 */}
{isOutputVideo ? (
// 当output是视频时只显示单个视频
<View className="single-video-container">
<Image className="video-poster" src={template.inputExampleUrl || ``} mode="aspectFill" />
<Video
className="single-video"
src={template.outputExampleUrl || ``}
autoplay
muted
loop
objectFit="cover"
showPlayBtn={false}
showCenterPlayBtn={false}
showFullscreenBtn={false}
controls={false}
/>
<div className="single-video-container">
<img className="video-poster" src={template.inputExampleUrl || ``} alt="poster" />
<video className="single-video" src={template.outputExampleUrl || ``} autoPlay muted loop style={{ objectFit: 'cover' }} controls={false} />
{/* 模板名称悬浮 - 视频底部 */}
<View className="name-overlay">
<Text className="name-badge">{template.name}</Text>
</View>
<View className="watermark">
<Text className="watermark-text">{t('templates.aiGeneratedContent')}</Text>
</View>
</View>
<div className="name-overlay">
<span className="name-badge">{template.name}</span>
</div>
</div>
) : (
// 原有的图片对比逻辑
<View className="image-comparison">
<View id={containerId} className="merged-image-container" ref={containerRef} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
<div className="image-comparison">
<div id={containerId} className="merged-image-container" ref={containerRef} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
{/* 原图层 - 完整图片/视频 */}
<View className="image-layer">
<div className="image-layer">
{isInputVideo ? (
<Video
<video
className="full-video"
src={template.inputExampleUrl || ``}
autoplay
autoPlay
muted
loop
objectFit="cover"
showPlayBtn={false}
showCenterPlayBtn={false}
showFullscreenBtn={false}
style={{ objectFit: 'cover' }}
controls={false}
/>
) : (
<Image className="full-image" src={template.inputExampleUrl || ``} mode="aspectFill" lazyLoad />
<img className="full-image" src={template.inputExampleUrl || ``} alt="input" loading="lazy" />
)}
</View>
</div>
{/* 效果图层 - 完整图片,通过遮罩显示右半部分 */}
<View
<div
className="image-layer overlay-layer"
style={{
clipPath: `polygon(${splitPosition}% 0%, 100% 0%, 100% 100%, ${splitPosition}% 100%)`,
}}
>
<Image className="full-image" src={template.outputExampleUrl || ``} mode="aspectFill" lazyLoad />
</View>
<img className="full-image" src={template.outputExampleUrl || ``} alt="output" loading="lazy" />
</div>
{/* 可拖拽的分割线 */}
<View
<div
className="split-line"
style={{ left: `${splitPosition}%` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<View className="split-handle">
<Text className="split-icon"></Text>
</View>
</View>
<div className="split-handle">
<span className="split-icon"></span>
</div>
</div>
{/* 模板名称悬浮 - 图片底部 */}
<View className="name-overlay">
<Text className="name-badge">{template.name}</Text>
</View>
<View className="watermark">
<Text className="watermark-text">{t('templates.aiGeneratedContent')}</Text>
</View>
</View>
</View>
<div className="name-overlay">
<span className="name-badge">{template.name}</span>
</div>
</div>
</div>
)}
</View>
</div>
);
}

View File

@@ -0,0 +1,85 @@
.user-info {
display: flex;
align-items: center;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.user-info-loading {
padding: 1rem;
text-align: center;
color: #666;
font-size: 14px;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
margin-right: 1rem;
flex-shrink: 0;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: #007aff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 14px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.logout-button {
background: #ff3b30;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.logout-button:hover {
background: #d70015;
}
.logout-button:active {
transform: scale(0.98);
}

View File

@@ -0,0 +1,67 @@
import { useState, useEffect } from 'react';
import { authService } from '../../services';
import './index.css';
interface UserInfoProps {
className?: string;
}
export default function UserInfo({ className }: UserInfoProps) {
const [userInfo, setUserInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadUserInfo = async () => {
try {
const info = await authService.getUserInfo();
setUserInfo(info);
} catch (error) {
console.error('获取用户信息失败:', error);
} finally {
setLoading(false);
}
};
loadUserInfo();
}, []);
const handleLogout = async () => {
if (confirm('确定要退出登录吗?')) {
await authService.logout();
window.location.reload();
}
};
if (loading) {
return (
<div className={`user-info ${className || ''}`}>
<div className="user-info-loading">...</div>
</div>
);
}
if (!userInfo) {
return null;
}
return (
<div className={`user-info ${className || ''}`}>
<div className="user-avatar">
{userInfo.picture ? (
<img src={userInfo.picture} alt="用户头像" />
) : (
<div className="avatar-placeholder">
{userInfo.name?.charAt(0) || 'U'}
</div>
)}
</div>
<div className="user-details">
<div className="user-name">{userInfo.name || '未知用户'}</div>
<div className="user-email">{userInfo.email || ''}</div>
</div>
<button className="logout-button" onClick={handleLogout}>
退
</button>
</div>
);
}

View File

@@ -2,5 +2,6 @@
export { useAd } from './useAd'
export { useSdk, useServerSdk } from './useSdk'
export { useUploadVideo } from './useUploadVideo'
// 暂时注释掉有Taro依赖的hook
// export { useUploadVideo } from './useUploadVideo'
export { useI18n } from './useI18n'

View File

@@ -1,5 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { createPlatformFactory, RewardedVideoAd, RewardedVideoCloseCb, RewardedVideoErrorCb } from "../platforms";
import { useState, useCallback } from 'react';
// 广告奖励回调函数类型
type AdRewardCallback = () => void;
@@ -17,187 +16,29 @@ interface UseAdOptions {
onReward?: AdRewardCallback; // 观看完整广告后的奖励回调
onClose?: (isEnded: boolean) => void; // 广告关闭回调,传入是否完整观看
}
// 创建平台广告实例
// H5版本的广告Hook - 不需要广告功能,直接给予奖励
export function useAd(options?: UseAdOptions): UseAdReturn {
const factory = createPlatformFactory()
const [loading, setLoading] = useState(true);
const [adAvailable, setAdAvailable] = useState(false);
const [adLoaded, setAdLoaded] = useState(false); // 广告是否已加载完成
const adRef = useRef<RewardedVideoAd | null>(null);
const platform = factory.getPlatform()
const [loading, setLoading] = useState(false);
const [adAvailable] = useState(false); // H5版本不支持广告
// 检查当前平台是否支持广告
const isAdSupportedPlatform = platform === 'bytedance';
useEffect(() => {
// 如果当前平台不支持广告,直接设置为不可用状态
if (!isAdSupportedPlatform) {
console.log(`当前平台 ${platform} 未开通广告,跳过广告初始化`);
setAdAvailable(false);
setLoading(false);
return;
}
try {
adRef.current = factory.createRewardedVideoAd();
const ad = adRef.current!;
// 检查广告是否可用
if (ad.canUse && !ad.canUse()) {
console.warn('广告功能不可用,可能是流量主未开通');
setAdAvailable(false);
setLoading(false);
return () => { }; // 返回空的清理函数
}
setAdAvailable(true);
// 广告关闭回调处理
const onClose: RewardedVideoCloseCb = (res) => {
console.log('广告关闭:', res);
setLoading(false);
// 广告播放后需要重新加载
setAdLoaded(false);
if (adRef.current) {
console.log('广告播放结束,重新加载下一个广告');
adRef.current.load();
}
// 判断用户是否完成播放
const isEnded = Boolean(res?.isEnded);
if (isEnded) {
// 播放完成的业务逻辑
console.log('用户观看完整广告,给予奖励');
// 执行奖励回调
options?.onReward?.();
// 可以在这里处理以下业务:
// 1. 发放奖励(积分、道具等)
// 2. 解锁功能或内容
// 3. 统计完成观看数据
// 4. 触发下一步操作
} else {
// 未完成播放的处理
console.log('用户未完整观看广告');
// 可以处理以下场景:
// 1. 提示用户观看完整广告才能获得奖励
// 2. 记录未完成播放的统计
// 3. 可能的重试提示
}
// 执行关闭回调,传入是否完整观看
options?.onClose?.(isEnded);
}
// 广告加载错误回调处理
const onError: RewardedVideoErrorCb = (res) => {
console.error('广告错误:', res);
setLoading(false);
setAdLoaded(false); // 加载失败,重置加载状态
// 根据错误类型判断广告是否不可用
const errorCode = res?.errCode;
if (errorCode && ['1004', '1008', '1009', 1004, 1008, 1009].includes(errorCode)) {
console.warn('广告服务不可用,可能是流量主未开通或被暂停');
setAdAvailable(false);
}
}
// 广告加载成功回调处理
const onLoad = () => {
console.log('广告加载成功');
setLoading(false);
setAdLoaded(true); // 标记广告已加载完成
}
// 绑定广告事件监听
ad.onLoad(onLoad);
ad.onClose(onClose);
ad.onError(onError);
// 立即预加载广告
console.log('开始预加载广告');
ad.load();
// 清理函数:组件卸载时移除事件监听和销毁广告实例
return () => {
if (adRef.current) {
adRef.current.offClose(onClose)
adRef.current.offError(onError)
adRef.current.offLoad(onLoad)
adRef.current.destroy();
}
};
} catch (error) {
console.error('初始化广告失败:', error);
setAdAvailable(false);
setLoading(false);
return () => { }; // 返回空的清理函数
}
}, [options, isAdSupportedPlatform, platform]);
// 显示广告方法
// 显示广告方法 - H5版本直接给予奖励
const showAd = useCallback(() => {
// 如果当前平台不支持广告,直接给予奖励并触发关闭回调
if (!isAdSupportedPlatform) {
console.log(`当前平台 ${platform} 未开通广告,直接给予奖励`);
console.log('H5版本不支持广告,直接给予奖励');
setLoading(true);
// 模拟广告播放时间
setTimeout(() => {
setLoading(false);
options?.onReward?.();
options?.onClose?.(true); // 模拟广告播放完成
return;
}
}, 1000);
}, [options]);
if (!adAvailable) {
console.warn('广告不可用,跳过广告播放');
// 如果广告不可用,直接给予奖励
options?.onReward?.();
options?.onClose?.(true); // 模拟广告播放完成
return;
}
if (!adLoaded) {
console.warn('广告尚未加载完成,请稍等');
// 广告还在加载中,先尝试重新加载
if (adRef.current) {
console.log('重新加载广告');
adRef.current.load();
setLoading(true);
}
return;
}
if (adRef.current) {
setLoading(true);
try {
adRef.current.show();
} catch (error) {
console.error('显示广告失败:', error);
setLoading(false);
// 如果显示失败,也给予奖励作为降级处理
options?.onReward?.();
options?.onClose?.(true); // 模拟广告播放完成
}
}
}, [adAvailable, adLoaded, options, isAdSupportedPlatform, platform]);
// 加载广告方法
// 加载广告方法 - H5版本无需实际加载
const loadAd = useCallback(() => {
// 如果当前平台不支持广告,直接跳过加载
if (!isAdSupportedPlatform) {
console.log(`当前平台 ${platform} 未开通广告,跳过广告加载`);
return;
}
if (adRef.current) {
setLoading(true);
setAdLoaded(false); // 重置加载状态
console.log('手动加载广告');
adRef.current.load();
}
}, [isAdSupportedPlatform, platform]);
console.log('H5版本不需要加载广告');
}, []);
return {
showAd,

View File

@@ -1,11 +1,10 @@
import { SdkServer } from "../sdk/sdk-server";
import { bowongAI } from "../sdk/bowongAISDK";
export function useSdk() {
return bowongAI;
// H5版本暂时返回空对象广告相关功能不需要
return {};
}
export function useServerSdk(){
return new SdkServer()
}
return new SdkServer();
}

View File

@@ -97,9 +97,6 @@ export interface LanguageResources {
generating: string;
startGenerating: string;
};
templates: {
aiGeneratedContent: string;
};
loading: {
aiGenerating: string;
pleaseWaitDesc: string;

Some files were not shown because too many files have changed in this diff Show More