feat: 添加图标

This commit is contained in:
imeepos
2025-09-03 15:57:27 +08:00
parent 353e0ee6dc
commit c8480308e0
93 changed files with 1503 additions and 12 deletions

243
TODO.md Normal file
View File

@@ -0,0 +1,243 @@
# 小程序重构开发计划
## 项目概述
将现有的图生图/图生视频小程序重构为模板卡片式首页 + 历史记录页面的架构。
## 开发阶段规划
### 阶段一:项目架构重构 (优先级:高)
#### 1.1 导航结构调整
- [ ] 修改 `app.config.ts` 添加tabBar配置
- 首页 (pages/home/index)
- 历史记录 (pages/history/index)
- [ ] 移除原有的 result 页面跳转逻辑
- [ ] 更新页面路由配置
#### 1.2 页面文件结构创建
```
src/pages/
├── home/ # 新首页 (模板卡片)
│ ├── index.tsx
│ ├── index.config.ts
│ └── index.css
├── history/ # 历史记录页面
│ ├── index.tsx
│ ├── index.config.ts
│ └── index.css
├── generate/ # 生成处理页面 (原index页面重构)
│ ├── index.tsx
│ ├── index.config.ts
│ └── index.css
└── result/ # 结果页面 (保留并优化)
├── index.tsx
├── index.config.ts
└── index.css
```
### 阶段二:数据存储和状态管理 (优先级:高)
#### 2.1 本地存储设计
- [ ] 设计历史记录数据结构
```typescript
interface HistoryRecord {
id: string
type: 'image-to-image' | 'image-to-video'
templateName: string
inputImage: string
outputResult: string | string[]
status: 'generating' | 'completed' | 'failed'
createTime: string
updateTime: string
}
```
#### 2.2 状态管理
- [ ] 创建 `src/store/` 目录
- [ ] 实现 Zustand 状态管理
- [ ] `src/store/historyStore.ts` - 历史记录管理
- [ ] `src/store/templateStore.ts` - 模板配置管理
- [ ] 添加数据持久化功能
### 阶段三:模板卡片首页开发 (优先级:高)
#### 3.1 模板配置和数据
- [ ] 设计8个模板的配置数据
```typescript
interface Template {
id: string
name: string
description: string
type: 'image-to-image' | 'image-to-video'
thumbnail: string
category: string
}
```
- [ ] 添加模板缩略图资源
- [ ] 创建模板配置文件 `src/config/templates.ts`
#### 3.2 首页组件开发
- [ ] 创建模板卡片组件 `src/components/TemplateCard/`
- [ ] 支持2列网格布局
- [ ] 卡片hover效果
- [ ] 模板名称和描述显示
- [ ] 实现首页布局 `src/pages/home/index.tsx`
- [ ] 2列4行网格布局
- [ ] 模板卡片点击跳转到生成页面
- [ ] 添加首页样式 `src/pages/home/index.css`
### 阶段四:生成页面重构 (优先级:中)
#### 4.1 原index页面重构
- [ ]`src/pages/index/` 重命名为 `src/pages/generate/`
- [ ] 修改生成页面接收模板参数
```typescript
// 从路由参数获取模板信息
interface GeneratePageParams {
templateId: string
templateName: string
templateType: 'image-to-image' | 'image-to-video'
}
```
#### 4.2 生成流程优化
- [ ] 添加模板信息显示
- [ ] 优化上传和生成流程
- [ ] 添加生成进度到历史记录
- [ ] 生成完成后更新历史记录状态
### 阶段五:历史记录页面开发 (优先级:中)
#### 5.1 历史记录页面布局
- [ ] 创建历史记录列表组件 `src/components/HistoryList/`
- [ ] 实现记录卡片组件 `src/components/HistoryCard/`
- [ ] 显示模板名称、生成时间
- [ ] 生成状态指示器
- [ ] 结果预览图
#### 5.2 历史记录功能
- [ ] 实现历史记录分页加载
- [ ] 添加筛选功能 (按类型、状态)
- [ ] 添加删除记录功能
- [ ] 点击记录查看详细结果
### 阶段六:组件优化和重构 (优先级:中)
#### 6.1 现有组件适配
- [ ] 优化 `UploadButton` 组件
- [ ] 适配不同模板类型
- [ ] 添加模板上下文信息
- [ ] 优化 `LoadingOverlay` 组件
- [ ] 显示当前模板信息
- [ ] 显示生成进度百分比
- [ ] 优化 `ErrorOverlay` 组件
- [ ] 提供重试和返回首页选项
#### 6.2 新增通用组件
- [ ] 创建 `NavigationBar` 组件 (如需要自定义导航)
- [ ] 创建 `StatusBadge` 组件 (状态指示器)
- [ ] 创建 `ImagePreview` 组件 (图片预览)
### 阶段七:用户体验优化 (优先级:低)
#### 7.1 交互优化
- [ ] 添加页面间过渡动画
- [ ] 添加加载骨架屏
- [ ] 优化触摸反馈
- [ ] 添加操作确认对话框
#### 7.2 性能优化
- [ ] 实现图片懒加载
- [ ] 添加历史记录虚拟滚动
- [ ] 优化打包体积
- [ ] 添加缓存策略
### 阶段八:测试和文档 (优先级:低)
#### 8.1 单元测试 (遵循TDD原则)
- [ ] 为历史记录Store添加测试
- [ ] 为模板配置添加测试
- [ ] 为通用工具函数添加测试
- [ ] 为核心业务逻辑添加测试
#### 8.2 集成测试
- [ ] 测试页面跳转流程
- [ ] 测试数据持久化
- [ ] 测试错误处理
## 技术实现细节
### 路由配置更新
```typescript
// app.config.ts
export default defineAppConfig({
pages: [
'pages/home/index', // 新首页
'pages/generate/index', // 生成页面
'pages/result/index', // 结果页面
'pages/history/index' // 历史记录页面
],
tabBar: {
list: [
{
pagePath: 'pages/home/index',
text: '首页',
iconPath: 'assets/icons/home.png',
selectedIconPath: 'assets/icons/home-active.png'
},
{
pagePath: 'pages/history/index',
text: '历史记录',
iconPath: 'assets/icons/history.png',
selectedIconPath: 'assets/icons/history-active.png'
}
]
},
// ...其他配置
})
```
### 数据流设计
```
首页模板选择 → 生成页面(携带模板信息) → 生成过程记录到历史 → 完成后更新历史状态 → 历史页面展示
```
## 开发优先级说明
### 高优先级 (第1-3周)
- 导航结构和基础页面创建
- 数据存储结构设计
- 模板卡片首页实现
### 中优先级 (第4-6周)
- 生成页面重构
- 历史记录页面开发
- 组件优化
### 低优先级 (第7-8周)
- 用户体验优化
- 性能优化
- 测试覆盖
## 里程碑检查点
1. **Week 2**: 基础页面结构和导航完成
2. **Week 4**: 模板首页和基础功能完成
3. **Week 6**: 历史记录功能完整实现
4. **Week 8**: 完整功能测试和优化完成
## 注意事项
1. **遵循TDD开发模式** - 先写测试,后写实现
2. **保持代码简洁** - 每个文件不超过500行
3. **类型安全** - 严格使用TypeScript类型定义
4. **平台兼容** - 确保微信小程序、H5、APP多端兼容
5. **用户体验** - 保持流畅的交互体验
6. **错误处理** - 完善的错误边界和用户反馈
## 依赖和资源需求
- **图标资源**: tabBar图标、模板缩略图
- **样式主题**: 统一的设计风格和颜色规范
- **API适配**: 确保现有SDK接口兼容新的页面流程
- **存储方案**: 本地存储和可能的云端同步需求

305
mini-app.md Normal file
View File

@@ -0,0 +1,305 @@
# Taro 抖音小程序发布视频解决方案
## 概述
抖音小程序提供了官方的视频发布能力,允许用户通过小程序直接拍摄并发布视频到抖音。在 Taro 框架中,我们需要通过特定的方式来调用这个官方 API。
## 核心原理
抖音小程序的视频发布功能基于两个核心要素:
1. **Button 组件**:设置 `open-type="uploadDouyinVideo"`
2. **Page 钩子**:在页面中配置 `onUploadDouyinVideo` 钩子函数
## 实现方案
### 方案一:函数组件 + useReady Hook推荐
```typescript
import React, { useState } from 'react';
import { View, Button, Text } from '@tarojs/components';
import Taro, { useReady } from '@tarojs/taro';
const VideoPublishPage: React.FC = () => {
const [uploadStatus, setUploadStatus] = useState<string>('');
useReady(() => {
// 获取当前页面实例
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
// 配置抖音视频上传钩子
currentPage.onUploadDouyinVideo = function(uploadOptions) {
console.log('抖音视频上传参数:', uploadOptions);
setUploadStatus('正在处理视频...');
// 处理上传结果
if (uploadOptions.errMsg) {
if (uploadOptions.errMsg.includes('ok')) {
setUploadStatus('视频发布成功!');
Taro.showToast({
title: '发布成功',
icon: 'success'
});
} else {
setUploadStatus('视频发布失败');
Taro.showToast({
title: '发布失败',
icon: 'error'
});
}
}
};
});
return (
<View className="video-publish-container">
<View className="header">
<Text className="title"></Text>
</View>
<View className="content">
{/* 关键Button 组件设置 openType */}
<Button
openType="uploadDouyinVideo"
className="upload-button"
type="primary"
>
</Button>
{uploadStatus && (
<View className="status">
<Text>{uploadStatus}</Text>
</View>
)}
</View>
</View>
);
};
export default VideoPublishPage;
```
### 方案二:类组件实现
```typescript
import { Component } from 'react';
import { View, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
class VideoPublishPage extends Component {
onReady() {
// 在页面 onReady 生命周期中配置钩子
this.$instance.page.onUploadDouyinVideo = (uploadOptions) => {
console.log('抖音视频上传参数:', uploadOptions);
this.handleVideoUpload(uploadOptions);
};
}
handleVideoUpload = (uploadOptions: any) => {
console.log('处理视频上传:', uploadOptions);
Taro.showToast({
title: '视频发布中...',
icon: 'loading'
});
};
render() {
return (
<View className="video-publish-page">
<Button openType="uploadDouyinVideo">
</Button>
</View>
);
}
}
export default VideoPublishPage;
```
### 方案三:自定义 Hook 封装
```typescript
// hooks/useUploadDouyinVideo.ts
import { useReady } from '@tarojs/taro';
import Taro from '@tarojs/taro';
interface UploadOptions {
errMsg?: string;
[key: string]: any;
}
export const useUploadDouyinVideo = (
onUpload: (options: UploadOptions) => void
) => {
useReady(() => {
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage) {
currentPage.onUploadDouyinVideo = onUpload;
}
});
};
// 使用示例
import React from 'react';
import { View, Button } from '@tarojs/components';
import { useUploadDouyinVideo } from '../hooks/useUploadDouyinVideo';
const VideoPublishPage: React.FC = () => {
useUploadDouyinVideo((uploadOptions) => {
console.log('视频上传参数:', uploadOptions);
// 处理上传逻辑
});
return (
<View>
<Button openType="uploadDouyinVideo">
</Button>
</View>
);
};
```
## 配置要求
### 1. app.config.ts 配置
```typescript
export default {
pages: [
'pages/video-publish/index'
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '抖音视频发布',
navigationBarTextStyle: 'black'
},
// 抖音小程序权限配置
permission: {
'scope.camera': {
desc: '需要使用摄像头拍摄视频'
},
'scope.writePhotosAlbum': {
desc: '需要保存视频到相册'
}
}
}
```
### 2. 样式配置
```scss
// video-publish/index.scss
.video-publish-container {
padding: 40px 32px;
.header {
text-align: center;
margin-bottom: 60px;
.title {
font-size: 36px;
font-weight: bold;
color: #333;
}
}
.content {
.upload-button {
width: 100%;
height: 88px;
font-size: 32px;
border-radius: 12px;
margin-bottom: 40px;
}
.status {
text-align: center;
padding: 20px;
background-color: #f5f5f5;
border-radius: 8px;
text {
color: #666;
font-size: 28px;
}
}
}
}
```
## 关键要点
### 1. 必须配置的两个要素
- **Button 组件**`openType="uploadDouyinVideo"`
- **钩子函数**`onUploadDouyinVideo`
### 2. 钩子函数配置时机
- 必须在页面的 `onReady` 生命周期或之后配置
- 函数组件使用 `useReady` Hook
- 类组件在 `onReady` 方法中配置
### 3. 参数说明
```typescript
// uploadOptions 参数结构
interface UploadOptions {
errMsg: string; // 上传结果信息
// 其他抖音官方返回的参数
}
```
## 故障排除
### 问题1钩子函数不触发
**解决方案**:确保在正确的时机配置钩子
```typescript
useReady(() => {
// 延迟配置,确保页面完全加载
setTimeout(() => {
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage) {
currentPage.onUploadDouyinVideo = function(uploadOptions) {
console.log('视频上传回调:', uploadOptions);
};
}
}, 100);
});
```
### 问题2Button 点击无反应
**检查清单**
- [ ] `openType` 属性是否正确设置为 `"uploadDouyinVideo"`
- [ ] 是否在真实的抖音小程序环境中测试
- [ ] 权限配置是否正确
### 问题3Taro 版本兼容性
**建议**
- 使用 Taro 3.x 以上版本
- 确保抖音小程序基础库版本支持该功能
## 注意事项
1. **测试环境**:该功能需要在真实的抖音小程序环境中测试,开发工具可能无法完全模拟
2. **权限申请**:需要在小程序后台申请相应的视频发布权限
3. **用户体验**:建议添加加载状态和错误处理
4. **版本兼容**:确保 Taro 版本和抖音小程序基础库版本的兼容性
## 参考资料
- [抖音小程序官方文档 - 发布抖音视频](https://developer.open-douyin.com/docs/resource/zh-CN/mini-app/develop/api/open-interface/video-capacity/upload-douyin-video)
- [Taro GitHub Issue #13882](https://github.com/NervJS/taro/issues/13882)

View File

@@ -67,7 +67,8 @@
"@tarojs/shared": "4.1.6",
"@tarojs/taro": "4.1.6",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react-dom": "^18.0.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@babel/core": "^7.24.4",

26
pnpm-lock.yaml generated
View File

@@ -62,6 +62,9 @@ importers:
react-dom:
specifier: ^18.0.0
version: 18.3.1(react@18.3.1)
zustand:
specifier: ^5.0.8
version: 5.0.8(@types/react@18.3.24)(react@18.3.1)
devDependencies:
'@babel/core':
specifier: ^7.24.4
@@ -6143,6 +6146,24 @@ packages:
yup@1.7.0:
resolution: {integrity: sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==}
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@adobe/css-tools@4.4.4': {}
@@ -13069,3 +13090,8 @@ snapshots:
tiny-case: 1.0.3
toposort: 2.0.2
type-fest: 2.19.0
zustand@5.0.8(@types/react@18.3.24)(react@18.3.1):
optionalDependencies:
'@types/react': 18.3.24
react: 18.3.1

View File

@@ -1,12 +1,36 @@
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/home/index', // 新的模板卡片首页
'pages/history/index', // 历史记录页面
'pages/generate/index', // 生成处理页面
'pages/index/index', // 保留原有首页,暂时作为模板页面
'pages/result/index'
],
tabBar: {
color: '#999999',
selectedColor: '#007AFF',
backgroundColor: '#FFFFFF',
borderStyle: 'black',
list: [
{
pagePath: 'pages/home/index',
text: '首页',
iconPath: 'assets/icons/default/shouye.png',
selectedIconPath: 'assets/icons/activate/shouye.png'
},
{
pagePath: 'pages/history/index',
text: '历史记录',
iconPath: 'assets/icons/default/shoulijindu.png',
selectedIconPath: 'assets/icons/activate/shoulijindu.png'
}
]
},
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTitleText: '图生视频',
navigationBarTextStyle: 'black'
}
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 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: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 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: 5.0 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: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 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

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

@@ -0,0 +1,104 @@
.template-card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
cursor: pointer;
display: flex;
flex-direction: column;
height: 160px;
position: relative;
overflow: hidden;
}
.template-card:active {
transform: scale(0.98);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.template-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(0, 122, 255, 0.05), rgba(90, 200, 250, 0.05));
pointer-events: none;
}
.template-thumbnail {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #007AFF, #5AC8FA);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
.template-icon {
font-size: 20px;
z-index: 1;
}
.template-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.template-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.template-name {
font-size: 16px;
font-weight: 600;
color: #333;
line-height: 1.3;
margin-bottom: 6px;
display: block;
}
.template-desc {
font-size: 13px;
color: #666;
line-height: 1.4;
margin-bottom: 12px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.template-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.template-type {
font-size: 11px;
font-weight: 500;
padding: 4px 8px;
border-radius: 12px;
display: inline-block;
white-space: nowrap;
}
.template-category {
font-size: 11px;
color: #8E8E93;
font-weight: 400;
}

View File

@@ -0,0 +1,57 @@
import { View, Text, Image } from '@tarojs/components'
import { Template } from '../../store/types'
import './index.css'
interface TemplateCardProps {
template: Template
onClick: (template: Template) => void
}
export default function TemplateCard({ template, onClick }: TemplateCardProps) {
const handleClick = () => {
onClick(template)
}
const getTypeText = (type: Template['type']) => {
return type === 'image-to-video' ? '图生视频' : '图生图'
}
const getTypeColor = (type: Template['type']) => {
return type === 'image-to-video' ? '#FF6B35' : '#007AFF'
}
return (
<View className='template-card' onClick={handleClick}>
<View className='template-thumbnail'>
{/* 暂时使用emoji作为占位符后续替换为真实图片 */}
<Text className='template-icon'>
{template.type === 'image-to-video' ? '🎬' : '🎨'}
</Text>
{/*
<Image
className='template-image'
src={template.thumbnail}
mode='aspectFill'
/>
*/}
</View>
<View className='template-info'>
<Text className='template-name'>{template.name}</Text>
<Text className='template-desc'>{template.description}</Text>
<View className='template-meta'>
<Text
className='template-type'
style={{
color: getTypeColor(template.type),
backgroundColor: `${getTypeColor(template.type)}15`
}}
>
{getTypeText(template.type)}
</Text>
<Text className='template-category'>{template.category}</Text>
</View>
</View>
</View>
)
}

84
src/config/templates.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Template } from '../store/types'
// 模板配置数据
export const TEMPLATE_CONFIG: Template[] = [
{
id: '1',
name: '艺术风格',
description: '将照片转为艺术风格',
type: 'image-to-image',
thumbnail: '/assets/templates/art-style.png',
category: '风格转换'
},
{
id: '2',
name: '视频生成',
description: '图片生成动态视频',
type: 'image-to-video',
thumbnail: '/assets/templates/video-gen.png',
category: '视频生成'
},
{
id: '3',
name: '卡通风格',
description: '真人照片卡通化',
type: 'image-to-image',
thumbnail: '/assets/templates/cartoon.png',
category: '风格转换'
},
{
id: '4',
name: '复古滤镜',
description: '添加复古质感',
type: 'image-to-image',
thumbnail: '/assets/templates/vintage.png',
category: '滤镜效果'
},
{
id: '5',
name: '动画效果',
description: '制作动画视频',
type: 'image-to-video',
thumbnail: '/assets/templates/animation.png',
category: '视频生成'
},
{
id: '6',
name: '素描风格',
description: '转换为素描效果',
type: 'image-to-image',
thumbnail: '/assets/templates/sketch.png',
category: '风格转换'
},
{
id: '7',
name: '水彩画',
description: '水彩画风格转换',
type: 'image-to-image',
thumbnail: '/assets/templates/watercolor.png',
category: '风格转换'
},
{
id: '8',
name: '3D动画',
description: '生成3D动画效果',
type: 'image-to-video',
thumbnail: '/assets/templates/3d-animation.png',
category: '视频生成'
}
]
// 根据类型获取模板
export const getTemplatesByType = (type: 'image-to-image' | 'image-to-video'): Template[] => {
return TEMPLATE_CONFIG.filter(template => template.type === type)
}
// 根据分类获取模板
export const getTemplatesByCategory = (category: string): Template[] => {
return TEMPLATE_CONFIG.filter(template => template.category === category)
}
// 根据ID获取模板
export const getTemplateById = (id: string): Template | undefined => {
return TEMPLATE_CONFIG.find(template => template.id === id)
}

View File

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

View File

@@ -0,0 +1,20 @@
import { useReady } from '@tarojs/taro';
import Taro from '@tarojs/taro';
interface UploadOptions {
errMsg?: string;
[key: string]: any;
}
export const useUploadVideo = (
onUpload: (options: UploadOptions) => void
) => {
useReady(() => {
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage) {
currentPage.onUploadDouyinVideo = onUpload;
}
});
};

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '生成中',
navigationBarBackgroundColor: '#2c3e50',
navigationBarTextStyle: 'white',
backgroundColorTop: '#2c3e50',
backgroundColorBottom: '#34495e',
backgroundColor: '#2c3e50'
})

View File

@@ -0,0 +1,4 @@
.generate {
padding: 20px;
text-align: center;
}

View File

@@ -0,0 +1,11 @@
import { View, Text } from '@tarojs/components'
import './index.css'
// 暂时的占位页面,后续会迁移原有的生成逻辑
export default function Generate() {
return (
<View className='generate'>
<Text></Text>
</View>
)
}

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '历史记录',
navigationBarBackgroundColor: '#2c3e50',
navigationBarTextStyle: 'white',
backgroundColorTop: '#2c3e50',
backgroundColorBottom: '#34495e',
backgroundColor: '#2c3e50'
})

134
src/pages/history/index.css Normal file
View File

@@ -0,0 +1,134 @@
.history {
background-color: #f8f9fa;
min-height: 100vh;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 16px 16px;
background: #fff;
border-bottom: 1px solid #eee;
}
.history-title {
font-size: 20px;
font-weight: 600;
color: #333;
}
.clear-btn {
font-size: 16px;
color: #007AFF;
padding: 8px;
cursor: pointer;
}
.history-list {
height: calc(100vh - 160px);
padding: 16px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-icon {
font-size: 60px;
display: block;
margin-bottom: 20px;
}
.empty-text {
font-size: 18px;
color: #333;
display: block;
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #666;
display: block;
}
.history-item {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.item-thumbnail {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #007AFF, #5AC8FA);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
}
.thumbnail-icon {
font-size: 20px;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.item-title {
font-size: 16px;
font-weight: 600;
color: #333;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-status {
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 10px;
background: rgba(142, 142, 147, 0.1);
margin-left: 12px;
flex-shrink: 0;
}
.item-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-type {
font-size: 13px;
color: #666;
background: rgba(0, 122, 255, 0.1);
color: #007AFF;
padding: 3px 8px;
border-radius: 8px;
font-size: 12px;
}
.item-time {
font-size: 12px;
color: #8E8E93;
}

123
src/pages/history/index.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { View, Text, ScrollView } from '@tarojs/components'
import { useEffect } from 'react'
import Taro from '@tarojs/taro'
import { useHistoryStore } from '../../store'
import './index.css'
export default function History() {
const {
records,
loadRecords,
clearRecords
} = useHistoryStore()
useEffect(() => {
loadRecords()
}, [loadRecords])
const clearHistory = () => {
Taro.showModal({
title: '确认清空',
content: '是否清空所有历史记录?',
success: async (res) => {
if (res.confirm) {
try {
await clearRecords()
Taro.showToast({
title: '清空成功',
icon: 'success'
})
} catch (error) {
Taro.showToast({
title: '清空失败',
icon: 'error'
})
}
}
}
})
}
const getStatusText = (status: string) => {
switch (status) {
case 'generating': return '生成中'
case 'completed': return '已完成'
case 'failed': return '生成失败'
default: return '未知状态'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'generating': return '#FF9500'
case 'completed': return '#34C759'
case 'failed': return '#FF3B30'
default: return '#8E8E93'
}
}
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
return (
<View className='history'>
<View className='history-header'>
<Text className='history-title'></Text>
{records.length > 0 && (
<Text className='clear-btn' onClick={clearHistory}></Text>
)}
</View>
<ScrollView
className='history-list'
scrollY
refresherEnabled
refresherTriggered={false}
>
{records.length === 0 ? (
<View className='empty-state'>
<Text className='empty-icon'>📝</Text>
<Text className='empty-text'></Text>
<Text className='empty-desc'></Text>
</View>
) : (
records.map((record) => (
<View key={record.id} className='history-item'>
<View className='item-thumbnail'>
<Text className='thumbnail-icon'>
{record.type === 'image-to-video' ? '🎬' : '🖼️'}
</Text>
</View>
<View className='item-content'>
<View className='item-header'>
<Text className='item-title'>{record.templateName}</Text>
<Text
className='item-status'
style={{ color: getStatusColor(record.status) }}
>
{getStatusText(record.status)}
</Text>
</View>
<View className='item-info'>
<Text className='item-type'>
{record.type === 'image-to-video' ? '图生视频' : '图生图'}
</Text>
<Text className='item-time'>{formatTime(record.createTime)}</Text>
</View>
</View>
</View>
))
)}
</ScrollView>
</View>
)
}

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '首页',
navigationBarBackgroundColor: '#2c3e50',
navigationBarTextStyle: 'white',
backgroundColorTop: '#2c3e50',
backgroundColorBottom: '#34495e',
backgroundColor: '#2c3e50'
})

32
src/pages/home/index.css Normal file
View File

@@ -0,0 +1,32 @@
.home {
padding: 20px 16px;
background-color: #f8f9fa;
min-height: 100vh;
}
.home-header {
text-align: center;
margin-bottom: 32px;
}
.home-title {
font-size: 28px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 8px;
}
.home-subtitle {
font-size: 16px;
color: #666;
display: block;
}
.template-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
max-width: 400px;
margin: 0 auto;
}

43
src/pages/home/index.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { View, Text } from '@tarojs/components'
import { useEffect } from 'react'
import { navigateTo } from '@tarojs/taro'
import { useTemplateStore, Template } from '../../store/index'
import TemplateCard from '../../components/TemplateCard'
import './index.css'
export default function Home() {
const { templates, initTemplates, setSelectedTemplate } = useTemplateStore()
useEffect(() => {
initTemplates()
}, [initTemplates])
const handleTemplateClick = (template: Template) => {
// 设置选中的模板
setSelectedTemplate(template)
// 跳转到原有的功能页面,保持老功能可用
navigateTo({
url: `/pages/index/index?templateId=${template.id}&templateName=${encodeURIComponent(template.name)}&templateType=${template.type}`
})
}
return (
<View className='home'>
<View className='home-header'>
<Text className='home-title'></Text>
<Text className='home-subtitle'></Text>
</View>
<View className='template-grid'>
{templates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onClick={handleTemplateClick}
/>
))}
</View>
</View>
)
}

View File

@@ -2,10 +2,11 @@ import { View } from '@tarojs/components'
import { useState } from 'react'
import { navigateTo } from '@tarojs/taro'
import './index.css'
import { useSdk } from '../../hooks/index'
import { useSdk, useUploadVideo } from '../../hooks/index'
import UploadButton from '../../components/UploadButton'
import LoadingOverlay from '../../components/LoadingOverlay'
import ErrorOverlay from '../../components/ErrorOverlay'
import { createPlatformFactory } from '../../platforms'
type PageStep = 'upload' | 'loading' | 'error'
@@ -21,8 +22,36 @@ export default function Index() {
error: null
})
const [videoPath, setVidePath] = useState<string>()
useUploadVideo((res) => {
console.log(res)
return {
videoPath: videoPath,
titleConfig: {
title: `视频发布`
},
success() {
console.log("视频发布/挂载成功");
},
fail() {
console.log("视频发布/挂载失败");
},
}
})
const imageToVideo = async () => {
const imgUrl = `https://cdn.roasmax.cn/upload/966d254b1859453e80e7de5f155a06fb.png`
console.log(`img url is : ${imgUrl}`)
const res = await sdk.imageToVideo({ img_url: imgUrl, duration: 2 })
console.log({ imageToVideoResult: res })
const tempFilePath = await createPlatformFactory().createMedia().downloadFile(res).then(res => res.tempFilePath)
setVidePath(tempFilePath)
}
const chooseAndGenerateImage = async () => {
try {
return await imageToVideo()
// 选择图片选择完成后会自动触发loading状态
const task_id = await sdk.chooseAndGenerateImage({
onImageSelected: () => {
@@ -52,6 +81,8 @@ export default function Index() {
setState({ step: 'upload', error: null })
}
const renderCurrentStep = () => {
switch (state.step) {
case 'upload':

View File

@@ -166,7 +166,13 @@ export abstract class UserInfo {
abstract checkSession(): Promise<ICheckSession>;
}
export interface IDownloadFile {
errMsg: string;
header: { [key: string]: string };
profile: {};
statusCode: number;
tempFilePath: string;
}
export abstract class Media {
abstract downloadFile(url: string): Promise<void>;
abstract downloadFile(url: string): Promise<IDownloadFile>;
}

View File

@@ -7,7 +7,8 @@ import {
UserInfo,
IUserProfile,
ICheckSession,
Media
Media,
IDownloadFile
} from "./core";
/**
@@ -244,7 +245,7 @@ export class UserInfoTT extends UserInfo {
export class MediaTT extends Media {
downloadFile(url: string): Promise<void> {
downloadFile(url: string): Promise<IDownloadFile> {
return new Promise((resolve, reject) => {
tt.downloadFile({
url, success: (res: any) => {

View File

@@ -7,7 +7,8 @@ import {
UserInfo,
IUserProfile,
ICheckSession,
Media
Media,
IDownloadFile
} from "./core";
/**
@@ -243,7 +244,7 @@ export class UserInfoWeApp extends UserInfo {
export class MediaWeApp extends Media {
downloadFile(url: string): Promise<void> {
downloadFile(url: string): Promise<IDownloadFile> {
return new Promise((resolve, reject) => {
wx.downloadFile({
url, success: (res: any) => {

137
src/store/historyStore.ts Normal file
View File

@@ -0,0 +1,137 @@
import { create } from 'zustand'
import Taro from '@tarojs/taro'
import { HistoryRecord } from './types'
interface HistoryStore {
records: HistoryRecord[]
loading: boolean
error: string | null
// Actions
loadRecords: () => Promise<void>
addRecord: (record: Omit<HistoryRecord, 'id' | 'createTime' | 'updateTime'>) => Promise<string>
updateRecord: (id: string, updates: Partial<HistoryRecord>) => Promise<void>
deleteRecord: (id: string) => Promise<void>
clearRecords: () => Promise<void>
getRecordById: (id: string) => HistoryRecord | undefined
getRecordsByStatus: (status: HistoryRecord['status']) => HistoryRecord[]
}
const STORAGE_KEY = 'historyRecords'
export const useHistoryStore = create<HistoryStore>((set, get) => ({
records: [],
loading: false,
error: null,
// 加载历史记录
loadRecords: async () => {
set({ loading: true, error: null })
try {
const savedRecords = Taro.getStorageSync(STORAGE_KEY) || []
set({ records: savedRecords, loading: false })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '加载历史记录失败'
set({ error: errorMsg, loading: false })
console.error('加载历史记录失败:', error)
}
},
// 添加新记录
addRecord: async (recordData) => {
const id = `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const now = new Date().toISOString()
const newRecord: HistoryRecord = {
...recordData,
id,
createTime: now,
updateTime: now
}
try {
const currentRecords = get().records
const updatedRecords = [newRecord, ...currentRecords]
await Taro.setStorage({
key: STORAGE_KEY,
data: updatedRecords
})
set({ records: updatedRecords })
return id
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '添加记录失败'
set({ error: errorMsg })
console.error('添加记录失败:', error)
throw error
}
},
// 更新记录
updateRecord: async (id, updates) => {
try {
const currentRecords = get().records
const updatedRecords = currentRecords.map(record =>
record.id === id
? { ...record, ...updates, updateTime: new Date().toISOString() }
: record
)
await Taro.setStorage({
key: STORAGE_KEY,
data: updatedRecords
})
set({ records: updatedRecords })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '更新记录失败'
set({ error: errorMsg })
console.error('更新记录失败:', error)
throw error
}
},
// 删除记录
deleteRecord: async (id) => {
try {
const currentRecords = get().records
const updatedRecords = currentRecords.filter(record => record.id !== id)
await Taro.setStorage({
key: STORAGE_KEY,
data: updatedRecords
})
set({ records: updatedRecords })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '删除记录失败'
set({ error: errorMsg })
console.error('删除记录失败:', error)
throw error
}
},
// 清空所有记录
clearRecords: async () => {
try {
await Taro.removeStorage({ key: STORAGE_KEY })
set({ records: [] })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '清空记录失败'
set({ error: errorMsg })
console.error('清空记录失败:', error)
throw error
}
},
// 获取单个记录
getRecordById: (id) => {
return get().records.find(record => record.id === id)
},
// 按状态筛选记录
getRecordsByStatus: (status) => {
return get().records.filter(record => record.status === status)
}
}))

4
src/store/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// 统一导出所有store
export * from './types'
export * from './historyStore'
export * from './templateStore'

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { Template } from './types'
import { TEMPLATE_CONFIG } from '../config/templates'
interface TemplateStore {
templates: Template[]
selectedTemplate: Template | null
// Actions
initTemplates: () => void
getTemplateById: (id: string) => Template | undefined
setSelectedTemplate: (template: Template | null) => void
getTemplatesByType: (type: 'image-to-image' | 'image-to-video') => Template[]
}
export const useTemplateStore = create<TemplateStore>((set, get) => ({
templates: [],
selectedTemplate: null,
// 初始化模板数据
initTemplates: () => {
set({ templates: TEMPLATE_CONFIG })
},
// 根据ID获取模板
getTemplateById: (id) => {
return get().templates.find(template => template.id === id)
},
// 设置选中的模板
setSelectedTemplate: (template) => {
set({ selectedTemplate: template })
},
// 根据类型筛选模板
getTemplatesByType: (type) => {
return get().templates.filter(template => template.type === type)
}
}))

28
src/store/types.ts Normal file
View File

@@ -0,0 +1,28 @@
// 数据类型定义
export interface HistoryRecord {
id: string
type: 'image-to-image' | 'image-to-video'
templateId: string
templateName: string
inputImage: string
outputResult?: string | string[]
status: 'generating' | 'completed' | 'failed'
createTime: string
updateTime: string
errorMessage?: string
}
export interface Template {
id: string
name: string
description: string
type: 'image-to-image' | 'image-to-video'
thumbnail: string
category: string
}
export interface GenerateParams {
templateId: string
imageUrl: string
additionalParams?: Record<string, any>
}