feat: 新增 ImageAudit 组件及演示页面
- 添加 ImageAudit 图片审核组件,支持图片上传和内容安全审核 - 集成 useImageDetectionTaskManager hook 处理审核任务管理 - 新增 imageaudit-demo 演示页面展示组件功能 - 添加图片上传工具 imageUploader - 支持审核进度跟踪、结果显示和历史记录 - 优化用户交互体验,包括上传进度、错误处理等
This commit is contained in:
369
CLAUDE.md
369
CLAUDE.md
@@ -1,368 +1,3 @@
|
||||
本文档展示如何在 Claude 环境中严格遵循 **测试驱动开发 (TDD)** 规范。
|
||||
这是一个 Taro 小程序项目
|
||||
|
||||
## 必须使用简体中文(严格遵守)
|
||||
## 界面(pages/components等)不写测试
|
||||
## 依赖网络请求、文件请求等外部环境不写测试(mock替代)
|
||||
|
||||
## 🎯 TDD 核心原则 (严格遵守)
|
||||
|
||||
### 🔴 红阶段:编写失败测试
|
||||
```typescript
|
||||
// ❌ 必须先写失败的测试
|
||||
describe('Calculator', () => {
|
||||
it('should add two numbers', () => {
|
||||
const calc = new Calculator(); // 这里会失败 - 正确的!
|
||||
expect(calc.add(2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 🟢 绿阶段:最小实现
|
||||
```typescript
|
||||
// ✅ 只写刚好让测试通过的代码
|
||||
export class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b; // 最简实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔄 重构阶段:优化代码
|
||||
```typescript
|
||||
// ✅ 在绿灯状态下安全重构
|
||||
export class Calculator {
|
||||
/**
|
||||
* 两数相加
|
||||
* @param a 第一个数
|
||||
* @param b 第二个数
|
||||
* @returns 和
|
||||
*/
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚫 TDD 强制规则 (绝不违反)
|
||||
|
||||
### ❌ 禁止行为
|
||||
- **绝对不允许**在没有测试的情况下编写生产代码
|
||||
- **绝对不允许**跳过红阶段直接写实现
|
||||
- **绝对不允许**在红灯状态下进行重构
|
||||
- **绝对不允许**同时处理多个失败测试
|
||||
- **绝对不允许**写example示例代码
|
||||
- **绝对不允许**业务中写测试代码
|
||||
- **绝对不允许**业务中写简化或mock代码
|
||||
|
||||
### ✅ 必须行为
|
||||
- **必须切换新分支** 切换新的分支后开始新功能开发
|
||||
- **必须确认**测试在功能实现前是失败的
|
||||
- **必须编写**刚好让测试通过的最小代码
|
||||
- **必须在**所有测试通过的绿灯状态下进行重构
|
||||
- **必须保持**每个测试的独立性
|
||||
- **必须遵守**单个文件代码行数不超过500行
|
||||
- **必须遵守**环境依赖性测试(文件系统监控、网络连接、性能测试),使用模拟对象(Mock)替代真实环境依赖,确保测试的稳定性
|
||||
|
||||
## 🛠️ Claude TDD 工作流程
|
||||
|
||||
### Step 1: 需求分析 → 测试设计
|
||||
```bash
|
||||
# 在 Claude 中的对话流程
|
||||
用户: "我需要一个计算器功能"
|
||||
Claude: "让我们从测试开始。首先写一个失败的测试..."
|
||||
```
|
||||
|
||||
### Step 2: 🔴 红阶段执行
|
||||
```typescript
|
||||
// 1. 先创建测试文件
|
||||
// src/calculator.test.ts
|
||||
import { Calculator } from './calculator'; // ❌ 这会失败
|
||||
|
||||
describe('Calculator', () => {
|
||||
it('should add two positive numbers', () => {
|
||||
const calculator = new Calculator();
|
||||
expect(calculator.add(2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. 运行测试确认失败
|
||||
npm run test
|
||||
# ❌ Cannot find module './calculator' - 正确的失败!
|
||||
```
|
||||
|
||||
### Step 3: 🟢 绿阶段执行
|
||||
```typescript
|
||||
// 3. 创建最小实现
|
||||
// src/calculator.ts
|
||||
export class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b; // 最简实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 4. 运行测试确认通过
|
||||
npm run test
|
||||
# ✅ Tests: 1 passed, 1 total
|
||||
```
|
||||
|
||||
### Step 4: 🔄 重构阶段执行
|
||||
```typescript
|
||||
// 5. 在绿灯状态下改进代码质量
|
||||
export class Calculator {
|
||||
/**
|
||||
* 添加两个数字
|
||||
* @param a 第一个数字
|
||||
* @param b 第二个数字
|
||||
* @returns 两数之和
|
||||
*/
|
||||
add(a: number, b: number): number {
|
||||
// 可以添加输入验证等
|
||||
if (typeof a !== 'number' || typeof b !== 'number') {
|
||||
throw new Error('Both arguments must be numbers');
|
||||
}
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 6. 确保重构后测试仍然通过
|
||||
npm run test
|
||||
# ✅ Tests: 1 passed, 1 total
|
||||
```
|
||||
|
||||
## 📊 Claude TDD 质量检查清单
|
||||
|
||||
### 🔴 红阶段检查清单
|
||||
- [ ] 测试用例清晰表达了期望的行为
|
||||
- [ ] 测试失败的原因是功能未实现,而非测试错误
|
||||
- [ ] 测试范围适中,只验证一个具体功能点
|
||||
- [ ] 测试用例名称具有良好的可读性
|
||||
|
||||
### 🟢 绿阶段检查清单
|
||||
- [ ] 所有测试都通过
|
||||
- [ ] 实现代码尽可能简单
|
||||
- [ ] 没有实现当前测试不需要的功能
|
||||
- [ ] 代码能够正确处理测试用例的所有场景
|
||||
|
||||
### 🔄 重构阶段检查清单
|
||||
- [ ] 消除了代码重复
|
||||
- [ ] 提高了代码可读性
|
||||
- [ ] 改善了代码结构
|
||||
- [ ] 所有测试仍然通过
|
||||
- [ ] 没有改变外部行为
|
||||
|
||||
## 🎓 Claude TDD 最佳实践
|
||||
|
||||
### 1. 对话驱动的 TDD
|
||||
```
|
||||
用户: "添加减法功能"
|
||||
Claude: "让我们遵循 TDD 流程:
|
||||
1. 🔴 先写减法的失败测试
|
||||
2. 🟢 实现最小的减法功能
|
||||
3. 🔄 重构优化代码"
|
||||
```
|
||||
|
||||
### 2. 增量式开发
|
||||
```typescript
|
||||
// 第一个测试:基本功能
|
||||
it('should subtract two positive numbers', () => {
|
||||
expect(calc.subtract(5, 3)).toBe(2);
|
||||
});
|
||||
|
||||
// 第二个测试:边界情况
|
||||
it('should handle negative results', () => {
|
||||
expect(calc.subtract(3, 5)).toBe(-2);
|
||||
});
|
||||
|
||||
// 第三个测试:零值处理
|
||||
it('should handle zero values', () => {
|
||||
expect(calc.subtract(5, 0)).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 测试即文档
|
||||
```typescript
|
||||
describe('Calculator', () => {
|
||||
describe('add method', () => {
|
||||
it('should add two positive integers correctly', () => {
|
||||
// 测试名称就是功能文档
|
||||
});
|
||||
|
||||
it('should handle floating point numbers with precision', () => {
|
||||
// 清晰表达预期行为
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🚀 Claude TDD 命令速查
|
||||
|
||||
### 快速启动 TDD 循环
|
||||
```bash
|
||||
# 1. 创建测试文件
|
||||
touch src/feature.test.ts
|
||||
|
||||
# 2. 运行测试 (红阶段)
|
||||
npm run test:watch
|
||||
|
||||
# 3. 创建实现文件
|
||||
touch src/feature.ts
|
||||
|
||||
# 4. 运行构建 (绿阶段后)
|
||||
npm run build
|
||||
|
||||
# 5. 查看覆盖率
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### TDD 开发节奏
|
||||
```bash
|
||||
# 保持快速的红-绿-重构循环
|
||||
🔴 写测试 → ❌ 运行测试 → 🟢 写代码 → ✅ 运行测试 → 🔄 重构 → ✅ 运行测试
|
||||
```
|
||||
|
||||
## 📈 成功的 TDD 指标
|
||||
|
||||
### 代码质量指标
|
||||
- ✅ **测试覆盖率**: 95%+ (强制要求)
|
||||
- ✅ **测试执行时间**: < 2 秒 (快速反馈)
|
||||
- ✅ **测试独立性**: 100% (无依赖测试)
|
||||
- ✅ **代码重复度**: 最小化
|
||||
|
||||
### TDD 流程指标
|
||||
- ✅ **红-绿-重构循环**: 严格遵循
|
||||
- ✅ **测试先行率**: 100% (无例外)
|
||||
- ✅ **最小实现原则**: 始终遵守
|
||||
- ✅ **重构安全性**: 测试保护下进行
|
||||
|
||||
## 🎯 下一步 TDD 开发
|
||||
|
||||
继续使用 Claude 进行 TDD 开发时:
|
||||
|
||||
1. **🔴 红阶段**: 告诉 Claude 你需要什么功能,让它先写失败测试
|
||||
2. **🟢 绿阶段**: 让 Claude 实现最小代码使测试通过
|
||||
3. **🔄 重构阶段**: 与 Claude 一起改进代码质量
|
||||
4. **重复循环**: 为每个新功能重复此过程
|
||||
|
||||
**记住:在 Claude 环境中,TDD 不仅是开发方法,更是确保代码质量和设计优雅的严格纪律!** 🎉
|
||||
|
||||
## 💡 Claude TDD 实战示例
|
||||
|
||||
### 示例:添加乘法功能
|
||||
|
||||
#### 🔴 红阶段:用户请求
|
||||
```
|
||||
用户: "我需要添加乘法功能"
|
||||
Claude: "好的,让我们严格遵循 TDD 流程。首先写一个失败的测试:"
|
||||
```
|
||||
|
||||
#### 测试代码 (先写)
|
||||
```typescript
|
||||
// src/calculator.test.ts
|
||||
describe('Calculator', () => {
|
||||
// ... 现有测试 ...
|
||||
|
||||
describe('multiply', () => {
|
||||
it('should multiply two positive numbers correctly', () => {
|
||||
const calculator = new Calculator();
|
||||
expect(calculator.multiply(3, 4)).toBe(12); // ❌ 这会失败
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 🟢 绿阶段:最小实现
|
||||
```typescript
|
||||
// src/calculator.ts
|
||||
export class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b; // 最简实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 🔄 重构阶段:优化代码
|
||||
```typescript
|
||||
export class Calculator {
|
||||
/**
|
||||
* 两数相加
|
||||
*/
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
/**
|
||||
* 两数相乘
|
||||
* @param a 被乘数
|
||||
* @param b 乘数
|
||||
* @returns 乘积
|
||||
*/
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Claude TDD 故障排除
|
||||
|
||||
### 常见问题与解决方案
|
||||
|
||||
#### 问题 1: 测试没有失败
|
||||
```typescript
|
||||
// ❌ 错误:测试一开始就通过了
|
||||
it('should return true', () => {
|
||||
expect(true).toBe(true); // 这不是有效的 TDD 测试
|
||||
});
|
||||
|
||||
// ✅ 正确:测试应该验证具体功能
|
||||
it('should validate email format', () => {
|
||||
const validator = new EmailValidator();
|
||||
expect(validator.isValid('test@example.com')).toBe(true); // 会失败,因为类不存在
|
||||
});
|
||||
```
|
||||
|
||||
#### 问题 2: 实现过度复杂
|
||||
```typescript
|
||||
// ❌ 错误:过度实现
|
||||
multiply(a: number, b: number): number {
|
||||
// 不需要的复杂逻辑
|
||||
if (a === 0 || b === 0) return 0;
|
||||
if (a === 1) return b;
|
||||
if (b === 1) return a;
|
||||
// ... 更多不必要的逻辑
|
||||
return a * b;
|
||||
}
|
||||
|
||||
// ✅ 正确:最小实现
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b; // 简单直接
|
||||
}
|
||||
```
|
||||
|
||||
#### 问题 3: 在红灯状态下重构
|
||||
```bash
|
||||
# ❌ 错误流程
|
||||
npm run test # ❌ 测试失败
|
||||
# 开始重构代码 <- 错误!应该先让测试通过
|
||||
|
||||
# ✅ 正确流程
|
||||
npm run test # ❌ 测试失败
|
||||
# 写最小代码让测试通过
|
||||
npm run test # ✅ 测试通过
|
||||
# 现在可以安全重构
|
||||
```
|
||||
|
||||
### 关键文件说明
|
||||
- `jest.config.js` - 测试框架配置
|
||||
- `tsconfig.json` - TypeScript 严格模式配置
|
||||
- `package.json` - TDD 相关脚本命令
|
||||
- `src/*.test.ts` - 测试文件 (TDD 的核心)
|
||||
dev服务器我已经启动了,你不用启动这个,报错我会告诉你的
|
||||
@@ -1,123 +0,0 @@
|
||||
# Home页面瀑布流设计预览 - 左右对比版本
|
||||
|
||||
## 设计概述
|
||||
|
||||
我已经成功美化了home页面,实现了瀑布流布局和左右对比效果来展示模板数据。以下是主要的设计改进:
|
||||
|
||||
## 🎨 设计特色
|
||||
|
||||
### 1. 瀑布流布局
|
||||
- **每行2列**:在移动端显示2列,充分利用屏幕空间
|
||||
- **响应式设计**:在更大屏幕上自动调整为3列
|
||||
- **自适应高度**:每个卡片根据内容自动调整高度
|
||||
|
||||
### 2. 可拖拽对比效果 🚀 **最新功能**
|
||||
- **可拖拽分割线**:用户可以左右拖动分割线,动态调整对比比例
|
||||
- **实时响应**:拖拽过程中实时看到两张图片的显示比例变化
|
||||
- **智能边界**:限制拖拽范围在10%-90%之间,确保两侧都有内容显示
|
||||
- **触摸优化**:增大拖拽区域,优化触摸体验,防止误触
|
||||
- **视觉反馈**:拖拽时手柄会放大,提供清晰的交互反馈
|
||||
- **动态标签**:左右标签宽度随分割线位置动态调整
|
||||
|
||||
### 3. 卡片设计
|
||||
- **渐变背景**:图片区域使用渐变背景增加层次感
|
||||
- **阴影效果**:卡片具有柔和的阴影,增加立体感
|
||||
- **交互反馈**:点击时有缩放和阴影变化效果
|
||||
|
||||
### 4. 信息展示
|
||||
- **模板名称**:使用大字体突出显示
|
||||
- **详细描述**:提供清晰的功能说明
|
||||
- **标签系统**:使用彩色标签展示功能特点
|
||||
- **积分显示**:清晰显示所需积分成本
|
||||
|
||||
## 📱 布局结构
|
||||
|
||||
```
|
||||
Home页面
|
||||
├── 页面标题区域
|
||||
│ ├── 主标题:"AI 图像处理"(渐变色)
|
||||
│ └── 副标题:"选择模板,一键生成精美效果"
|
||||
└── 瀑布流网格
|
||||
└── 模板卡片 × 6
|
||||
├── 可拖拽对比区域 🚀 **交互功能**
|
||||
│ ├── 左半边:原图左半部分(动态宽度,蓝色标签)
|
||||
│ ├── 可拖拽分割线:白色线条 + 可拖拽手柄(⟷图标)
|
||||
│ └── 右半边:效果图右半部分(动态宽度,绿色标签)
|
||||
└── 信息区域
|
||||
├── 模板名称
|
||||
├── 功能描述
|
||||
└── 底部元数据
|
||||
├── 功能标签
|
||||
└── 积分成本
|
||||
```
|
||||
|
||||
## 🎯 视觉亮点
|
||||
|
||||
### 颜色方案
|
||||
- **主色调**:蓝紫渐变 (#667eea → #764ba2)
|
||||
- **原图标签**:蓝色 (rgba(52, 152, 219, 0.9))
|
||||
- **效果标签**:绿色 (rgba(46, 204, 113, 0.9))
|
||||
- **积分显示**:橙色 (#FF6B35)
|
||||
|
||||
### 交互效果
|
||||
- **悬停效果**:卡片向上移动2px
|
||||
- **点击反馈**:轻微缩放效果
|
||||
- **图片加载**:渐显动画效果
|
||||
|
||||
## 📊 模板数据
|
||||
|
||||
已添加6个模板展示不同的AI处理功能:
|
||||
|
||||
1. **老照片修复** - 10积分
|
||||
2. **艺术风格转换** - 15积分
|
||||
3. **人像美化** - 12积分
|
||||
4. **背景替换** - 18积分
|
||||
5. **超分辨率** - 20积分
|
||||
6. **黑白上色** - 16积分
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 响应式布局
|
||||
- 使用CSS Grid布局实现瀑布流效果
|
||||
- 支持小程序环境的兼容性处理
|
||||
- 自动适配不同屏幕尺寸
|
||||
|
||||
### 性能优化
|
||||
- 图片懒加载 (lazyLoad)
|
||||
- CSS动画使用transform提升性能
|
||||
- 合理的图片尺寸设置
|
||||
|
||||
## 🎮 交互使用说明
|
||||
|
||||
### 拖拽对比功能
|
||||
1. **初始状态**:分割线位于中央(50/50比例)
|
||||
2. **开始拖拽**:用手指按住分割线上的圆形手柄
|
||||
3. **左右拖动**:
|
||||
- 向左拖动:原图区域变小,效果图区域变大
|
||||
- 向右拖动:原图区域变大,效果图区域变小
|
||||
4. **实时预览**:拖拽过程中可以实时看到对比效果
|
||||
5. **智能限制**:拖拽范围限制在10%-90%之间
|
||||
|
||||
### 视觉反馈
|
||||
- **拖拽时**:手柄会放大并增强阴影效果
|
||||
- **动态标签**:左右标签宽度随分割线实时调整
|
||||
- **平滑过渡**:所有动画都有平滑的过渡效果
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
1. 启动开发服务器:`npm run dev:weapp`
|
||||
2. 在微信开发者工具中打开项目
|
||||
3. 查看home页面的瀑布流效果
|
||||
4. 尝试拖拽分割线体验对比功能
|
||||
|
||||
## 📝 后续优化建议
|
||||
|
||||
1. **图片优化**:可以添加图片加载失败的占位符
|
||||
2. **动画增强**:可以添加卡片进入动画
|
||||
3. **筛选功能**:可以添加按标签筛选模板的功能
|
||||
4. **搜索功能**:可以添加模板搜索功能
|
||||
|
||||
---
|
||||
|
||||
*设计完成时间:2025年1月3日*
|
||||
*设计师:AI Assistant*
|
||||
246
TODO.md
246
TODO.md
@@ -1,246 +0,0 @@
|
||||
# 小程序重构开发计划
|
||||
|
||||
## 项目概述
|
||||
将现有的图生图/图生视频小程序重构为模板卡片式首页 + 历史记录页面的架构。
|
||||
|
||||
## 开发阶段规划
|
||||
|
||||
### 阶段一:项目架构重构 (优先级:高)
|
||||
|
||||
#### 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 状态管理
|
||||
- [x] 创建 `src/store/` 目录
|
||||
- [x] 实现 Redux 状态管理(已从 Zustand 迁移到 Redux Toolkit)
|
||||
- [x] `src/reducers/history.ts` - 历史记录管理
|
||||
- [x] `src/reducers/template.ts` - 模板配置管理
|
||||
- [x] `src/actions/` - Action creators
|
||||
- [x] `src/selectors/` - 选择器
|
||||
- [x] `src/hooks/redux.ts` - 类型化 hooks
|
||||
- [x] 添加数据持久化功能(通过 Taro Storage API)
|
||||
|
||||
### 阶段三:模板卡片首页开发 (优先级:高)
|
||||
|
||||
#### 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接口兼容新的页面流程
|
||||
- **存储方案**: 本地存储和可能的云端同步需求
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/imageaudit-demo/index',
|
||||
'pages/home/index', // 新的模板卡片首页
|
||||
'pages/history/index', // 历史记录页面
|
||||
'pages/generate/index', // 生成处理页面
|
||||
|
||||
207
src/components/ImageAudit/index.css
Normal file
207
src/components/ImageAudit/index.css
Normal file
@@ -0,0 +1,207 @@
|
||||
.image-audit {
|
||||
width: 100%;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 上传区域 */
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
height: 400rpx;
|
||||
border: 2rpx dashed #dcdcdc;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #007aff;
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #666;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* 图片预览 */
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
max-height: 400rpx;
|
||||
border-radius: 12rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-buttons {
|
||||
margin: 20rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audit-btn {
|
||||
width: 300rpx;
|
||||
height: 80rpx;
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 40rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
|
||||
.audit-btn:hover {
|
||||
background-color: #0066cc;
|
||||
}
|
||||
|
||||
/* 审核中状态 */
|
||||
.auditing-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.auditing-text {
|
||||
font-size: 30rpx;
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
width: 200rpx;
|
||||
height: 60rpx;
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 30rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 60rpx;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
|
||||
/* 上传进度 */
|
||||
.upload-progress {
|
||||
width: 100%;
|
||||
height: 400rpx;
|
||||
border: 2rpx solid #007aff;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f8ff;
|
||||
gap: 15rpx;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
color: #007aff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 重新上传按钮 */
|
||||
.reupload-btn {
|
||||
width: 300rpx;
|
||||
height: 80rpx;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 40rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 80rpx;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
|
||||
.reupload-btn:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
/* 审核结果 */
|
||||
.audit-result {
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.result-success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
font-size: 28rpx;
|
||||
margin-bottom: 8rpx;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.result-details-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-top: 10rpx;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 750px) {
|
||||
.image-audit {
|
||||
padding: 15rpx;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
height: 300rpx;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-height: 300rpx;
|
||||
}
|
||||
|
||||
.audit-btn {
|
||||
width: 250rpx;
|
||||
height: 70rpx;
|
||||
font-size: 30rpx;
|
||||
line-height: 70rpx;
|
||||
}
|
||||
}
|
||||
274
src/components/ImageAudit/index.tsx
Normal file
274
src/components/ImageAudit/index.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Image, Text, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useImageDetectionTaskManager, ImageAuditResult, AuditStatus, AuditConclusion } from '../../hooks/useImageDetectionTaskManager';
|
||||
import { imageUploader } from '../../utils/imageUploader';
|
||||
import './index.css';
|
||||
|
||||
interface ImageAuditProps {
|
||||
imageUrl?: string;
|
||||
auditResult?: ImageAuditResult;
|
||||
isAuditing?: boolean;
|
||||
currentTaskId?: string;
|
||||
onImageUpload?: (imageUrl: string) => void;
|
||||
onAuditComplete?: (result: ImageAuditResult) => void;
|
||||
onAuditError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const ImageAudit: React.FC<ImageAuditProps> = ({
|
||||
imageUrl: propImageUrl,
|
||||
auditResult,
|
||||
isAuditing: propIsAuditing,
|
||||
currentTaskId,
|
||||
onImageUpload,
|
||||
onAuditComplete,
|
||||
onAuditError
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState<string>(propImageUrl || '');
|
||||
const [isAuditing, setIsAuditing] = useState<boolean>(propIsAuditing || false);
|
||||
const [taskId, setTaskId] = useState<string>(currentTaskId || '');
|
||||
const [result, setResult] = useState<ImageAuditResult | undefined>(auditResult);
|
||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
|
||||
const {
|
||||
submitAuditTask,
|
||||
cancelAuditTask
|
||||
} = useImageDetectionTaskManager();
|
||||
|
||||
// 选择并上传图片
|
||||
const handleChooseImage = useCallback(async () => {
|
||||
try {
|
||||
const res = await Taro.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera']
|
||||
});
|
||||
|
||||
const tempFilePath = res.tempFilePaths[0];
|
||||
|
||||
// 显示本地图片预览
|
||||
setImageUrl(tempFilePath);
|
||||
|
||||
// 开始上传
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const uploadedUrl = await imageUploader.upload({
|
||||
filePath: tempFilePath,
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
// 上传成功,更新为服务器URL
|
||||
setImageUrl(uploadedUrl);
|
||||
onImageUpload?.(uploadedUrl);
|
||||
|
||||
Taro.showToast({
|
||||
title: '图片上传成功',
|
||||
icon: 'success'
|
||||
});
|
||||
} catch (uploadError) {
|
||||
console.error('图片上传失败:', uploadError);
|
||||
Taro.showToast({
|
||||
title: '图片上传失败',
|
||||
icon: 'error'
|
||||
});
|
||||
// 上传失败时清除图片
|
||||
setImageUrl('');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择图片失败:', error);
|
||||
Taro.showToast({
|
||||
title: '选择图片失败',
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
}, [onImageUpload]);
|
||||
|
||||
// 开始审核
|
||||
const handleStartAudit = useCallback(async () => {
|
||||
if (!imageUrl) {
|
||||
Taro.showToast({
|
||||
title: '请先选择图片',
|
||||
icon: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否为临时文件路径,如果是则不能进行检测
|
||||
if (imageUrl.startsWith('http://tmp/') || imageUrl.includes('wxfile://')) {
|
||||
Taro.showToast({
|
||||
title: '请等待图片上传完成',
|
||||
icon: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuditing(true);
|
||||
|
||||
try {
|
||||
const newTaskId = await submitAuditTask(
|
||||
{ imageUrl },
|
||||
// onProgress
|
||||
(status, progress) => {
|
||||
console.log('审核进度:', status, progress);
|
||||
},
|
||||
// onSuccess
|
||||
(auditSuccessResult) => {
|
||||
setIsAuditing(false);
|
||||
setResult(auditSuccessResult);
|
||||
onAuditComplete?.(auditSuccessResult);
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
setIsAuditing(false);
|
||||
onAuditError?.(error);
|
||||
}
|
||||
);
|
||||
|
||||
setTaskId(newTaskId);
|
||||
} catch (error) {
|
||||
setIsAuditing(false);
|
||||
onAuditError?.(error as Error);
|
||||
}
|
||||
}, [imageUrl, submitAuditTask, onAuditComplete, onAuditError]);
|
||||
|
||||
// 取消审核
|
||||
const handleCancelAudit = useCallback(async () => {
|
||||
if (taskId) {
|
||||
try {
|
||||
await cancelAuditTask(taskId);
|
||||
setIsAuditing(false);
|
||||
setTaskId('');
|
||||
} catch (error) {
|
||||
console.error('取消审核失败:', error);
|
||||
}
|
||||
}
|
||||
}, [taskId, cancelAuditTask]);
|
||||
|
||||
// 获取审核结果文本
|
||||
const getAuditResultText = (conclusion?: AuditConclusion): string => {
|
||||
switch (conclusion) {
|
||||
case AuditConclusion.PASS:
|
||||
return '通过';
|
||||
case AuditConclusion.REJECT:
|
||||
return '拒绝';
|
||||
case AuditConclusion.REVIEW:
|
||||
return '人工复审';
|
||||
case AuditConclusion.UNCERTAIN:
|
||||
return '不确定';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取风险等级文本
|
||||
const getRiskLevelText = (riskLevel?: 'low' | 'medium' | 'high'): string => {
|
||||
switch (riskLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='image-audit'>
|
||||
{/* 图片上传区域 */}
|
||||
{!imageUrl && !isUploading && (
|
||||
<View className='upload-area' onClick={handleChooseImage}>
|
||||
<Text className='upload-text'>点击上传图片进行审核</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{isUploading && (
|
||||
<View className='upload-progress'>
|
||||
<Text className='upload-text'>正在上传图片...</Text>
|
||||
<Text className='progress-text'>{uploadProgress}%</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 图片预览 */}
|
||||
{imageUrl && (
|
||||
<View className='image-preview'>
|
||||
<Image src={imageUrl} mode='aspectFit' className='preview-image' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{imageUrl && !isUploading && (
|
||||
<View className='action-buttons'>
|
||||
{!isAuditing && !result && (
|
||||
<View>
|
||||
<Button className='audit-btn' onClick={handleStartAudit}>
|
||||
开始审核
|
||||
</Button>
|
||||
<Button className='reupload-btn' onClick={handleChooseImage}>
|
||||
重新选择图片
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isAuditing && (
|
||||
<View className='auditing-section'>
|
||||
<Text className='auditing-text'>审核中...</Text>
|
||||
<Button className='cancel-btn' onClick={handleCancelAudit}>
|
||||
取消审核
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 审核结果 */}
|
||||
{result && (
|
||||
<View className='audit-result'>
|
||||
{result.status === AuditStatus.COMPLETED && result.conclusion && (
|
||||
<View className='result-success'>
|
||||
<Text className='result-title'>
|
||||
审核结果: {getAuditResultText(result.conclusion)}
|
||||
</Text>
|
||||
{result.result && (
|
||||
<View className='result-details'>
|
||||
<Text className='result-item'>
|
||||
安全评分: {Math.round((result.result.score || 0) * 100)}
|
||||
</Text>
|
||||
<Text className='result-item'>
|
||||
风险等级: {getRiskLevelText(result.result.riskLevel)}
|
||||
</Text>
|
||||
{result.result.details && (
|
||||
<Text className='result-details-text'>
|
||||
{result.result.details}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{result.status === AuditStatus.FAILED && (
|
||||
<View className='result-error'>
|
||||
<Text className='error-title'>审核失败</Text>
|
||||
{result.errorMessage && (
|
||||
<Text className='error-message'>{result.errorMessage}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAudit;
|
||||
504
src/hooks/useImageDetectionTaskManager.ts
Normal file
504
src/hooks/useImageDetectionTaskManager.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { AsyncTaskManager, TaskStatus, TaskConfig } from '../utils/AsyncTaskManager';
|
||||
|
||||
/**
|
||||
* 平台类型枚举
|
||||
*/
|
||||
export enum PlatformType {
|
||||
WECHAT = 'wechat', // 微信小程序 - 腾讯微信生态
|
||||
ALIPAY = 'alipay', // 支付宝小程序 - 蚂蚁金服生态
|
||||
BAIDU = 'baidu', // 百度智能小程序 - 百度生态
|
||||
BYTEDANCE = 'bytedance', // 字节跳动小程序 - 抖音、今日头条等
|
||||
JD = 'jd', // 京东小程序 - 京东购物生态
|
||||
QQ = 'qq', // QQ小程序 - 腾讯QQ生态
|
||||
FEISHU = 'feishu', // 飞书小程序 - 企业办公生态
|
||||
KUAISHOU = 'kuaishou', // 快手小程序 - 快手短视频生态
|
||||
H5 = 'h5', // H5网页版 - 浏览器端应用
|
||||
RN = 'rn', // React Native应用 - 原生移动应用
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核状态枚举
|
||||
*/
|
||||
export enum AuditStatus {
|
||||
PENDING = 'pending', // 待审核
|
||||
PROCESSING = 'processing', // 审核中
|
||||
COMPLETED = 'completed', // 审核完成
|
||||
FAILED = 'failed', // 审核失败
|
||||
TIMEOUT = 'timeout', // 审核超时
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核结果枚举
|
||||
*/
|
||||
export enum AuditConclusion {
|
||||
PASS = 'pass', // 通过
|
||||
REJECT = 'reject', // 拒绝
|
||||
REVIEW = 'review', // 人工复审
|
||||
UNCERTAIN = 'uncertain', // 不确定
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片审核结果接口
|
||||
*/
|
||||
export interface ImageAuditResult {
|
||||
taskId: string;
|
||||
platform: PlatformType;
|
||||
status: AuditStatus;
|
||||
conclusion?: AuditConclusion; // 审核结果
|
||||
imageUrl: string;
|
||||
result?: {
|
||||
safe?: boolean; // 内容是否安全
|
||||
score?: number; // 审核分数
|
||||
riskLevel?: 'low' | 'medium' | 'high'; // 风险等级
|
||||
categories?: Array<{ // 违规分类
|
||||
name: string;
|
||||
score: number;
|
||||
}>;
|
||||
suggestion?: 'pass' | 'review' | 'block'; // 建议操作
|
||||
details?: string; // 详细信息
|
||||
};
|
||||
processingTime?: number;
|
||||
timestamp: number;
|
||||
errorMessage?: string; // 错误信息(失败时)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片审核任务提交参数
|
||||
*/
|
||||
export interface ImageAuditParams {
|
||||
imageUrl: string; // 图片URL
|
||||
platform?: PlatformType; // 平台类型(可选,自动检测)
|
||||
options?: {
|
||||
maxSize?: number; // 最大图片大小(KB)
|
||||
quality?: number; // 压缩质量 0-100
|
||||
enableCache?: boolean; // 是否启用缓存
|
||||
timeout?: number; // 自定义超时时间(毫秒)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片内容审核任务管理器 Hook
|
||||
* 专门用于管理图片内容审核相关的异步任务
|
||||
*/
|
||||
export function useImageDetectionTaskManager() {
|
||||
const taskManagerRef = useRef<AsyncTaskManager | null>(null);
|
||||
const auditCacheRef = useRef<Map<string, ImageAuditResult>>(new Map());
|
||||
const currentPlatform = useRef<PlatformType>(getCurrentPlatform());
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化任务管理器
|
||||
const storage = {
|
||||
setStorage: Taro.setStorage,
|
||||
getStorage: Taro.getStorage
|
||||
};
|
||||
|
||||
taskManagerRef.current = new AsyncTaskManager(storage);
|
||||
|
||||
// 设置图片审核任务状态检查函数
|
||||
taskManagerRef.current.setStatusChecker(async (taskId: string) => {
|
||||
try {
|
||||
const platform = currentPlatform.current;
|
||||
const baseUrl = `https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com`;
|
||||
const apiUrl = `${baseUrl}/api/v1/content-moderation/${platform}/result/${taskId}`;
|
||||
|
||||
const response = await Taro.request({
|
||||
url: apiUrl,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': `Bearer ${Taro.getStorageSync('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
const { status, conclusion } = response.data;
|
||||
|
||||
// 映射审核状态到任务状态
|
||||
const taskStatus = mapAuditStatusToTaskStatus(status as AuditStatus);
|
||||
|
||||
// 如果审核完成,缓存结果
|
||||
if (status === AuditStatus.COMPLETED) {
|
||||
const auditResult: ImageAuditResult = {
|
||||
taskId,
|
||||
platform,
|
||||
status,
|
||||
conclusion: conclusion as AuditConclusion,
|
||||
imageUrl: '', // 从任务数据中获取
|
||||
result: response.data.result,
|
||||
processingTime: response.data.processingTime,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const cacheKey = `${platform}_${taskId}`;
|
||||
auditCacheRef.current.set(cacheKey, auditResult);
|
||||
}
|
||||
|
||||
return taskStatus;
|
||||
}
|
||||
throw new Error(`API调用失败: ${response.statusCode}`);
|
||||
} catch (error) {
|
||||
console.error('检查图片审核任务状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复任务
|
||||
taskManagerRef.current.resumeTasks();
|
||||
|
||||
return () => {
|
||||
if (taskManagerRef.current) {
|
||||
taskManagerRef.current.pausePolling();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 应用前后台切换处理
|
||||
useEffect(() => {
|
||||
const handleAppShow = () => {
|
||||
taskManagerRef.current?.activatePolling();
|
||||
};
|
||||
|
||||
const handleAppHide = () => {
|
||||
taskManagerRef.current?.pausePolling();
|
||||
};
|
||||
|
||||
Taro.onAppShow(handleAppShow);
|
||||
Taro.onAppHide(handleAppHide);
|
||||
|
||||
return () => {
|
||||
Taro.offAppShow?.(handleAppShow);
|
||||
Taro.offAppHide?.(handleAppHide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 提交图片审核任务
|
||||
*/
|
||||
const submitAuditTask = useCallback(async (
|
||||
params: ImageAuditParams,
|
||||
onProgress?: (status: TaskStatus, progress?: number) => void,
|
||||
onSuccess?: (result: ImageAuditResult) => void,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<string> => {
|
||||
if (!taskManagerRef.current) {
|
||||
throw new Error('图片审核任务管理器未初始化');
|
||||
}
|
||||
|
||||
const { imageUrl, platform, options = {} } = params;
|
||||
const targetPlatform = platform || currentPlatform.current;
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = `${targetPlatform}_${imageUrl}`;
|
||||
if (options.enableCache && auditCacheRef.current.has(cacheKey)) {
|
||||
const cachedResult = auditCacheRef.current.get(cacheKey)!;
|
||||
setTimeout(() => onSuccess?.(cachedResult), 0);
|
||||
return cachedResult.taskId;
|
||||
}
|
||||
|
||||
try {
|
||||
// 预处理图片(如果需要)
|
||||
const processedImageUrl = await preprocessImage(imageUrl, options);
|
||||
|
||||
// 提交审核任务
|
||||
const baseUrl = `https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com`;
|
||||
const apiUrl = `${baseUrl}/api/v1/content-moderation/${targetPlatform}/audit-image`;
|
||||
|
||||
const response = await Taro.request({
|
||||
url: apiUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${Taro.getStorageSync('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
imageUrl: processedImageUrl
|
||||
}
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
throw new Error(`提交图片审核任务失败: ${response.data?.message || '未知错误'}`);
|
||||
}
|
||||
|
||||
const { taskId } = response.data;
|
||||
|
||||
// 创建任务配置
|
||||
const config: TaskConfig = {
|
||||
taskId,
|
||||
pollingInterval: 2000, // 2秒轮询
|
||||
maxRetries: 5, // 最大重试5次
|
||||
timeout: options.timeout || 300000, // 自定义超时时间或默认5分钟
|
||||
onProgress: (status, data) => {
|
||||
console.log(`图片审核任务 ${taskId} 状态: ${status}`);
|
||||
|
||||
// 显示不同状态的UI反馈
|
||||
switch (status) {
|
||||
case TaskStatus.RUNNING:
|
||||
Taro.showLoading({
|
||||
title: '审核中...',
|
||||
mask: true
|
||||
});
|
||||
break;
|
||||
case TaskStatus.SUCCESS:
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({
|
||||
title: '审核完成',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
break;
|
||||
case TaskStatus.FAILED:
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({
|
||||
title: '审核失败',
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
onProgress?.(status, data?.progress);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
console.log(`图片审核任务 ${taskId} 成功完成:`, result);
|
||||
|
||||
// 缓存结果
|
||||
if (options.enableCache) {
|
||||
auditCacheRef.current.set(cacheKey, result);
|
||||
}
|
||||
|
||||
onSuccess?.(result);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(`图片审核任务 ${taskId} 失败:`, error);
|
||||
onError?.(error);
|
||||
},
|
||||
onTimeout: () => {
|
||||
console.warn(`图片审核任务 ${taskId} 超时`);
|
||||
Taro.showToast({
|
||||
title: '审核超时,请重试',
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 启动任务监控
|
||||
await taskManagerRef.current.startTask(config);
|
||||
|
||||
return taskId;
|
||||
|
||||
} catch (error) {
|
||||
console.error('提交图片审核任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 批量审核多张图片
|
||||
*/
|
||||
const submitBatchAuditTask = useCallback(async (
|
||||
imageUrls: string[],
|
||||
platform?: PlatformType,
|
||||
options?: ImageAuditParams['options'],
|
||||
onProgress?: (completed: number, total: number) => void,
|
||||
onBatchSuccess?: (results: ImageAuditResult[]) => void,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<string[]> => {
|
||||
const taskIds: string[] = [];
|
||||
const results: ImageAuditResult[] = [];
|
||||
let completed = 0;
|
||||
|
||||
for (const imageUrl of imageUrls) {
|
||||
try {
|
||||
const taskId = await submitAuditTask(
|
||||
{ imageUrl, platform, options },
|
||||
undefined,
|
||||
(result) => {
|
||||
results.push(result);
|
||||
completed++;
|
||||
onProgress?.(completed, imageUrls.length);
|
||||
|
||||
if (completed === imageUrls.length) {
|
||||
onBatchSuccess?.(results);
|
||||
}
|
||||
},
|
||||
onError
|
||||
);
|
||||
taskIds.push(taskId);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds;
|
||||
}, [submitAuditTask]);
|
||||
|
||||
/**
|
||||
* 取消审核任务
|
||||
*/
|
||||
const cancelAuditTask = useCallback(async (taskId: string) => {
|
||||
if (!taskManagerRef.current) return;
|
||||
|
||||
await taskManagerRef.current.cancelTask(taskId);
|
||||
Taro.showToast({
|
||||
title: '审核任务已取消',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
const getTaskStatus = useCallback((taskId: string) => {
|
||||
return taskManagerRef.current?.getTaskStatus(taskId) || null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
const clearCache = useCallback(() => {
|
||||
auditCacheRef.current.clear();
|
||||
Taro.showToast({
|
||||
title: '缓存已清除',
|
||||
icon: 'success'
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取缓存大小
|
||||
*/
|
||||
const getCacheSize = useCallback(() => {
|
||||
return auditCacheRef.current.size;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
submitAuditTask,
|
||||
submitBatchAuditTask,
|
||||
cancelAuditTask,
|
||||
getTaskStatus,
|
||||
clearCache,
|
||||
getCacheSize,
|
||||
hasTask: (taskId: string) => taskManagerRef.current?.hasTask(taskId) || false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预处理
|
||||
*/
|
||||
async function preprocessImage(
|
||||
imageUrl: string,
|
||||
options: ImageAuditParams['options'] = {}
|
||||
): Promise<string> {
|
||||
const { maxSize, quality } = options;
|
||||
|
||||
// 如果不需要处理,直接返回原URL
|
||||
if (!maxSize && !quality) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取图片信息
|
||||
const imageInfo = await Taro.getImageInfo({ src: imageUrl });
|
||||
|
||||
// 如果图片已经符合要求,直接返回
|
||||
if (!maxSize || (imageInfo.width * imageInfo.height * 4 / 1024) <= maxSize) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// 压缩图片
|
||||
const canvas = Taro.createCanvasContext('image-compress-canvas');
|
||||
const scaleFactor = Math.sqrt(maxSize * 1024 / (imageInfo.width * imageInfo.height * 4));
|
||||
|
||||
const newWidth = Math.floor(imageInfo.width * scaleFactor);
|
||||
const newHeight = Math.floor(imageInfo.height * scaleFactor);
|
||||
|
||||
canvas.drawImage(imageUrl, 0, 0, newWidth, newHeight);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Taro.canvasToTempFilePath({
|
||||
canvasId: 'image-compress-canvas',
|
||||
quality: (quality || 80) / 100,
|
||||
success: (res) => resolve(res.tempFilePath),
|
||||
fail: () => resolve(imageUrl) // 失败时返回原URL
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.warn('图片预处理失败,使用原图:', error);
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行平台
|
||||
*/
|
||||
function getCurrentPlatform(): PlatformType {
|
||||
try {
|
||||
const env = Taro.getEnv();
|
||||
|
||||
console.log('当前平台环境:', env);
|
||||
|
||||
switch (env) {
|
||||
case 'WEAPP':
|
||||
return PlatformType.WECHAT;
|
||||
case 'ALIPAY':
|
||||
return PlatformType.ALIPAY;
|
||||
case 'SWAN':
|
||||
return PlatformType.BAIDU;
|
||||
case 'TT':
|
||||
return PlatformType.BYTEDANCE;
|
||||
case 'JD':
|
||||
return PlatformType.JD;
|
||||
case 'QQ':
|
||||
return PlatformType.QQ;
|
||||
case 'QUICKAPP':
|
||||
return PlatformType.KUAISHOU;
|
||||
case 'RN':
|
||||
return PlatformType.RN;
|
||||
case 'WEB':
|
||||
case 'H5':
|
||||
return PlatformType.H5;
|
||||
default:
|
||||
// 针对抖音小程序的特殊处理
|
||||
if (typeof tt !== 'undefined') {
|
||||
console.log('检测到抖音小程序环境');
|
||||
return PlatformType.BYTEDANCE;
|
||||
}
|
||||
// 如果在浏览器环境
|
||||
if (typeof window !== 'undefined') {
|
||||
return PlatformType.H5;
|
||||
}
|
||||
console.warn('未知平台类型,使用默认字节跳动平台:', env);
|
||||
return PlatformType.BYTEDANCE; // 默认使用字节跳动平台而不是H5
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法检测平台类型,使用默认字节跳动平台:', error);
|
||||
// 针对抖音小程序的兜底处理
|
||||
if (typeof tt !== 'undefined') {
|
||||
return PlatformType.BYTEDANCE;
|
||||
}
|
||||
return PlatformType.BYTEDANCE; // 默认使用字节跳动平台
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射审核状态到任务状态
|
||||
*/
|
||||
function mapAuditStatusToTaskStatus(auditStatus: AuditStatus): TaskStatus {
|
||||
switch (auditStatus) {
|
||||
case AuditStatus.PENDING:
|
||||
case AuditStatus.PROCESSING:
|
||||
return TaskStatus.RUNNING;
|
||||
case AuditStatus.COMPLETED:
|
||||
return TaskStatus.SUCCESS;
|
||||
case AuditStatus.FAILED:
|
||||
return TaskStatus.FAILED;
|
||||
case AuditStatus.TIMEOUT:
|
||||
return TaskStatus.TIMEOUT;
|
||||
default:
|
||||
return TaskStatus.RUNNING;
|
||||
}
|
||||
}
|
||||
93
src/pages/imageaudit-demo/index.css
Normal file
93
src/pages/imageaudit-demo/index.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.imageaudit-demo {
|
||||
padding: 20rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40rpx;
|
||||
padding: 30rpx 20rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-subtitle {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 40rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.audit-history {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 30rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 20rpx;
|
||||
border: 1rpx solid #eee;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.history-result {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 12rpx;
|
||||
border-top: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.history-score,
|
||||
.history-risk {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
121
src/pages/imageaudit-demo/index.tsx
Normal file
121
src/pages/imageaudit-demo/index.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import ImageAudit from '../../components/ImageAudit';
|
||||
import { ImageAuditResult } from '../../hooks/useImageDetectionTaskManager';
|
||||
import './index.css';
|
||||
|
||||
const ImageAuditDemo: React.FC = () => {
|
||||
const [currentImageUrl, setCurrentImageUrl] = useState<string>('');
|
||||
const [auditHistory, setAuditHistory] = useState<Array<{
|
||||
imageUrl: string;
|
||||
result: ImageAuditResult;
|
||||
timestamp: number;
|
||||
}>>([]);
|
||||
|
||||
const handleImageUpload = (imageUrl: string) => {
|
||||
console.log('图片上传成功:', imageUrl);
|
||||
setCurrentImageUrl(imageUrl);
|
||||
Taro.showToast({
|
||||
title: '图片上传成功',
|
||||
icon: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuditComplete = (result: ImageAuditResult) => {
|
||||
console.log('审核完成:', result);
|
||||
|
||||
// 添加到历史记录
|
||||
if (currentImageUrl) {
|
||||
setAuditHistory(prev => [{
|
||||
imageUrl: currentImageUrl,
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
}, ...prev]);
|
||||
}
|
||||
|
||||
Taro.showToast({
|
||||
title: '审核完成',
|
||||
icon: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuditError = (error: Error) => {
|
||||
console.error('审核失败:', error);
|
||||
Taro.showToast({
|
||||
title: `审核失败: ${error.message}`,
|
||||
icon: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getResultColor = (conclusion?: string): string => {
|
||||
switch (conclusion) {
|
||||
case 'PASS':
|
||||
return '#52c41a';
|
||||
case 'REJECT':
|
||||
return '#ff4d4f';
|
||||
case 'REVIEW':
|
||||
return '#faad14';
|
||||
default:
|
||||
return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='imageaudit-demo'>
|
||||
<View className='demo-header'>
|
||||
<Text className='demo-title'>图片审核组件演示</Text>
|
||||
<Text className='demo-subtitle'>上传图片进行内容安全审核</Text>
|
||||
</View>
|
||||
|
||||
<View className='demo-content'>
|
||||
<ImageAudit
|
||||
onImageUpload={handleImageUpload}
|
||||
onAuditComplete={handleAuditComplete}
|
||||
onAuditError={handleAuditError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{auditHistory.length > 0 && (
|
||||
<View className='audit-history'>
|
||||
<Text className='history-title'>审核历史记录</Text>
|
||||
{auditHistory.map((item, index) => (
|
||||
<View key={index} className='history-item'>
|
||||
<View className='history-info'>
|
||||
<Text className='history-time'>
|
||||
{formatTimestamp(item.timestamp)}
|
||||
</Text>
|
||||
<Text
|
||||
className='history-result'
|
||||
style={{ color: getResultColor(item.result.conclusion) }}
|
||||
>
|
||||
{item.result.conclusion === 'PASS' && '✅ 审核通过'}
|
||||
{item.result.conclusion === 'REJECT' && '❌ 审核拒绝'}
|
||||
{item.result.conclusion === 'REVIEW' && '⚠️ 人工复审'}
|
||||
{item.result.conclusion === 'UNCERTAIN' && '❓ 结果不确定'}
|
||||
</Text>
|
||||
</View>
|
||||
{item.result.result && (
|
||||
<View className='history-details'>
|
||||
<Text className='history-score'>
|
||||
安全评分: {Math.round((item.result.result.score || 0) * 100)}
|
||||
</Text>
|
||||
<Text className='history-risk'>
|
||||
风险等级: {item.result.result.riskLevel || '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAuditDemo;
|
||||
@@ -189,10 +189,14 @@ export class SdkServer {
|
||||
*/
|
||||
async executeTemplate(params: ExecuteTemplateParams): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.request<string | null>(`/api/v1/templates/code/${params.templateCode}/execute`, 'POST', {
|
||||
const response = await this.request<string | any | null>(`/api/v1/templates/code/${params.templateCode}/execute`, 'POST', {
|
||||
imageUrl: params.imageUrl,
|
||||
});
|
||||
return response.data
|
||||
const data = response.data;
|
||||
if(typeof data === 'string'){
|
||||
return data;
|
||||
}
|
||||
return data.executionId
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
343
src/utils/AsyncTaskManager.README.md
Normal file
343
src/utils/AsyncTaskManager.README.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# AsyncTaskManager 异步任务管理器
|
||||
|
||||
专为 Taro 小程序设计的通用异步任务管理工具,支持任务轮询、状态监控、后台运行恢复等功能。
|
||||
|
||||
## ✨ 功能特点
|
||||
|
||||
- 🔄 **智能轮询**: 自动轮询任务状态,支持自定义间隔
|
||||
- 🏃♂️ **后台运行**: 应用切换后台后自动暂停,恢复前台时继续轮询
|
||||
- 💾 **状态持久化**: 任务状态本地存储,应用重启后自动恢复
|
||||
- ⏰ **超时控制**: 自动检测任务超时,可自定义超时时间
|
||||
- 🔁 **智能重试**: 网络异常时自动重试,可配置重试次数
|
||||
- 📞 **事件回调**: 支持进度、成功、失败、超时等回调函数
|
||||
- 🚫 **任务取消**: 支持主动取消正在运行的任务
|
||||
- 🧪 **测试友好**: 完整的单元测试覆盖,支持依赖注入
|
||||
|
||||
## 📦 安装使用
|
||||
|
||||
### 1. 基础用法
|
||||
|
||||
```typescript
|
||||
import { AsyncTaskManager, TaskStatus, TaskConfig } from '@/utils/AsyncTaskManager';
|
||||
|
||||
// 创建任务管理器实例
|
||||
const taskManager = new AsyncTaskManager();
|
||||
|
||||
// 配置任务
|
||||
const config: TaskConfig = {
|
||||
taskId: 'your-task-id', // 任务ID
|
||||
pollingInterval: 3000, // 3秒轮询间隔
|
||||
maxRetries: 3, // 最大重试3次
|
||||
timeout: 300000, // 5分钟超时
|
||||
onProgress: (status) => {
|
||||
console.log('任务状态:', status);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
console.log('任务完成:', result);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('任务失败:', error);
|
||||
},
|
||||
onTimeout: () => {
|
||||
console.log('任务超时');
|
||||
}
|
||||
};
|
||||
|
||||
// 启动任务
|
||||
await taskManager.startTask(config);
|
||||
```
|
||||
|
||||
### 2. 使用 Hook(推荐)
|
||||
|
||||
```typescript
|
||||
import { useTaskOperations } from '@/hooks/useAsyncTaskManager';
|
||||
|
||||
export default function MyPage() {
|
||||
const { submitImageTask, cancelTask, getTaskStatus } = useTaskOperations();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const taskId = await submitImageTask(
|
||||
'https://example.com/image.jpg',
|
||||
'sketch-style',
|
||||
(status) => console.log('进度:', status),
|
||||
(result) => console.log('完成:', result),
|
||||
(error) => console.error('失败:', error)
|
||||
);
|
||||
|
||||
console.log('任务已提交:', taskId);
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleSubmit}>
|
||||
开始处理
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 API 参考
|
||||
|
||||
### TaskStatus 枚举
|
||||
|
||||
```typescript
|
||||
enum TaskStatus {
|
||||
PENDING = 'pending', // 待开始
|
||||
RUNNING = 'running', // 执行中
|
||||
SUCCESS = 'success', // 成功
|
||||
FAILED = 'failed', // 失败
|
||||
TIMEOUT = 'timeout', // 超时
|
||||
CANCELLED = 'cancelled' // 已取消
|
||||
}
|
||||
```
|
||||
|
||||
### TaskConfig 接口
|
||||
|
||||
```typescript
|
||||
interface TaskConfig {
|
||||
taskId: string; // 任务ID(必需)
|
||||
pollingInterval: number; // 轮询间隔,单位毫秒(必需)
|
||||
maxRetries: number; // 最大重试次数(必需)
|
||||
timeout: number; // 超时时间,单位毫秒(必需)
|
||||
onProgress?: (status: TaskStatus, data?: any) => void; // 进度回调
|
||||
onSuccess?: (result: any) => void; // 成功回调
|
||||
onError?: (error: Error) => void; // 错误回调
|
||||
onTimeout?: () => void; // 超时回调
|
||||
}
|
||||
```
|
||||
|
||||
### AsyncTaskManager 方法
|
||||
|
||||
```typescript
|
||||
class AsyncTaskManager {
|
||||
// 启动新任务
|
||||
async startTask(config: TaskConfig): Promise<void>
|
||||
|
||||
// 取消任务
|
||||
async cancelTask(taskId: string): Promise<void>
|
||||
|
||||
// 检查任务是否存在
|
||||
hasTask(taskId: string): boolean
|
||||
|
||||
// 获取任务状态
|
||||
getTaskStatus(taskId: string): TaskStatus | null
|
||||
|
||||
// 暂停所有轮询(应用切后台时调用)
|
||||
pausePolling(): void
|
||||
|
||||
// 激活所有轮询(应用恢复前台时调用)
|
||||
activatePolling(): void
|
||||
|
||||
// 恢复任务(应用启动时调用)
|
||||
async resumeTasks(): Promise<void>
|
||||
|
||||
// 设置状态检查函数(用于自定义API调用)
|
||||
setStatusChecker(checker: (taskId: string) => Promise<TaskStatus>): void
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 1. 图像处理任务
|
||||
|
||||
```typescript
|
||||
const handleImageProcess = async (imageUrl: string) => {
|
||||
const config: TaskConfig = {
|
||||
taskId: await submitImageToAPI(imageUrl),
|
||||
pollingInterval: 2000, // 2秒轮询
|
||||
maxRetries: 3,
|
||||
timeout: 180000, // 3分钟超时
|
||||
onProgress: (status) => {
|
||||
switch (status) {
|
||||
case TaskStatus.RUNNING:
|
||||
Taro.showLoading({ title: '处理中...', mask: true });
|
||||
break;
|
||||
case TaskStatus.SUCCESS:
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({ title: '处理完成', icon: 'success' });
|
||||
break;
|
||||
}
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// 显示处理结果
|
||||
setResultImage(result.outputUrl);
|
||||
},
|
||||
onError: (error) => {
|
||||
Taro.showToast({ title: '处理失败', icon: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 文件上传任务
|
||||
|
||||
```typescript
|
||||
const handleFileUpload = async (filePath: string) => {
|
||||
const config: TaskConfig = {
|
||||
taskId: await startUploadTask(filePath),
|
||||
pollingInterval: 1000, // 1秒轮询
|
||||
maxRetries: 5, // 上传可能网络不稳定,多重试几次
|
||||
timeout: 600000, // 10分钟超时
|
||||
onProgress: (status, data) => {
|
||||
if (data?.progress) {
|
||||
setUploadProgress(data.progress);
|
||||
}
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
console.log('上传完成:', result.fileUrl);
|
||||
}
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 数据分析任务
|
||||
|
||||
```typescript
|
||||
const handleDataAnalysis = async (datasetId: string) => {
|
||||
const config: TaskConfig = {
|
||||
taskId: await submitAnalysisJob(datasetId),
|
||||
pollingInterval: 5000, // 5秒轮询(分析任务通常较慢)
|
||||
maxRetries: 2,
|
||||
timeout: 1800000, // 30分钟超时
|
||||
onProgress: (status) => {
|
||||
updateAnalysisStatus(status);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
displayAnalysisReport(result);
|
||||
}
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
};
|
||||
```
|
||||
|
||||
## 🔄 生命周期集成
|
||||
|
||||
### 在 App.tsx 中集成
|
||||
|
||||
```typescript
|
||||
import { useAsyncTaskManager } from '@/hooks/useAsyncTaskManager';
|
||||
|
||||
function App({ children }) {
|
||||
const taskManager = useAsyncTaskManager();
|
||||
|
||||
useLaunch(() => {
|
||||
// 应用启动时恢复任务
|
||||
taskManager?.resumeTasks();
|
||||
});
|
||||
|
||||
useDidShow(() => {
|
||||
// 应用显示时激活轮询
|
||||
taskManager?.activatePolling();
|
||||
});
|
||||
|
||||
useDidHide(() => {
|
||||
// 应用隐藏时暂停轮询
|
||||
taskManager?.pausePolling();
|
||||
});
|
||||
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
npm test AsyncTaskManager
|
||||
```
|
||||
|
||||
测试覆盖了以下场景:
|
||||
- ✅ 任务创建和状态管理
|
||||
- ✅ 轮询机制和定时器控制
|
||||
- ✅ 回调函数调用
|
||||
- ✅ 超时处理
|
||||
- ✅ 持久化存储
|
||||
- ✅ 错误处理和重试
|
||||
- ✅ 任务暂停和恢复
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 小程序环境限制
|
||||
|
||||
- 小程序切后台5分钟后会被暂停,长时间任务需要提醒用户保持前台
|
||||
- 网络请求有并发限制,避免创建过多同时运行的任务
|
||||
|
||||
### 2. 性能优化
|
||||
|
||||
```typescript
|
||||
// ❌ 避免过短的轮询间隔
|
||||
const badConfig = {
|
||||
pollingInterval: 100 // 100ms 太频繁
|
||||
};
|
||||
|
||||
// ✅ 合理的轮询间隔
|
||||
const goodConfig = {
|
||||
pollingInterval: 3000 // 3秒比较合适
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 错误处理
|
||||
|
||||
```typescript
|
||||
const config: TaskConfig = {
|
||||
// ... 其他配置
|
||||
onError: (error) => {
|
||||
// 根据错误类型进行不同处理
|
||||
if (error.message.includes('网络')) {
|
||||
Taro.showToast({ title: '网络连接异常', icon: 'none' });
|
||||
} else if (error.message.includes('授权')) {
|
||||
// 跳转到授权页面
|
||||
} else {
|
||||
Taro.showToast({ title: '处理失败,请重试', icon: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 状态检查函数
|
||||
|
||||
```typescript
|
||||
taskManager.setStatusChecker(async (taskId: string) => {
|
||||
try {
|
||||
const response = await Taro.request({
|
||||
url: `${API_BASE}/tasks/${taskId}/status`,
|
||||
method: 'GET',
|
||||
timeout: 10000 // 设置超时
|
||||
});
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
return response.data.status as TaskStatus;
|
||||
}
|
||||
|
||||
throw new Error(`HTTP ${response.statusCode}`);
|
||||
} catch (error) {
|
||||
console.error('状态检查失败:', error);
|
||||
throw error; // 重要:要抛出错误以触发重试机制
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 📈 最佳实践
|
||||
|
||||
1. **合理设置轮询间隔**: 根据任务类型调整,图像处理可以 2-3 秒,数据分析可以 5-10 秒
|
||||
2. **设置适当超时**: 不要设置过短的超时时间,给任务足够的处理时间
|
||||
3. **错误分类处理**: 区分网络错误、业务错误、授权错误等,给用户明确提示
|
||||
4. **用户体验优化**: 在关键状态变化时给用户及时反馈
|
||||
5. **资源清理**: 页面卸载时记得取消不再需要的任务
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request 来帮助改进这个工具!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
329
src/utils/AsyncTaskManager.ts
Normal file
329
src/utils/AsyncTaskManager.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Taro 存储接口
|
||||
*/
|
||||
interface StorageInterface {
|
||||
setStorage: (options: { key: string; data: any }) => Promise<any>;
|
||||
getStorage: (options: { key: string }) => Promise<{ data: any }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务状态枚举
|
||||
*/
|
||||
export enum TaskStatus {
|
||||
PENDING = 'pending', // 待开始
|
||||
RUNNING = 'running', // 执行中
|
||||
SUCCESS = 'success', // 成功
|
||||
FAILED = 'failed', // 失败
|
||||
TIMEOUT = 'timeout', // 超时
|
||||
CANCELLED = 'cancelled' // 已取消
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务配置接口
|
||||
*/
|
||||
export interface TaskConfig {
|
||||
taskId: string; // 任务ID
|
||||
pollingInterval: number; // 轮询间隔(毫秒)
|
||||
maxRetries: number; // 最大重试次数
|
||||
timeout: number; // 超时时间(毫秒)
|
||||
onProgress?: (status: TaskStatus, data?: any) => void;
|
||||
onSuccess?: (result: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onTimeout?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务数据结构
|
||||
*/
|
||||
export interface TaskData {
|
||||
id: string;
|
||||
status: TaskStatus;
|
||||
startTime: number;
|
||||
retryCount: number;
|
||||
lastPollTime: number;
|
||||
result?: any;
|
||||
error?: string;
|
||||
config: TaskConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态检查函数类型
|
||||
*/
|
||||
type StatusChecker = (taskId: string) => Promise<TaskStatus>;
|
||||
|
||||
/**
|
||||
* 异步任务管理器
|
||||
* 专为 Taro 环境设计的异步任务管理工具
|
||||
*/
|
||||
export class AsyncTaskManager {
|
||||
private activeTasks: Map<string, TaskData> = new Map();
|
||||
private pollingTimers: Map<string, number> = new Map();
|
||||
private statusChecker?: StatusChecker;
|
||||
private isPaused: boolean = false;
|
||||
private storage?: StorageInterface;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param storage 存储接口实现(测试时可注入mock)
|
||||
*/
|
||||
constructor(storage?: StorageInterface) {
|
||||
this.storage = storage;
|
||||
|
||||
// 如果没有注入存储,尝试使用真实的 Taro
|
||||
if (!this.storage && typeof window !== 'undefined') {
|
||||
try {
|
||||
const Taro = require('@tarojs/taro');
|
||||
this.storage = {
|
||||
setStorage: Taro.setStorage,
|
||||
getStorage: Taro.getStorage
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('无法加载 Taro,存储功能将不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态检查函数(用于测试和自定义状态检查)
|
||||
*/
|
||||
setStatusChecker(checker: StatusChecker) {
|
||||
this.statusChecker = checker;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动新任务
|
||||
*/
|
||||
async startTask(config: TaskConfig): Promise<void> {
|
||||
const taskData: TaskData = {
|
||||
id: config.taskId,
|
||||
status: TaskStatus.RUNNING,
|
||||
startTime: Date.now(),
|
||||
retryCount: 0,
|
||||
lastPollTime: Date.now(),
|
||||
config
|
||||
};
|
||||
|
||||
this.activeTasks.set(config.taskId, taskData);
|
||||
await this.saveTaskToStorage(taskData);
|
||||
|
||||
if (!this.isPaused) {
|
||||
this.startPolling(config.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否存在
|
||||
*/
|
||||
hasTask(taskId: string): boolean {
|
||||
return this.activeTasks.has(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
getTaskStatus(taskId: string): TaskStatus | null {
|
||||
const task = this.activeTasks.get(taskId);
|
||||
return task ? task.status : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
*/
|
||||
async cancelTask(taskId: string): Promise<void> {
|
||||
const task = this.activeTasks.get(taskId);
|
||||
if (task) {
|
||||
task.status = TaskStatus.CANCELLED;
|
||||
this.stopPolling(taskId);
|
||||
await this.saveTaskToStorage(task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始轮询任务状态
|
||||
*/
|
||||
private startPolling(taskId: string): void {
|
||||
const task = this.activeTasks.get(taskId);
|
||||
if (!task || this.isPaused) return;
|
||||
|
||||
// 先清除现有的定时器
|
||||
this.stopPolling(taskId);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
await this.pollTaskStatus(taskId);
|
||||
}, task.config.pollingInterval);
|
||||
|
||||
this.pollingTimers.set(taskId, timer as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
private stopPolling(taskId: string): void {
|
||||
const timer = this.pollingTimers.get(taskId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.pollingTimers.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
*/
|
||||
private async pollTaskStatus(taskId: string): Promise<void> {
|
||||
const task = this.activeTasks.get(taskId);
|
||||
if (!task || task.status !== TaskStatus.RUNNING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查超时
|
||||
if (Date.now() - task.startTime > task.config.timeout) {
|
||||
task.status = TaskStatus.TIMEOUT;
|
||||
this.stopPolling(taskId);
|
||||
task.config.onTimeout?.();
|
||||
await this.saveTaskToStorage(task);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let status: TaskStatus;
|
||||
|
||||
if (this.statusChecker) {
|
||||
status = await this.statusChecker(taskId);
|
||||
} else {
|
||||
status = TaskStatus.RUNNING; // 默认状态
|
||||
}
|
||||
|
||||
task.status = status;
|
||||
task.lastPollTime = Date.now();
|
||||
task.retryCount = 0; // 重置重试计数
|
||||
|
||||
// 触发进度回调
|
||||
task.config.onProgress?.(status);
|
||||
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
task.config.onSuccess?.(task.result);
|
||||
await this.saveTaskToStorage(task);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === TaskStatus.FAILED) {
|
||||
task.config.onError?.(new Error('Task failed'));
|
||||
await this.saveTaskToStorage(task);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果仍在运行,继续轮询
|
||||
if (status === TaskStatus.RUNNING && !this.isPaused) {
|
||||
this.startPolling(taskId);
|
||||
}
|
||||
|
||||
await this.saveTaskToStorage(task);
|
||||
|
||||
} catch (error) {
|
||||
task.retryCount++;
|
||||
|
||||
if (task.retryCount >= task.config.maxRetries) {
|
||||
task.status = TaskStatus.FAILED;
|
||||
task.error = error instanceof Error ? error.message : String(error);
|
||||
task.config.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
await this.saveTaskToStorage(task);
|
||||
} else {
|
||||
// 重试
|
||||
if (!this.isPaused) {
|
||||
this.startPolling(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停所有任务的轮询
|
||||
*/
|
||||
pausePolling(): void {
|
||||
this.isPaused = true;
|
||||
for (const taskId of this.pollingTimers.keys()) {
|
||||
this.stopPolling(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活所有任务的轮询
|
||||
*/
|
||||
activatePolling(): void {
|
||||
this.isPaused = false;
|
||||
for (const [taskId, task] of this.activeTasks) {
|
||||
if (task.status === TaskStatus.RUNNING) {
|
||||
this.startPolling(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复任务(从存储中加载)
|
||||
*/
|
||||
async resumeTasks(): Promise<void> {
|
||||
try {
|
||||
const storedTasks = await this.loadTasksFromStorage();
|
||||
|
||||
for (const taskData of storedTasks) {
|
||||
this.activeTasks.set(taskData.id, taskData);
|
||||
|
||||
// 只恢复运行中的任务
|
||||
if (taskData.status === TaskStatus.RUNNING && !this.isPaused) {
|
||||
this.startPolling(taskData.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存任务到本地存储
|
||||
*/
|
||||
private async saveTaskToStorage(task: TaskData): Promise<void> {
|
||||
if (!this.storage) {
|
||||
console.warn('存储接口不可用,跳过保存');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingTasks = await this.loadTasksFromStorage();
|
||||
const tasksMap = existingTasks.reduce((map, t) => {
|
||||
map[t.id] = t;
|
||||
return map;
|
||||
}, {} as Record<string, TaskData>);
|
||||
|
||||
tasksMap[task.id] = {
|
||||
...task,
|
||||
lastSaveTime: Date.now()
|
||||
} as any;
|
||||
|
||||
await this.storage.setStorage({
|
||||
key: 'async_tasks',
|
||||
data: tasksMap
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储加载任务
|
||||
*/
|
||||
private async loadTasksFromStorage(): Promise<TaskData[]> {
|
||||
if (!this.storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.storage.getStorage({ key: 'async_tasks' });
|
||||
if (result.data && typeof result.data === 'object') {
|
||||
return Object.values(result.data) as TaskData[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
359
src/utils/__tests__/AsyncTaskManager.test.ts
Normal file
359
src/utils/__tests__/AsyncTaskManager.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
// Mock Taro 在导入之前
|
||||
import { AsyncTaskManager, TaskStatus, TaskConfig } from '../AsyncTaskManager';
|
||||
|
||||
const mockTaro = {
|
||||
request: jest.fn(),
|
||||
setStorage: jest.fn().mockResolvedValue({}),
|
||||
getStorage: jest.fn().mockResolvedValue({ data: {} }),
|
||||
removeStorage: jest.fn().mockResolvedValue({})
|
||||
};
|
||||
|
||||
// Mock '@tarojs/taro' 模块
|
||||
jest.mock('@tarojs/taro', () => mockTaro);
|
||||
|
||||
describe('AsyncTaskManager', () => {
|
||||
let taskManager: AsyncTaskManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
// 确保 mock 函数正确重置
|
||||
mockTaro.setStorage.mockResolvedValue({});
|
||||
mockTaro.getStorage.mockResolvedValue({ data: {} });
|
||||
|
||||
// 注入 storage mock
|
||||
taskManager = new AsyncTaskManager(mockTaro);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('任务提交和状态管理', () => {
|
||||
it('应该能创建新任务', async () => {
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-task-001',
|
||||
pollingInterval: 2000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
expect(taskManager.hasTask('test-task-001')).toBe(true);
|
||||
expect(taskManager.getTaskStatus('test-task-001')).toBe(TaskStatus.RUNNING);
|
||||
});
|
||||
|
||||
it('应该能获取任务状态', async () => {
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-task-002',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 2,
|
||||
timeout: 30000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
const status = taskManager.getTaskStatus('test-task-002');
|
||||
expect(status).toBe(TaskStatus.RUNNING);
|
||||
});
|
||||
|
||||
it('应该能取消正在运行的任务', async () => {
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-task-003',
|
||||
pollingInterval: 2000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
await taskManager.cancelTask('test-task-003');
|
||||
|
||||
expect(taskManager.getTaskStatus('test-task-003')).toBe(TaskStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('轮询机制', () => {
|
||||
it('应该开始轮询任务状态', async () => {
|
||||
const mockStatusCheck = jest.fn().mockResolvedValue(TaskStatus.RUNNING);
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-poll-001',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
// 快进1秒触发轮询
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve(); // 等待异步操作
|
||||
|
||||
expect(mockStatusCheck).toHaveBeenCalledWith('test-poll-001');
|
||||
});
|
||||
|
||||
it('应该在任务完成时停止轮询', async () => {
|
||||
const mockStatusCheck = jest.fn()
|
||||
.mockResolvedValueOnce(TaskStatus.RUNNING)
|
||||
.mockResolvedValueOnce(TaskStatus.SUCCESS);
|
||||
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-poll-002',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
// 触发两次轮询
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(taskManager.getTaskStatus('test-poll-002')).toBe(TaskStatus.SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回调处理', () => {
|
||||
it('应该在任务成功时调用成功回调', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const mockStatusCheck = jest.fn().mockResolvedValue(TaskStatus.SUCCESS);
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-callback-001',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000,
|
||||
onSuccess
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在任务失败时调用错误回调', async () => {
|
||||
const onError = jest.fn();
|
||||
const mockStatusCheck = jest.fn().mockResolvedValue(TaskStatus.FAILED);
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-callback-002',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000,
|
||||
onError
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在进度更新时调用进度回调', async () => {
|
||||
const onProgress = jest.fn();
|
||||
const mockStatusCheck = jest.fn().mockResolvedValue(TaskStatus.RUNNING);
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-callback-003',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000,
|
||||
onProgress
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(TaskStatus.RUNNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('超时处理', () => {
|
||||
it('应该在超时后将任务标记为超时状态', async () => {
|
||||
const onTimeout = jest.fn();
|
||||
const mockStatusCheck = jest.fn().mockResolvedValue(TaskStatus.RUNNING);
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-timeout-001',
|
||||
pollingInterval: 100,
|
||||
maxRetries: 3,
|
||||
timeout: 150, // 150毫秒超时
|
||||
onTimeout
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
// 快进到第一次轮询时间
|
||||
jest.advanceTimersByTime(100);
|
||||
await Promise.resolve();
|
||||
|
||||
// 再快进到超时时间
|
||||
jest.advanceTimersByTime(100);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(taskManager.getTaskStatus('test-timeout-001')).toBe(TaskStatus.TIMEOUT);
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
}, 2000); // 2秒超时限制
|
||||
});
|
||||
|
||||
describe('持久化存储', () => {
|
||||
it('应该将任务状态保存到本地存储', async () => {
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-storage-001',
|
||||
pollingInterval: 2000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
expect(mockTaro.setStorage).toHaveBeenCalledWith({
|
||||
key: 'async_tasks',
|
||||
data: expect.objectContaining({
|
||||
'test-storage-001': expect.objectContaining({
|
||||
id: 'test-storage-001',
|
||||
status: TaskStatus.RUNNING
|
||||
})
|
||||
})
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
it('应该从本地存储恢复任务', async () => {
|
||||
const storedTasks = {
|
||||
'restored-task-001': {
|
||||
id: 'restored-task-001',
|
||||
status: TaskStatus.RUNNING,
|
||||
startTime: Date.now() - 10000,
|
||||
retryCount: 0,
|
||||
lastPollTime: Date.now() - 5000,
|
||||
config: {
|
||||
taskId: 'restored-task-001',
|
||||
pollingInterval: 2000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockTaro.getStorage.mockResolvedValueOnce({ data: storedTasks });
|
||||
|
||||
await taskManager.resumeTasks();
|
||||
|
||||
expect(taskManager.hasTask('restored-task-001')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理和重试', () => {
|
||||
it('应该在网络错误时进行重试', async () => {
|
||||
const mockStatusCheck = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('网络错误'))
|
||||
.mockRejectedValueOnce(new Error('网络错误'))
|
||||
.mockResolvedValueOnce(TaskStatus.SUCCESS);
|
||||
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-retry-001',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
// 触发多次轮询以测试重试
|
||||
for (let i = 0; i < 3; i++) {
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(mockStatusCheck).toHaveBeenCalledTimes(3);
|
||||
expect(taskManager.getTaskStatus('test-retry-001')).toBe(TaskStatus.SUCCESS);
|
||||
});
|
||||
|
||||
it('应该在达到最大重试次数后标记为失败', async () => {
|
||||
const mockStatusCheck = jest.fn().mockRejectedValue(new Error('持续网络错误'));
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-retry-002',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 2,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
|
||||
// 触发足够的轮询次数以超过重试限制
|
||||
for (let i = 0; i < 4; i++) {
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(taskManager.getTaskStatus('test-retry-002')).toBe(TaskStatus.FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('任务暂停和恢复', () => {
|
||||
it('应该能暂停所有活跃任务的轮询', async () => {
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-pause-001',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
taskManager.pausePolling();
|
||||
|
||||
const mockStatusCheck = jest.fn();
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
// 即使时间前进,暂停状态下不应该触发轮询
|
||||
jest.advanceTimersByTime(2000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockStatusCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该能恢复所有任务的轮询', async () => {
|
||||
const mockStatusCheck = jest.fn().mockResolvedValue(TaskStatus.RUNNING);
|
||||
taskManager.setStatusChecker(mockStatusCheck);
|
||||
|
||||
const config: TaskConfig = {
|
||||
taskId: 'test-resume-001',
|
||||
pollingInterval: 1000,
|
||||
maxRetries: 3,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
await taskManager.startTask(config);
|
||||
taskManager.pausePolling();
|
||||
taskManager.activatePolling();
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockStatusCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/utils/imageUploader.ts
Normal file
168
src/utils/imageUploader.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
/**
|
||||
* 上传参数接口
|
||||
*/
|
||||
export interface UploadParams {
|
||||
filePath: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* API响应接口
|
||||
*/
|
||||
interface ApiResponse<T = any> {
|
||||
status: boolean;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息成功回调结果类型守卫
|
||||
*/
|
||||
function isGetFileInfoSuccessCallbackResult(
|
||||
result: any
|
||||
): result is Taro.getFileInfo.SuccessCallbackResult {
|
||||
return result && typeof result.size === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传工具类
|
||||
*/
|
||||
export class ImageUploader {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = 'https://bowongai-test--text-video-agent-fastapi-app.modal.run') {
|
||||
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async upload(params: UploadParams): Promise<string> {
|
||||
const { filePath, name, type, onProgress } = params;
|
||||
const url = `${this.baseUrl}/api/file/upload/s3`;
|
||||
|
||||
try {
|
||||
// 获取文件信息
|
||||
let fileInfo;
|
||||
try {
|
||||
fileInfo = await Taro.getFileInfo({ filePath });
|
||||
if (!isGetFileInfoSuccessCallbackResult(fileInfo)) {
|
||||
console.warn('获取文件信息失败,使用默认值');
|
||||
fileInfo = { size: 0 };
|
||||
}
|
||||
} catch (getFileInfoError) {
|
||||
console.warn('getFileInfo调用失败,使用默认值:', getFileInfoError);
|
||||
fileInfo = { size: 0 };
|
||||
}
|
||||
|
||||
// 自动生成文件名(如果未提供)
|
||||
const fileName = name || `upload_${Date.now()}.${this._getFileExtension(filePath)}`;
|
||||
|
||||
// 自动判断文件类型(如果未提供)
|
||||
const fileType = type || this._getMimeType(filePath);
|
||||
|
||||
console.log('开始上传文件:', {
|
||||
fileName,
|
||||
fileSize: fileInfo.size,
|
||||
fileType
|
||||
});
|
||||
|
||||
// 使用 Taro.uploadFile 进行文件上传
|
||||
const response = await new Promise<Taro.uploadFile.SuccessCallbackResult>((resolve, reject) => {
|
||||
const uploadTask = Taro.uploadFile({
|
||||
url,
|
||||
filePath,
|
||||
name: 'file', // 服务端接收的字段名
|
||||
header: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
formData: {
|
||||
filename: fileName,
|
||||
type: fileType
|
||||
},
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
|
||||
// 监听上传进度
|
||||
if (onProgress) {
|
||||
uploadTask.progress((res) => {
|
||||
const progress = Math.round((res.totalBytesSent / res.totalBytesExpectedToSend) * 100);
|
||||
onProgress(progress);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
if (response.statusCode !== 200) {
|
||||
throw new Error(`HTTP ${response.statusCode}: 文件上传失败`);
|
||||
}
|
||||
|
||||
// 解析响应数据
|
||||
let result: ApiResponse<string>;
|
||||
try {
|
||||
result = JSON.parse(response.data) as ApiResponse<string>;
|
||||
} catch (parseError) {
|
||||
throw new Error('服务器响应格式错误');
|
||||
}
|
||||
|
||||
// 检查业务状态码
|
||||
if (!result.status) {
|
||||
throw new Error(result.msg || '文件上传失败');
|
||||
}
|
||||
|
||||
console.log('文件上传成功:', result.data);
|
||||
return result.data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
private _getFileExtension(filePath: string): string {
|
||||
const parts = filePath.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取MIME类型
|
||||
*/
|
||||
private _getMimeType(filePath: string): string {
|
||||
const ext = this._getFileExtension(filePath);
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml'
|
||||
};
|
||||
return mimeTypes[ext] || 'image/jpeg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置基础URL
|
||||
*/
|
||||
setBaseUrl(baseUrl: string): void {
|
||||
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础URL
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const imageUploader = new ImageUploader();
|
||||
Reference in New Issue
Block a user