feat(friends-photo): add Friends Photo page with image upload and AI generation functionality

- Introduced a new page for generating a combined photo from two uploaded images.
- Implemented image upload logic with audit handling and user feedback.
- Added CSS styles for the new page layout and components.
- Updated app configuration to include the new Friends Photo page in the navigation.
This commit is contained in:
iHeyTang
2025-09-26 16:44:22 +08:00
parent 9d890478d8
commit 4c5b910f61
7 changed files with 991 additions and 195 deletions

View File

@@ -3,6 +3,7 @@ export default defineAppConfig({
'pages/home/index', // 新的模板卡片首页
'pages/history/index', // 历史记录页面
'pages/result/index',
'pages/friends-photo/index', // 好友合照页面
],
tabBar: {
color: '#8E9BAE',

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '好友合照',
enableShareAppMessage: true,
enableShareTimeline: true,
});

View File

@@ -0,0 +1,427 @@
/* 页面容器 */
.friends-photo {
height: 100vh;
background: #f5f6f8;
display: flex;
flex-direction: column;
position: relative;
}
/* 滚动容器 */
.friends-photo-scroll {
flex: 1;
height: 100%;
padding-bottom: 100px; /* 为固定底部按钮留空间 */
}
/* 介绍卡片 */
.intro-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 20px;
border-radius: 20px;
padding: 24px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}
.intro-card::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.intro-content {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
.intro-icon {
font-size: 36px;
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 12px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.intro-text {
flex: 1;
}
.intro-title {
font-size: 20px;
font-weight: 600;
color: white;
margin-bottom: 4px;
}
.intro-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
line-height: 1.4;
}
/* 上传区域 */
.upload-section {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 上传卡片 */
.upload-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s ease;
}
.upload-card:active {
transform: scale(0.98);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.upload-card-content {
padding: 20px;
}
/* 上传信息 */
.upload-info {
margin-bottom: 16px;
}
.upload-title {
font-size: 16px;
font-weight: 600;
color: #1d1f22;
margin-bottom: 4px;
}
.upload-description {
font-size: 14px;
color: #8e9bae;
}
/* 上传区域 */
.upload-area {
width: 100%;
height: 160px;
border: 2px dashed #e5e7eb;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: #fafbfc;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.upload-area:active {
transform: scale(0.98);
}
.upload-area.has-image {
border: 2px solid #3b82f6;
background: transparent;
}
/* 上传占位符 */
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.upload-icon {
font-size: 32px;
color: #8e9bae;
}
.upload-hint {
font-size: 14px;
color: #8e9bae;
font-weight: 500;
}
/* 上传的图片 */
.upload-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
/* 图片覆盖层 */
.image-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
backdrop-filter: blur(4px);
}
.upload-area.has-image:active .image-overlay {
opacity: 1;
}
.overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.change-icon {
font-size: 24px;
color: white;
}
.change-text {
color: white;
font-size: 14px;
font-weight: 500;
}
/* 预览卡片 */
.preview-card {
background: white;
margin: 24px 20px 0;
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.preview-header {
margin-bottom: 16px;
}
.preview-title {
font-size: 16px;
font-weight: 600;
color: #1d1f22;
margin-bottom: 4px;
}
.preview-subtitle {
font-size: 14px;
color: #8e9bae;
}
/* 预览网格 */
.preview-grid {
display: flex;
align-items: center;
gap: 16px;
}
.preview-item {
flex: 1;
position: relative;
}
.preview-image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.preview-label {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 6px;
backdrop-filter: blur(4px);
}
.preview-connector {
font-size: 20px;
font-weight: bold;
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* 底部间距 */
.bottom-spacer {
height: 20px;
}
/* 提交区域 */
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background: white;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
z-index: 10;
}
/* 提交按钮 */
.submit-button {
width: 100%;
height: 56px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border-radius: 28px;
border: none;
color: white;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s ease;
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
position: relative;
overflow: hidden;
}
.submit-button::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transform: rotate(45deg);
transition: transform 0.6s ease;
}
.submit-button:not(.disabled):active::before {
transform: rotate(45deg) translateX(100%);
}
.submit-button.disabled {
background: #d1d5db;
box-shadow: none;
cursor: not-allowed;
}
.submit-button:not(.disabled):active {
transform: translateY(2px);
box-shadow: 0 3px 16px rgba(59, 130, 246, 0.4);
}
.submit-icon {
font-size: 18px;
}
.submit-text {
font-weight: 600;
}
/* 加载动画 */
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 响应式适配 */
@media (max-width: 375px) {
.intro-card {
margin: 16px;
padding: 20px;
}
.upload-section {
padding: 0 16px;
}
.preview-card {
margin: 20px 16px 0;
}
.submit-section {
padding: 16px;
}
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.friends-photo {
background: #1a1a1a;
}
.upload-card,
.preview-card {
background: #2a2a2a;
border: 1px solid #333;
}
.upload-title {
color: #fff;
}
.upload-description {
color: #999;
}
.upload-area {
background: #333;
border-color: #444;
}
.submit-section {
background: #2a2a2a;
border-color: #333;
}
}

View File

@@ -0,0 +1,322 @@
import { View, Image, Button, ScrollView } from '@tarojs/components';
import { useState, useEffect } from 'react';
import Taro, { navigateTo } from '@tarojs/taro';
import { useSdk, useServerSdk } from '../../hooks/index';
import { useImageDetectionTaskManager, ImageAuditResult, AuditConclusion } from '../../hooks/useImageDetectionTaskManager';
import './index.css';
// 好友合照模板的固定代码
const FRIENDS_PHOTO_TEMPLATE_CODE = 'friends_photo';
export default function FriendsPhoto() {
const sdk = useSdk();
const serverSdk = useServerSdk();
const { submitAuditTask } = useImageDetectionTaskManager();
const [image1, setImage1] = useState<string>('');
const [image2, setImage2] = useState<string>('');
const [loading, setLoading] = useState(false);
const [showGuide, setShowGuide] = useState(true);
// 显示使用指南
useEffect(() => {
if (!showGuide) return;
const timer = setTimeout(() => {
Taro.showModal({
title: '使用小贴士',
content: '• 选择清晰的人脸照片效果更佳\n• 建议选择光线充足的照片\n• 支持JPG、PNG格式',
confirmText: '我知道了',
showCancel: false,
success: () => {
setShowGuide(false);
}
});
}, 1000);
return () => clearTimeout(timer);
}, [showGuide]);
// 处理审核失败
const handleAuditFailure = (auditResult: ImageAuditResult) => {
const messages: Record<AuditConclusion, string> = {
[AuditConclusion.REJECT]: '图片内容不符合规范,请重新选择符合规范的图片',
[AuditConclusion.REVIEW]: '图片需要人工审核,请稍后重试或更换其他图片',
[AuditConclusion.UNCERTAIN]: '图片审核结果不确定,请重新选择图片',
[AuditConclusion.PASS]: '图片审核通过',
};
const message = auditResult.conclusion ? messages[auditResult.conclusion] : '图片审核失败,请重新选择图片';
Taro.showModal({
title: '图片审核未通过',
content: message,
confirmText: '我知道了',
showCancel: false,
});
};
// 上传单张图片
const uploadSingleImage = async (imageIndex: 1 | 2) => {
try {
// 添加触觉反馈
Taro.vibrateShort();
Taro.showLoading({
title: '选择图片中...',
mask: true,
});
const imageUrl = await sdk.chooseAndUploadImage({
count: 1,
onImageSelected: () => {
Taro.showLoading({
title: '正在上传...',
mask: true,
});
},
onProgress: () => {
Taro.showLoading({
title: '上传中...',
mask: true,
});
},
});
// 审核图片
const auditResult = await new Promise<ImageAuditResult>((resolve, reject) => {
submitAuditTask(
{
imageUrl,
options: {
enableCache: true,
timeout: 30000,
},
},
(status, progress) => {
console.log('审核进度:', status, progress);
},
result => {
console.log('审核成功:', result);
resolve(result);
},
error => {
console.error('审核失败:', error);
reject(error);
}
);
});
Taro.hideLoading();
if (auditResult.conclusion === AuditConclusion.PASS) {
if (imageIndex === 1) {
setImage1(imageUrl);
} else {
setImage2(imageUrl);
}
// 成功反馈
Taro.vibrateShort();
Taro.showToast({
title: `图片${imageIndex}上传成功`,
icon: 'success',
duration: 1500,
});
} else {
handleAuditFailure(auditResult);
}
} catch (error: any) {
Taro.hideLoading();
// 用户取消选择图片,不显示错误提示
if (error.errMsg && error.errMsg.includes('chooseImage:fail cancel')) {
return;
}
// 提供更详细的错误提示
let errorTitle = '图片上传失败';
if (error?.message) {
errorTitle = error.message;
} else if (error?.errMsg) {
if (error.errMsg.includes('chooseImage:fail auth')) {
errorTitle = '需要授权访问相册';
} else if (error.errMsg.includes('timeout')) {
errorTitle = '上传超时,请重试';
} else if (error.errMsg.includes('network')) {
errorTitle = '网络连接失败';
} else {
errorTitle = '图片上传失败,请重试';
}
}
Taro.showToast({
title: errorTitle,
icon: 'error',
duration: 3000,
});
console.error('图片上传详细错误:', error);
}
};
// 提交处理
const handleSubmit = async () => {
if (!image1 || !image2) {
Taro.vibrateShort();
Taro.showToast({
title: '请先上传两张图片',
icon: 'none',
duration: 2000,
});
return;
}
// 提交时的触觉反馈
Taro.vibrateShort();
setLoading(true);
try {
Taro.showLoading({
title: 'AI正在生成中...',
mask: true,
});
// 将两个图片URL用逗号分隔
const combinedImageUrl = `${image1},${image2}`;
const taskId = await serverSdk.executeTemplate({
imageUrl: combinedImageUrl,
templateCode: FRIENDS_PHOTO_TEMPLATE_CODE,
});
Taro.hideLoading();
// 跳转到结果页面
navigateTo({
url: `/pages/result/index?taskId=${taskId}&templateCode=${FRIENDS_PHOTO_TEMPLATE_CODE}`,
});
} catch (error) {
Taro.hideLoading();
console.error('提交失败:', error);
Taro.showToast({
title: '请等待生成中的任务完成后再尝试',
icon: 'none',
duration: 2000,
});
} finally {
setLoading(false);
}
};
const renderUploadCard = (imageIndex: 1 | 2, imageUrl: string, title: string, description: string) => (
<View className="upload-card">
<View className="upload-card-content">
<View className="upload-info">
<View className="upload-title">{title}</View>
<View className="upload-description">{description}</View>
</View>
<View
className={`upload-area ${imageUrl ? 'has-image' : ''}`}
onClick={() => uploadSingleImage(imageIndex)}
>
{imageUrl ? (
<>
<Image src={imageUrl} className="upload-image" mode="aspectFill" />
<View className="image-overlay">
<View className="overlay-content">
<View className="change-icon">🔄</View>
<View className="change-text"></View>
</View>
</View>
</>
) : (
<View className="upload-placeholder">
<View className="upload-icon">📷</View>
<View className="upload-hint"></View>
</View>
)}
</View>
</View>
</View>
);
return (
<View className="friends-photo">
<ScrollView
className="friends-photo-scroll"
scrollY
enhanced
showScrollbar={false}
enablePassive
bounces
>
{/* 顶部介绍卡片 */}
<View className="intro-card">
<View className="intro-content">
<View className="intro-icon">👫</View>
<View className="intro-text">
<View className="intro-title"></View>
<View className="intro-subtitle">
AI将为你们生成精彩的合照视频
</View>
</View>
</View>
</View>
{/* 上传区域 */}
<View className="upload-section">
{renderUploadCard(1, image1, "第一张照片", "选择你的照片")}
{renderUploadCard(2, image2, "第二张照片", "选择朋友的照片")}
</View>
{/* 预览区域 */}
{image1 && image2 && (
<View className="preview-card">
<View className="preview-header">
<View className="preview-title">📸 </View>
<View className="preview-subtitle"></View>
</View>
<View className="preview-grid">
<View className="preview-item">
<Image src={image1} className="preview-image" mode="aspectFill" />
<View className="preview-label"> 1</View>
</View>
<View className="preview-connector">+</View>
<View className="preview-item">
<Image src={image2} className="preview-image" mode="aspectFill" />
<View className="preview-label"> 2</View>
</View>
</View>
</View>
)}
{/* 底部间距 */}
<View className="bottom-spacer" />
</ScrollView>
{/* 固定底部按钮 */}
<View className="submit-section">
<Button
className={`submit-button ${(!image1 || !image2 || loading) ? 'disabled' : ''}`}
disabled={!image1 || !image2 || loading}
onClick={handleSubmit}
>
{loading ? (
<>
<View className="loading-spinner" />
<View className="submit-text">...</View>
</>
) : (
<>
<View className="submit-icon"></View>
<View className="submit-text"></View>
</>
)}
</Button>
</View>
</View>
);
}

View File

@@ -1,4 +1,4 @@
import { View, ScrollView, Canvas } from '@tarojs/components';
import { View, ScrollView } from '@tarojs/components';
import { useEffect, useState } from 'react';
import Taro, { navigateTo } from '@tarojs/taro';
import { useAppDispatch, useAppSelector } from '../../hooks/redux';
@@ -90,9 +90,15 @@ export default function Home() {
};
const handleTemplateClick = async (template: Template) => {
// 如果是好友合照,跳转到专门的页面
if (template.name === '好友合照') {
navigateTo({ url: `/pages/friends-photo/index` });
return;
}
try {
// 第一步:选择并上传图片
const imageCount = template.templateType === '2image-to-video' ? 2 : 1;
const imageCount = 1;
const imageUrl = await sdk.chooseAndUploadImage({
count: imageCount,
onImageSelected: () => {

View File

@@ -1,3 +1,4 @@
import { PlatformFactory } from "../platforms/factory";
import Taro from "@tarojs/taro"
/**
@@ -51,7 +52,7 @@ interface ImageToVideoParams {
}
export function isGetFileInfoSuccessCallbackResult(val: any): val is Taro.getFileInfo.SuccessCallbackResult {
return val && Reflect.has(val, 'digest')
return val && typeof val.size === 'number' && !val.errMsg
}
/**
@@ -74,11 +75,25 @@ export class BowongAISDK {
const url = `${this.baseUrl}/api/file/upload/s3`;
try {
// 获取文件信息
const fileInfo = await Taro.getFileInfo({ filePath });
if (!isGetFileInfoSuccessCallbackResult(fileInfo)) {
throw new Error(fileInfo.errMsg)
// 检测环境并使用不同的上传策略
if (PlatformFactory.detectPlatform() === 'h5') {
return await this._uploadForH5(params);
}
// 小程序环境的原有逻辑
// 获取文件信息
let fileInfo;
try {
fileInfo = await Taro.getFileInfo({ filePath });
if (!isGetFileInfoSuccessCallbackResult(fileInfo)) {
console.warn('获取文件信息失败,使用默认值:', fileInfo.errMsg);
fileInfo = { size: 0 };
}
} catch (getFileInfoError) {
console.warn('getFileInfo调用失败使用默认值:', getFileInfoError);
fileInfo = { size: 0 };
}
// 自动生成文件名(如果未提供)
const fileName = name || `upload_${Date.now()}.${this._getFileExtension(filePath)}`;
@@ -138,8 +153,105 @@ export class BowongAISDK {
console.log('文件上传成功:', result.data);
return result.data;
} catch (error) {
} catch (error: any) {
console.error('文件上传失败:', error);
// 提供更详细的错误信息
let errorMessage = '文件上传失败';
if (error?.errMsg) {
if (error.errMsg.includes('timeout')) {
errorMessage = '上传超时,请检查网络连接';
} else if (error.errMsg.includes('network')) {
errorMessage = '网络连接失败,请检查网络';
} else if (error.errMsg.includes('size')) {
errorMessage = '文件过大,请选择较小的图片';
} else if (error.errMsg.includes('cors') || error.errMsg.includes('CORS')) {
errorMessage = 'H5环境跨域错误请联系开发者';
} else {
errorMessage = `上传失败: ${error.errMsg}`;
}
} else if (error?.message) {
if (error.message.includes('cors') || error.message.includes('CORS')) {
errorMessage = 'H5环境跨域错误请联系开发者';
} else {
errorMessage = error.message;
}
}
throw new Error(errorMessage);
}
}
/**
* H5环境专用上传方法
* @private
*/
private async _uploadForH5(params: UploadParams): Promise<string> {
const { filePath, name, type } = params;
const url = `${this.baseUrl}/api/file/upload/s3`;
try {
// H5环境中filePath实际上是File对象或Blob URL
let file: File;
if (typeof filePath === 'string') {
// 如果是blob URL需要先获取blob
const response = await fetch(filePath);
const blob = await response.blob();
const fileName = name || `upload_${Date.now()}.${this._getFileExtension(filePath)}`;
file = new File([blob], fileName, { type: type || blob.type });
} else if (filePath && typeof filePath === 'object' && 'name' in filePath && 'size' in filePath && 'type' in filePath) {
file = filePath as File;
} else {
throw new Error('H5环境下文件路径格式不正确');
}
// 创建FormData
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
formData.append('type', file.type);
console.log('H5环境开始上传文件:', {
fileName: file.name,
fileSize: file.size,
fileType: file.type
});
// 使用fetch API上传添加CORS支持
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json',
// 不要手动设置Content-Type让浏览器自动设置multipart/form-data边界
},
mode: 'cors', // 明确指定CORS模式
credentials: 'omit' // 不发送cookie
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 文件上传失败`);
}
const result = await response.json() as ApiResponse<string>;
// 检查业务状态码
if (!result.status) {
throw new Error(result.msg || '文件上传失败');
}
console.log('H5环境文件上传成功:', result.data);
return result.data;
} catch (error: any) {
console.error('H5环境文件上传失败:', error);
// 特殊处理CORS错误
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('网络错误可能是CORS问题请确保服务器支持跨域请求');
}
throw error;
}
}
@@ -203,14 +315,14 @@ export class BowongAISDK {
} catch (error) {
console.error('选择并上传图片失败:', error);
// 检查是否是授权被拒绝的错误
if (this._isAuthorizationError(error)) {
await this._handleAuthorizationError();
// 用户重新授权后,递归调用自己重试
return this.chooseAndUploadImage(options);
}
throw error;
}
}
@@ -223,10 +335,10 @@ export class BowongAISDK {
if (!error || typeof error.errMsg !== 'string') {
return false;
}
const errMsg = error.errMsg.toLowerCase();
return errMsg.includes('auth') ||
errMsg.includes('permission') ||
return errMsg.includes('auth') ||
errMsg.includes('permission') ||
errMsg.includes('denied') ||
errMsg.includes('authorize') ||
errMsg.includes('scope');
@@ -251,8 +363,8 @@ export class BowongAISDK {
success: (settingResult) => {
console.log('用户设置结果:', settingResult);
// 检查是否授权了相册权限
if (settingResult.authSetting &&
(settingResult.authSetting['scope.writePhotosAlbum'] ||
if (settingResult.authSetting &&
(settingResult.authSetting['scope.writePhotosAlbum'] ||
settingResult.authSetting['scope.camera'])) {
Taro.showToast({
title: '授权成功',
@@ -341,14 +453,14 @@ export class BowongAISDK {
} catch (error) {
console.error('选择并上传图片失败:', error);
// 检查是否是授权被拒绝的错误
if (this._isAuthorizationError(error)) {
await this._handleAuthorizationError();
// 用户重新授权后,递归调用自己重试
return this.chooseAndGenerateImage(options);
}
throw error;
}
}
@@ -385,7 +497,7 @@ export class BowongAISDK {
* 生成图像
* @param params 图像生成参数
* @returns Promise<string> 返回任务ID
*
*
* 使用 multipart/form-data 格式提交支持文件上传和URL参数
* 对应 curl 命令:
* curl -X 'POST' \
@@ -413,17 +525,22 @@ export class BowongAISDK {
const url = `${this.baseUrl}/api/custom/image/submit/task`;
try {
// 改为选择图片
// 如果提供了本地文件路径,使用 uploadFile
if (img_file) {
// 检测环境并使用不同的上传策略
if (PlatformFactory.detectPlatform() === 'h5') {
return await this._generateImageForH5(params);
}
// 小程序环境的原有逻辑
const response = await new Promise<Taro.uploadFile.SuccessCallbackResult>((resolve, reject) => {
Taro.uploadFile({
url,
filePath: img_file,
name: 'img_file',
header: {
'accept': 'application/json'
'accept': 'application/json',
'Content-Type': 'multipart/form-data'
},
formData: {
prompt,
@@ -458,6 +575,92 @@ export class BowongAISDK {
}
}
/**
* H5环境专用图像生成方法
* @private
*/
private async _generateImageForH5(params: GenerateImageParams): Promise<string> {
const {
prompt,
model_name = 'gemini-2.5-flash-image-preview',
aspect_ratio = '9:16',
mode = 'turbo',
webhook_flag = false,
img_file
} = params;
const url = `${this.baseUrl}/api/custom/image/submit/task`;
try {
// H5环境中img_file实际上是File对象或Blob URL
let file: File;
if (typeof img_file === 'string') {
// 如果是blob URL需要先获取blob
const response = await fetch(img_file);
const blob = await response.blob();
const fileName = `generate_${Date.now()}.${this._getFileExtension(img_file)}`;
file = new File([blob], fileName, { type: blob.type });
} else if (img_file && typeof img_file === 'object' && 'name' in img_file && 'size' in img_file && 'type' in img_file) {
file = img_file as File;
} else {
throw new Error('H5环境下图片文件格式不正确');
}
// 创建FormData
const formData = new FormData();
formData.append('img_file', file);
formData.append('prompt', prompt);
formData.append('model_name', model_name);
formData.append('aspect_ratio', aspect_ratio);
formData.append('mode', mode.toString());
formData.append('webhook_flag', webhook_flag.toString());
formData.append('img_url', '');
console.log('H5环境开始图像生成:', {
fileName: file.name,
fileSize: file.size,
fileType: file.type,
prompt: prompt.substring(0, 100) + '...'
});
// 使用fetch API上传添加CORS支持
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'accept': 'application/json',
// 不要手动设置Content-Type让浏览器自动设置multipart/form-data边界
},
mode: 'cors', // 明确指定CORS模式
credentials: 'omit' // 不发送cookie
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 图像生成请求失败`);
}
const result = await response.json() as ApiResponse<string>;
if (!result.status) {
throw new Error(result.msg || '图像生成请求失败');
}
console.log('H5环境图像生成任务提交成功:', result.data);
return result.data;
} catch (error: any) {
console.error('H5环境图像生成失败:', error);
// 特殊处理CORS错误
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('网络错误可能是CORS问题请确保服务器支持跨域请求');
}
throw error;
}
}
/**
* 查询任务状态
* @param taskId 任务ID
@@ -511,7 +714,7 @@ export class BowongAISDK {
* 图像转视频
* @param params 图像转视频参数
* @returns Promise<string> 返回任务ID
*
*
* 对应 curl 命令:
* curl -X 'POST' \
* 'https://bowongai-test--text-video-agent-fastapi-app.modal.run/api/custom/transform/i2v' \
@@ -561,21 +764,21 @@ export class BowongAISDK {
/**
* 使用示例:
*
*
* // 1. 选择并上传图片
* const uploadResult = await bowongAI.chooseAndUploadImage({
* onProgress: (progress) => console.log(`上传进度: ${progress}%`)
* });
*
*
* // 2. 使用上传的图片生成新图像
* const generateResult = await bowongAI.generateImage({
* img_url: uploadResult.file_url,
* prompt: '转换为油画风格'
* });
*
*
* // 3. 查询生成状态
* const status = await bowongAI.getTaskStatus(generateResult.task_id);
*
*
* // 4. 获取最终结果
* const finalResult = await bowongAI.getTaskResult(generateResult.task_id);
*/
@@ -584,4 +787,4 @@ export class BowongAISDK {
export const bowongAI = new BowongAISDK();
// 向后兼容的类名导出
export const Sdk = BowongAISDK;
export const Sdk = BowongAISDK;

View File

@@ -1,168 +0,0 @@
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();