style: 统一样式单位为px,优化代码格式和可读性

- 将CSS样式中的rpx单位替换为px,提升样式一致性
- 在TypeScript文件中添加缺失的分号,优化代码可读性
- 删除不再使用的组件及其样式,简化项目结构
- 更新生成页面和结果页面的样式,提升用户体验
This commit is contained in:
iHeyTang
2025-09-10 20:12:50 +08:00
parent 09aa9d6192
commit b3c7c09e13
16 changed files with 456 additions and 1251 deletions

View File

@@ -5,22 +5,19 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding-left: 0;
padding-right: 0;
padding-bottom: 40rpx;
padding-top: 20rpx;
padding: 20px 0 40px;
}
.download-btn {
width: 80%;
height: 100rpx;
height: 100px;
background: linear-gradient(45deg, #ff6b6b, #ee5a24);
color: white;
border: none;
border-radius: 50rpx;
font-size: 32rpx;
border-radius: 50px;
font-size: 32px;
font-weight: bold;
margin-bottom: 20rpx;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
@@ -52,14 +49,14 @@
.regenerate-btn {
width: 80%;
height: 100rpx;
height: 100px;
background: linear-gradient(45deg, #52c41a, #73d13d);
color: white;
border: none;
border-radius: 50rpx;
font-size: 32rpx;
border-radius: 50px;
font-size: 32px;
font-weight: bold;
margin-bottom: 20rpx;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
@@ -72,6 +69,6 @@
}
.download-tip {
font-size: 24rpx;
font-size: 24px;
opacity: 0.8;
}

View File

@@ -1,67 +1,61 @@
import { View, Text } from '@tarojs/components'
import './index.css'
import { View, Text } from '@tarojs/components';
import './index.css';
interface DownloadSectionProps {
onDownload: () => void
onRegenerate?: () => void
onImageToVideo?: () => void
loading: boolean
hasWatchedAd?: boolean
adAvailable?: boolean
showImageToVideo?: boolean
imageToVideoLoading?: boolean
onDownload: () => void;
onRegenerate?: () => void;
onImageToVideo?: () => void;
loading: boolean;
hasWatchedAd?: boolean;
adAvailable?: boolean;
showImageToVideo?: boolean;
imageToVideoLoading?: boolean;
}
const DownloadSection: React.FC<DownloadSectionProps> = ({
onDownload,
onRegenerate,
onImageToVideo,
loading,
adAvailable = true,
const DownloadSection: React.FC<DownloadSectionProps> = ({
onDownload,
onRegenerate,
onImageToVideo,
loading,
adAvailable = true,
showImageToVideo = false,
imageToVideoLoading = false
imageToVideoLoading = false,
}) => {
const getButtonText = () => {
if (loading) {
return '广告加载中...'
return '广告加载中...';
}
if (!adAvailable) {
return '📱 免费下载'
return '📱 免费下载';
}
return '🎬 看广告下载'
}
return '🎬 看广告下载';
};
const getTipText = () => {
if (!adAvailable) {
return '🎁 已为您跳过广告,可以直接免费下载'
return '🎁 已为您跳过广告,可以直接免费下载';
}
return '观看完整广告即可免费下载所有图片'
}
return '观看完整广告即可免费下载所有图片';
};
return (
<View className='download-section'>
<View
className={`download-btn ${loading ? 'disabled' : ''} ${!adAvailable ? 'watched' : ''}`}
onClick={loading ? undefined : onDownload}
>
<View className="download-section">
<View className={`download-btn ${loading ? 'disabled' : ''} ${!adAvailable ? 'watched' : ''}`} onClick={loading ? undefined : onDownload}>
<Text>{getButtonText()}</Text>
</View>
{onRegenerate && (
<View className='regenerate-btn' onClick={onRegenerate}>
<View className="regenerate-btn" onClick={onRegenerate}>
<Text>🎨 </Text>
</View>
)}
{showImageToVideo && onImageToVideo && (
<View
className={`regenerate-btn ${imageToVideoLoading ? 'disabled' : ''}`}
onClick={imageToVideoLoading ? undefined : onImageToVideo}
>
<View className={`regenerate-btn ${imageToVideoLoading ? 'disabled' : ''}`} onClick={imageToVideoLoading ? undefined : onImageToVideo}>
<Text>{imageToVideoLoading ? '🎬 生成中...' : '🎬 图片生视频'}</Text>
</View>
)}
<Text className='download-tip'>{getTipText()}</Text>
<Text className="download-tip">{getTipText()}</Text>
</View>
)
}
);
};
export default DownloadSection
export default DownloadSection;

View File

@@ -1,38 +0,0 @@
.error-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
cursor: pointer;
}
.error-container {
text-align: center;
color: white;
padding: 80rpx;
}
.error-icon {
font-size: 96rpx;
margin-bottom: 40rpx;
display: block;
}
.error-title {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 20rpx;
display: block;
}
.error-hint {
font-size: 28rpx;
opacity: 0.8;
display: block;
}

View File

@@ -1,20 +0,0 @@
import { View, Text } from '@tarojs/components'
import './index.css'
interface ErrorOverlayProps {
onRetry: () => void
}
const ErrorOverlay: React.FC<ErrorOverlayProps> = ({ onRetry }) => {
return (
<View className='error-overlay' onClick={onRetry}>
<View className='error-container'>
<Text className='error-icon'></Text>
<Text className='error-title'></Text>
<Text className='error-hint'></Text>
</View>
</View>
)
}
export default ErrorOverlay

View File

@@ -1,207 +0,0 @@
.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;
}
}

View File

@@ -1,275 +0,0 @@
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 => {
console.log({ conclusion })
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;

View File

@@ -4,7 +4,7 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
background: rgb(0 0 0 / 80%);
display: flex;
justify-content: center;
align-items: center;
@@ -19,53 +19,58 @@
.loading-text {
display: flex;
flex-direction: column;
gap: 20rpx;
gap: 20px;
}
.loading-spinner {
width: 100rpx;
height: 100rpx;
border: 6rpx solid #f3f3f3;
border-top: 6rpx solid #667eea;
width: 100px;
height: 100px;
border: 6px solid #f3f3f3;
border-top: 6px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 40rpx;
margin: 0 auto 40px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-title {
font-size: 36rpx;
font-size: 36px;
font-weight: bold;
display: block;
}
.loading-desc {
font-size: 28rpx;
font-size: 28px;
opacity: 0.8;
display: block;
}
.loading-container .action-buttons {
margin-top: 60rpx;
margin-top: 60px;
display: flex;
flex-direction: column;
gap: 20rpx;
width: 240rpx;
gap: 20px;
width: 240px;
align-items: center;
}
.loading-container .btn-primary,
.loading-container .btn-secondary {
border-radius: 25rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 28rpx;
border-radius: 25px;
height: 80px;
line-height: 80px;
font-size: 28px;
border: none;
width: 240rpx;
width: 240px;
display: flex;
align-items: center;
justify-content: center;
@@ -77,6 +82,6 @@
}
.loading-container .btn-secondary {
background: rgba(255, 255, 255, 0.9);
background: rgb(255 255 255 / 90%);
color: black;
}
}

View File

@@ -1,23 +1,23 @@
import { View, Text } from '@tarojs/components'
import './index.css'
import { View, Text } from '@tarojs/components';
import './index.css';
interface LoadingOverlayProps {
children?: React.ReactNode
children?: React.ReactNode;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ children }) => {
return (
<View className='loading-overlay'>
<View className='loading-container'>
<View className='loading-spinner' />
<View className='loading-text'>
<Text className='loading-title'>AI正在生成中...</Text>
<Text className='loading-desc'></Text>
<View className="loading-overlay">
<View className="loading-container">
<View className="loading-spinner" />
<View className="loading-text">
<Text className="loading-title">AI正在生成中...</Text>
<Text className="loading-desc"></Text>
</View>
{children}
</View>
</View>
)
}
);
};
export default LoadingOverlay
export default LoadingOverlay;

View File

@@ -1,8 +1,8 @@
.template-card {
background: #fff;
border-radius: 32rpx;
border-radius: 32px;
padding: 0;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 24px rgb(0 0 0 / 8%);
cursor: pointer;
display: flex;
flex-direction: column;
@@ -15,7 +15,6 @@
will-change: transform;
}
/* 优化简化active状态减少重绘 */
.template-card:active {
opacity: 0.9;
}
@@ -25,17 +24,17 @@
position: relative;
padding: 0;
flex: 1;
min-height: 480rpx;
border-radius: 32rpx;
min-height: 480px;
border-radius: 32px;
}
.merged-image-container {
position: relative;
border-radius: 32rpx;
border-radius: 32px;
overflow: hidden;
width: 100%;
height: 100%;
min-height: 480rpx;
min-height: 480px;
display: block;
}
@@ -45,13 +44,11 @@
left: 0;
width: 100%;
height: 100%;
min-height: 480rpx;
min-height: 480px;
}
/* 优化减少clip-path动画频率提升性能 */
.overlay-layer {
transition: clip-path 0.2s ease-out;
/* 启用硬件加速 */
transform: translateZ(0);
will-change: clip-path;
}
@@ -59,28 +56,21 @@
.full-image {
width: 100%;
height: 100%;
min-height: 480rpx;
min-height: 480px;
object-fit: cover;
display: block;
/* 性能优化:启用硬件加速和图片优化 */
transform: translateZ(0);
image-rendering: optimizeSpeed;
/* 防止图片闪烁 */
image-rendering: optimizespeed;
backface-visibility: hidden;
}
/* 可拖拽分割线 */
.split-line {
position: absolute;
top: 0;
bottom: 0;
width: 8rpx; /* 增加宽度便于拖拽 */
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.9) 50%,
rgba(255, 255, 255, 0.8) 100%);
width: 8px; /* 增加宽度便于拖拽 */
background: linear-gradient(to bottom, rgb(255 255 255 / 80%) 0%, rgb(255 255 255 / 90%) 50%, rgb(255 255 255 / 80%) 100%);
transform: translateX(-50%);
z-index: 3;
cursor: col-resize;
@@ -93,15 +83,15 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64rpx;
height: 64rpx;
background: rgba(255, 255, 255, 0.95);
width: 64px;
height: 64px;
background: rgb(255 255 255 / 95%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.25);
border: 4rpx solid rgba(0, 122, 255, 0.4);
box-shadow: 0 4px 24px rgb(0 0 0 / 25%);
border: 4px solid rgb(0 122 255 / 40%);
cursor: col-resize;
touch-action: none;
transition: all 0.2s ease;
@@ -109,12 +99,12 @@
.split-handle:active {
transform: translate(-50%, -50%) scale(1.1);
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.3);
box-shadow: 0 8px 32px rgb(0 0 0 / 30%);
}
.split-icon {
font-size: 24rpx;
color: #007AFF;
font-size: 24px;
color: #007aff;
font-weight: bold;
}
@@ -123,9 +113,9 @@
/* 模板名称悬浮 - 图片底部 */
.name-overlay {
position: absolute;
bottom: 12rpx;
left: 24rpx;
right: 24rpx;
bottom: 12px;
left: 24px;
right: 24px;
z-index: 4;
display: flex;
flex-direction: column;
@@ -134,15 +124,14 @@
}
.name-badge {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
background: rgb(0 0 0 / 70%);
backdrop-filter: blur(20px);
color: #fff;
font-size: 24rpx;
font-size: 24px;
font-weight: 600;
padding: 8rpx 20rpx;
border-radius: 20rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
padding: 8px 20px;
border-radius: 20px;
text-shadow: 0 2px 4px rgb(0 0 0 / 30%);
text-align: center;
max-width: 90%;
width: fit-content;
@@ -154,25 +143,23 @@
/* 单视频容器样式 */
.single-video-container {
position: relative;
border-radius: 32rpx;
border-radius: 32px;
overflow: hidden;
width: 100%;
min-height: 480rpx;
min-height: 480px;
display: block;
}
.single-video {
width: 100%;
height: 100%;
min-height: 480rpx;
min-height: 480px;
object-fit: cover;
display: block;
border-radius: 32rpx;
border-radius: 32px;
position: relative;
z-index: 2;
/* 性能优化:启用硬件加速 */
transform: translateZ(0);
/* 防止视频闪烁 */
backface-visibility: hidden;
}
@@ -185,27 +172,23 @@
height: 100%;
z-index: 1;
object-fit: cover;
border-radius: 32rpx;
border-radius: 32px;
}
/* 视频样式 */
.full-video {
width: 100%;
height: 100%;
min-height: 480rpx;
min-height: 480px;
object-fit: cover;
display: block;
/* 性能优化:启用硬件加速 */
transform: translateZ(0);
/* 防止视频闪烁 */
backface-visibility: hidden;
}
.watermark-text {
color: rgba(255, 255, 255, 0.5);
font-size: 20rpx;
color: rgb(255 255 255 / 50%);
font-size: 20px;
font-weight: 400;
text-align: center;
}

View File

@@ -1,186 +1,165 @@
import { View, Text, Image, Video } from '@tarojs/components'
import { useState, useRef, useMemo } from 'react'
import Taro from '@tarojs/taro'
import { Template } from '../../store/types'
import './index.css'
import { View, Text, Image, Video } from '@tarojs/components';
import { useState, useRef, useMemo } from 'react';
import Taro from '@tarojs/taro';
import { Template } from '../../store/types';
import './index.css';
interface TemplateCardProps {
template: Template
onClick: (template: Template) => void
template: Template;
onClick: (template: Template) => void;
}
export default function TemplateCard({ template, onClick }: TemplateCardProps) {
const [splitPosition, setSplitPosition] = useState(50) // 分割线位置百分比
const [isDragging, setIsDragging] = useState(false)
const [containerInfo, setContainerInfo] = useState<any>(null)
const containerRef = useRef<any>(null)
const containerId = `container-${template.code}` // 为每个容器创建唯一ID
const [splitPosition, setSplitPosition] = useState(50); // 分割线位置百分比
const [isDragging, setIsDragging] = useState(false);
const [containerInfo, setContainerInfo] = useState<any>(null);
const containerRef = useRef<any>(null);
const containerId = `container-${template.code}`; // 为每个容器创建唯一ID
// 检测output是否为视频
const isOutputVideo = useMemo(() => {
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.outputExample)
}, [template.outputExample])
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.outputExample);
}, [template.outputExample]);
// 检测input是否为视频
const isInputVideo = useMemo(() => {
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.inputExample)
}, [template.inputExample])
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.inputExample);
}, [template.inputExample]);
const handleClick = () => {
if (!isDragging) {
onClick(template)
onClick(template);
}
}
};
// 获取容器信息
const getContainerInfo = () => {
return new Promise((resolve) => {
const query = Taro.createSelectorQuery()
query.select(`#${containerId}`).boundingClientRect((rect) => {
if (rect) {
setContainerInfo(rect)
resolve(rect)
}
}).exec()
})
}
return new Promise(resolve => {
const query = Taro.createSelectorQuery();
query
.select(`#${containerId}`)
.boundingClientRect(rect => {
if (rect) {
setContainerInfo(rect);
resolve(rect);
}
})
.exec();
});
};
// 处理触摸开始
const handleTouchStart = async (e: any) => {
e.stopPropagation()
setIsDragging(true)
e.stopPropagation();
setIsDragging(true);
// 获取容器信息用于后续计算
await getContainerInfo()
}
await getContainerInfo();
};
// 处理触摸移动 - 优化:添加节流,减少计算频率
const handleTouchMove = (e: any) => {
if (!isDragging || !containerInfo) return
e.stopPropagation()
if (!isDragging || !containerInfo) return;
e.stopPropagation();
const touch = e.touches[0]
if (!touch) return
const touch = e.touches[0];
if (!touch) return;
// 使用requestAnimationFrame节流提升性能
requestAnimationFrame(() => {
// 计算触摸点相对于容器的位置
const touchX = touch.clientX - containerInfo.left
const percentage = Math.max(10, Math.min(90, (touchX / containerInfo.width) * 100))
const touchX = touch.clientX - containerInfo.left;
const percentage = Math.max(10, Math.min(90, (touchX / containerInfo.width) * 100));
setSplitPosition(percentage)
})
}
setSplitPosition(percentage);
});
};
// 处理触摸结束
const handleTouchEnd = (e: any) => {
e.stopPropagation()
setTimeout(() => setIsDragging(false), 100) // 延迟重置,避免触发点击
}
e.stopPropagation();
setTimeout(() => setIsDragging(false), 100); // 延迟重置,避免触发点击
};
return (
<View className='template-card' onClick={handleClick}>
<View className="template-card" onClick={handleClick}>
{/* 根据output类型显示不同的内容 */}
{isOutputVideo ? (
// 当output是视频时只显示单个视频
<View className='single-video-container'>
<Image
className='video-poster'
src={template.inputExample}
mode='aspectFill'
/>
<View className="single-video-container">
<Image className="video-poster" src={template.inputExample} mode="aspectFill" />
<Video
className='single-video'
className="single-video"
src={template.outputExample}
autoplay
muted
loop
objectFit='cover'
objectFit="cover"
showPlayBtn={false}
showCenterPlayBtn={false}
showFullscreenBtn={false}
controls={false}
/>
{/* 模板名称悬浮 - 视频底部 */}
<View className='name-overlay'>
<Text className='name-badge'>
{template.name}
</Text>
<Text className='watermark-text'>AI生成</Text>
<View className="name-overlay">
<Text className="name-badge">{template.name}</Text>
<Text className="watermark-text">AI生成</Text>
</View>
</View>
) : (
// 原有的图片对比逻辑
<View className='image-comparison'>
<View
id={containerId}
className='merged-image-container'
ref={containerRef}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<View className="image-comparison">
<View id={containerId} className="merged-image-container" ref={containerRef} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
{/* 原图层 - 完整图片/视频 */}
<View className='image-layer'>
<View className="image-layer">
{isInputVideo ? (
<Video
className='full-video'
className="full-video"
src={template.inputExample}
autoplay
muted
loop
objectFit='cover'
objectFit="cover"
showPlayBtn={false}
showCenterPlayBtn={false}
showFullscreenBtn={false}
controls={false}
/>
) : (
<Image
className='full-image'
src={template.inputExample}
mode='aspectFill'
lazyLoad
/>
<Image className="full-image" src={template.inputExample} mode="aspectFill" lazyLoad />
)}
</View>
{/* 效果图层 - 完整图片,通过遮罩显示右半部分 */}
<View
className='image-layer overlay-layer'
className="image-layer overlay-layer"
style={{
clipPath: `polygon(${splitPosition}% 0%, 100% 0%, 100% 100%, ${splitPosition}% 100%)`
clipPath: `polygon(${splitPosition}% 0%, 100% 0%, 100% 100%, ${splitPosition}% 100%)`,
}}
>
<Image
className='full-image'
src={template.outputExample}
mode='aspectFill'
lazyLoad
/>
<Image className="full-image" src={template.outputExample} mode="aspectFill" lazyLoad />
</View>
{/* 可拖拽的分割线 */}
<View
className='split-line'
className="split-line"
style={{ left: `${splitPosition}%` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<View className='split-handle'>
<Text className='split-icon'></Text>
<View className="split-handle">
<Text className="split-icon"></Text>
</View>
</View>
{/* 模板名称悬浮 - 图片底部 */}
<View className='name-overlay'>
<Text className='name-badge'>
{template.name}
</Text>
<Text className='watermark-text'>AI生成</Text>
<View className="name-overlay">
<Text className="name-badge">{template.name}</Text>
<Text className="watermark-text">AI生成</Text>
</View>
</View>
</View>
)}
</View>
)
);
}

View File

@@ -1,67 +0,0 @@
.button-container {
position: absolute;
bottom: 20vh;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.create-button {
background: linear-gradient(135deg, #ff6b9d, #ff9a56, #ffd93d);
border: none;
border-radius: 144rpx;
padding: 24rpx 216rpx;
font-size: 72rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.3);
box-shadow: 0 16rpx 50rpx rgba(255, 107, 157, 0.4),
0 8rpx 30rpx rgba(255, 154, 86, 0.3), inset 0 4rpx 0 rgba(255, 255, 255, 0.3);
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
transform: scale(1);
}
.create-button::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent);
transition: left 0.5s;
}
.create-button:hover {
transform: scale(1.05) translateY(-2px);
box-shadow: 0 12px 35px rgba(255, 107, 157, 0.5),
0 6px 20px rgba(255, 154, 86, 0.4), inset 0 2px 0 rgba(255, 255, 255, 0.4);
filter: brightness(1.1);
}
.create-button:hover::before {
left: 100%;
}
.create-button:active {
transform: scale(0.98) translateY(1px);
box-shadow: 0 4px 15px rgba(255, 107, 157, 0.3),
0 2px 8px rgba(255, 154, 86, 0.2), inset 0 2px 0 rgba(255, 255, 255, 0.2);
}
.create-button::after {
content: "✨";
position: absolute;
top: -10rpx;
right: -10rpx;
font-size: 32rpx;
animation: sparkle 2s infinite;
pointer-events: none;
}

View File

@@ -1,18 +0,0 @@
import { View, Button } from '@tarojs/components'
import './index.css'
interface UploadButtonProps {
onUpload: () => void
}
const UploadButton: React.FC<UploadButtonProps> = ({ onUpload }) => {
return (
<View className='button-container'>
<Button className='create-button' onClick={onUpload}>
</Button>
</View>
)
}
export default UploadButton

View File

@@ -10,39 +10,39 @@
.generate-loading {
text-align: center;
padding: 60rpx;
padding: 30px;
}
.progress-container {
margin-bottom: 40rpx;
margin-bottom: 20px;
}
.progress-bar {
width: 500rpx;
height: 12rpx;
width: 250px;
height: 6px;
background-color: #e9ecef;
border-radius: 6rpx;
border-radius: 3px;
overflow: hidden;
margin-bottom: 20rpx;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #007aff 0%, #34c759 100%);
border-radius: 6rpx;
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 28rpx;
font-size: 14px;
color: #666;
font-weight: 500;
}
.loading-text {
font-size: 32rpx;
font-size: 16px;
color: #333;
margin-top: 20rpx;
margin-top: 10px;
}
.generate-success {
@@ -52,42 +52,41 @@
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
padding: 40rpx 32rpx;
padding: 20px 16px;
box-sizing: border-box;
}
/* 成功页面头部 */
.success-header {
text-align: center;
margin-bottom: 40rpx;
padding-top: 80rpx;
margin-bottom: 20px;
padding-top: 40px;
}
.success-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-bottom: 24rpx;
gap: 6px;
margin-bottom: 12px;
}
.success-icon {
font-size: 64rpx;
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.1));
font-size: 32px;
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 10%));
}
.success-title {
font-size: 48rpx;
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.5rpx;
letter-spacing: -0.25px;
}
.success-subtitle {
font-size: 28rpx;
font-size: 14px;
color: #666;
font-weight: 500;
line-height: 1.5;
@@ -99,21 +98,21 @@
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 40rpx;
margin-bottom: 20px;
min-height: 0;
padding: 0 16rpx;
padding: 0 8px;
}
.preview-container {
border-radius: 32rpx;
border-radius: 16px;
padding: 0;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
border: 1rpx solid #f0f0f0;
box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
border: 0.5px solid #f0f0f0;
position: relative;
overflow: hidden;
width: 90%;
max-width: 600rpx;
min-height: 680rpx;
max-width: 300px;
min-height: 340px;
height: 60vh;
display: block;
}
@@ -121,22 +120,22 @@
.preview-image {
width: 100%;
height: 100%;
min-height: 680rpx;
min-height: 340px;
object-fit: cover;
border-radius: 32rpx;
border-radius: 16px;
cursor: pointer;
display: block;
transform: translateZ(0);
image-rendering: optimizeSpeed;
image-rendering: optimizespeed;
backface-visibility: hidden;
}
.preview-video {
width: 100%;
height: 100%;
min-height: 680rpx;
min-height: 340px;
object-fit: cover;
border-radius: 32rpx;
border-radius: 16px;
display: block;
transform: translateZ(0);
backface-visibility: hidden;
@@ -170,7 +169,7 @@
.generate-error {
text-align: center;
padding: 60rpx;
padding: 30px;
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
display: flex;
@@ -180,33 +179,33 @@
}
.error-container {
margin-bottom: 60rpx;
margin-bottom: 30px;
background: #fff;
border-radius: 32rpx;
padding: 60rpx 40rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
border: 1rpx solid #f0f0f0;
max-width: 600rpx;
border-radius: 16px;
padding: 30px 20px;
box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
border: 0.5px solid #f0f0f0;
max-width: 300px;
}
.error-text {
font-size: 40rpx;
font-size: 20px;
color: #ff3b30;
font-weight: 700;
margin-bottom: 20rpx;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
gap: 6px;
}
.error-text::before {
content: '⚠️';
font-size: 36rpx;
font-size: 18px;
}
.error-message {
font-size: 30rpx;
font-size: 15px;
color: #666;
line-height: 1.4;
}
@@ -214,44 +213,43 @@
.action-buttons {
display: flex;
flex-direction: row;
gap: 24rpx;
gap: 12px;
width: 100%;
max-width: 600rpx;
margin: 0 auto 32rpx;
max-width: 300px;
margin: 0 auto 16px;
}
.success-actions .action-buttons {
margin-bottom: 24rpx;
margin-bottom: 12px;
}
/* 结果提示 */
.result-tips {
text-align: center;
margin-top: 16rpx;
margin-top: 8px;
}
.tips-text {
font-size: 24rpx;
font-size: 12px;
color: #999;
background: rgba(255, 255, 255, 0.8);
padding: 12rpx 24rpx;
border-radius: 20rpx;
background: rgb(255 255 255 / 80%);
padding: 6px 12px;
border-radius: 10px;
display: inline-block;
backdrop-filter: blur(10rpx);
-webkit-backdrop-filter: blur(10rpx);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
backdrop-filter: blur(5px);
box-shadow: 0 1px 4px rgb(0 0 0 / 5%);
}
.btn-primary {
flex: 1;
background: linear-gradient(135deg, #FFD67A 0%, #FFC947 100%);
background: linear-gradient(135deg, #ffd67a 0%, #ffc947 100%);
color: #333;
border-radius: 24rpx;
height: 96rpx;
font-size: 32rpx;
border-radius: 12px;
height: 48px;
font-size: 16px;
font-weight: 600;
border: none;
box-shadow: 0 8rpx 24rpx rgba(255, 214, 122, 0.3);
box-shadow: 0 4px 12px rgb(255 214 122 / 30%);
position: relative;
overflow: hidden;
display: flex;
@@ -266,7 +264,7 @@
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 30%), transparent);
z-index: 1;
}
@@ -276,16 +274,15 @@
.btn-secondary {
flex: 1;
background: rgba(255, 255, 255, 0.9);
background: rgb(255 255 255 / 90%);
color: #333;
border: 2rpx solid rgba(255, 214, 122, 0.6);
border-radius: 24rpx;
height: 96rpx;
font-size: 32rpx;
border: 1px solid rgb(255 214 122 / 60%);
border-radius: 12px;
height: 48px;
font-size: 16px;
font-weight: 600;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10rpx);
-webkit-backdrop-filter: blur(10rpx);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
@@ -298,4 +295,4 @@
.btn-text {
position: relative;
z-index: 2;
}
}

View File

@@ -1,127 +1,121 @@
import { View, Text, Button } from '@tarojs/components'
import { useEffect, useState } from 'react'
import Taro, { switchTab, navigateTo } from '@tarojs/taro'
import { useServerSdk } from '../../hooks/index'
import LoadingOverlay from '../../components/LoadingOverlay'
import './index.css'
import { Button, Text, View } from '@tarojs/components';
import Taro, { navigateTo, switchTab } from '@tarojs/taro';
import { useEffect, useState } from 'react';
import LoadingOverlay from '../../components/LoadingOverlay';
import { useServerSdk } from '../../hooks/index';
import './index.css';
export default function Generate() {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [result, setResult] = useState<any | null>(null)
const serverSdk = useServerSdk()
let timerRef: NodeJS.Timeout | null = null
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [result, setResult] = useState<any | null>(null);
const serverSdk = useServerSdk();
let timerRef: NodeJS.Timeout | null = null;
useEffect(() => {
// 获取页面参数
const instance = Taro.getCurrentInstance()
const params = instance.router?.params
const instance = Taro.getCurrentInstance();
const params = instance.router?.params;
if (params?.taskId) {
pollTaskResult(params.taskId)
pollTaskResult(params.taskId);
} else {
setStatus('error')
setResult({ success: false, error: '缺少必要参数' })
setStatus('error');
setResult({ success: false, error: '缺少必要参数' });
}
// 页面离开时清理定时器
return () => {
if (timerRef) {
clearTimeout(timerRef)
timerRef = null
clearTimeout(timerRef);
timerRef = null;
}
}
}, [])
};
}, []);
const pollTaskResult = async (taskId: string) => {
try {
let attempts = 0
const maxAttempts = 30 * 20 // 最多轮询30次30秒
let attempts = 0;
const maxAttempts = 30 * 20;
const poll = async () => {
attempts++
attempts++;
try {
const result = await serverSdk.getTaskProgress(taskId)
const result = await serverSdk.getTaskProgress(taskId);
if (result.status === 'completed') {
setStatus('success')
setStatus('success');
navigateTo({
url: `/pages/result/index?images=${encodeURIComponent(JSON.stringify([result.outputUrl]))}`
})
url: `/pages/result/index?images=${encodeURIComponent(JSON.stringify([result.outputUrl]))}`,
});
return;
} else if (result.status === 'failed') {
setStatus('error')
setResult({ success: false, error: result.error || '处理失败' })
setStatus('error');
setResult({ success: false, error: result.error || '处理失败' });
} else if (attempts < maxAttempts) {
// 继续轮询
timerRef = setTimeout(poll, 1000)
timerRef = setTimeout(poll, 1000);
} else {
// 超时
setStatus('error')
setResult({ success: false, error: '处理超时' })
setStatus('error');
setResult({ success: false, error: '处理超时' });
}
} catch (error) {
if (attempts < maxAttempts) {
timerRef = setTimeout(poll, 1000)
timerRef = setTimeout(poll, 1000);
} else {
setStatus('error')
setResult({ success: false, error: '获取结果失败' })
setStatus('error');
setResult({ success: false, error: '获取结果失败' });
}
}
}
};
poll()
poll();
} catch (error) {
setStatus('error')
setResult({ success: false, error: '开始处理失败' })
setStatus('error');
setResult({ success: false, error: '开始处理失败' });
}
}
};
const handleTryAgain = () => {
// 再来一张,返回首页
switchTab({ url: '/pages/home/index' })
}
switchTab({ url: '/pages/home/index' });
};
const handleViewHistory = () => {
// 查看记录,切换到历史记录页
switchTab({ url: '/pages/history/index' })
}
switchTab({ url: '/pages/history/index' });
};
const renderLoading = () => (
<View className='generate-loading'>
<View className="generate-loading">
<LoadingOverlay>
<View className='action-buttons'>
<Button className='btn-primary' onClick={handleTryAgain}>
<View className="action-buttons">
<Button className="btn-primary" onClick={handleTryAgain}>
</Button>
<Button className='btn-secondary' onClick={handleViewHistory}>
<Button className="btn-secondary" onClick={handleViewHistory}>
</Button>
</View>
</LoadingOverlay>
</View>
)
);
const renderError = () => (
<View className='generate-error'>
<View className='error-container'>
<Text className='error-text'></Text>
<Text className='error-message'>{result?.error || '未知错误'}</Text>
<View className="generate-error">
<View className="error-container">
<Text className="error-text"></Text>
<Text className="error-message">{result?.error || '未知错误'}</Text>
</View>
<View className='action-buttons'>
<Button className='btn-primary' onClick={handleTryAgain}>
<View className="action-buttons">
<Button className="btn-primary" onClick={handleTryAgain}>
</Button>
<Button className='btn-secondary' onClick={handleViewHistory}>
<Button className="btn-secondary" onClick={handleViewHistory}>
</Button>
</View>
</View>
)
);
return (
<View className='generate'>
<View className="generate">
{status === 'loading' && renderLoading()}
{status === 'error' && renderError()}
</View>
)
}
);
}

View File

@@ -1,7 +1,6 @@
.result-page {
width: 100%;
height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
display: flex;
flex-direction: column;
}
@@ -10,31 +9,29 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 40rpx;
background: transparent;
padding: 20px;
color: white;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgb(255 255 255 / 10%);
}
.result-title {
font-size: 36rpx;
font-size: 18px;
font-weight: bold;
}
.close-btn {
width: 80rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.2);
width: 40px;
height: 40px;
color: white;
border: none;
border-radius: 40rpx;
font-size: 48rpx;
line-height: 80rpx;
border-radius: 20px;
font-size: 24px;
line-height: 40px;
text-align: center;
}
@@ -50,8 +47,6 @@
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
@@ -62,16 +57,7 @@
.backdrop-blur::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 50%,
rgba(0, 0, 0, 0.1) 100%
);
inset: 0;
pointer-events: none;
z-index: 1;
}
@@ -83,20 +69,20 @@
height: 100%;
z-index: 2;
position: relative;
border-radius: 0rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
border-radius: 0;
box-shadow: 0 10px 30px rgb(0 0 0 / 30%);
}
/* 模板处理结果对比样式 */
.template-name {
font-size: 36rpx;
font-size: 18px;
font-weight: 700;
margin-bottom: 8rpx;
margin-bottom: 4px;
text-align: center;
}
.result-status {
font-size: 28rpx;
font-size: 14px;
opacity: 0.8;
text-align: center;
}
@@ -107,8 +93,8 @@
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 32rpx;
gap: 32rpx;
padding: 20px 16px;
gap: 16px;
}
.comparison-item {
@@ -121,23 +107,22 @@
.comparison-image {
width: 100%;
max-height: 600rpx;
border-radius: 24rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.2);
background: #fff;
max-height: 300px;
border-radius: 12px;
box-shadow: 0 4px 20px rgb(0 0 0 / 20%);
}
.comparison-label {
margin-top: 16rpx;
margin-top: 8px;
color: #fff;
font-size: 28rpx;
font-size: 14px;
font-weight: 600;
text-align: center;
}
.comparison-arrow {
color: #fff;
font-size: 48rpx;
font-size: 24px;
font-weight: bold;
opacity: 0.8;
}
@@ -151,12 +136,11 @@
display: flex;
justify-content: center;
align-items: center;
background: black;
}
.result-video {
width: 100%;
height: 100%;
max-height: 80vh;
border-radius: 0rpx;
border-radius: 0;
}

View File

@@ -1,239 +1,149 @@
import { View, Image, Video } from '@tarojs/components'
import { useEffect, useState } from 'react'
import { saveImageToPhotosAlbum, saveVideoToPhotosAlbum, navigateTo, downloadFile, showToast, navigateBack, getCurrentInstance, hideToast } from '@tarojs/taro'
import DownloadSection from '../../components/DownloadSection'
import { useAd } from '../../hooks/useAd'
import { useServerSdk } from '../../hooks/index'
import './index.css'
import { Image, Video, View } from '@tarojs/components';
import {
downloadFile,
getCurrentInstance,
hideToast,
navigateBack,
navigateTo,
saveImageToPhotosAlbum,
saveVideoToPhotosAlbum,
showToast,
} from '@tarojs/taro';
import { useEffect, useState } from 'react';
import DownloadSection from '../../components/DownloadSection';
import { useServerSdk } from '../../hooks/index';
import { useAd } from '../../hooks/useAd';
import './index.css';
const ResultPage: React.FC = () => {
const [images, setImages] = useState<string[]>([])
const [mediaType, setMediaType] = useState<'image' | 'video'>('image')
const [loading, setLoading] = useState(false)
const serverSdk = useServerSdk()
// 集成广告钩子
const { showAd, loading: adLoading, adAvailable } = useAd({
const [images, setImages] = useState<string[]>([]);
const [mediaType, setMediaType] = useState<'image' | 'video'>('image');
const [loading, setLoading] = useState(false);
const serverSdk = useServerSdk();
const {
showAd,
loading: adLoading,
adAvailable,
} = useAd({
onReward: () => {
// 观看完整广告后的奖励回调
handleDownloadImages()
handleDownloadImages();
},
onClose: (isEnded) => {
onClose: isEnded => {
if (!isEnded) {
showToast({
title: '请观看完整广告才能下载',
icon: 'none',
duration: 2000
})
duration: 2000,
});
}
}
})
},
});
useEffect(() => {
// 获取页面参数
const instance = getCurrentInstance()
const params = instance?.router?.params
const instance = getCurrentInstance();
const params = instance?.router?.params;
// 兼容原有的多图片参数
if (params?.images) {
try {
const imageList = JSON.parse(decodeURIComponent(params.images))
setImages(imageList)
const imageList = JSON.parse(decodeURIComponent(params.images));
setImages(imageList);
// 检测媒体类型
if (imageList.length > 0) {
const firstUrl = imageList[0]
const isVideo = /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(firstUrl)
setMediaType(isVideo ? 'video' : 'image')
const firstUrl = imageList[0];
const isVideo = /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(firstUrl);
setMediaType(isVideo ? 'video' : 'image');
}
} catch (error) {
console.error('解析图片参数失败:', error)
console.error('解析图片参数失败:', error);
showToast({
title: '参数错误',
icon: 'error'
})
navigateBack()
icon: 'error',
});
navigateBack();
}
} else {
showToast({
title: '缺少图片参数',
icon: 'error'
})
navigateBack()
icon: 'error',
});
navigateBack();
}
}, [])
}, []);
// 检查和请求相册权限
// const checkAndRequestAlbumPermission = async (): Promise<boolean> => {
// try {
// // 首先检查当前权限状态
// const settings = await getSetting()
// const authSetting: any = settings.authSetting
// // 检查不同平台的相册权限
// const hasAlbumPermission =
// authSetting['scope.album'] === true ||
// authSetting['scope.writePhotosAlbum'] === true
// if (hasAlbumPermission) {
// console.log('用户已授权相册权限')
// return true
// }
// // 检查是否之前被拒绝
// const isDenied =
// authSetting['scope.album'] === false ||
// authSetting['scope.writePhotosAlbum'] === false
// if (isDenied) {
// console.log('用户之前拒绝过权限,需要引导到设置页面')
// throw new Error('用户之前拒绝过权限')
// }
// // 用户还没有被询问过权限,尝试请求授权
// await authorize({ scope: 'scope.writePhotosAlbum' })
// return true
// } catch (error) {
// // 授权失败,询问用户是否手动开启权限
// try {
// const contentText = mediaType === 'video'
// ? '需要访问您的相册权限来保存视频,请在设置中开启权限'
// : '需要访问您的相册权限来保存图片,请在设置中开启权限'
// const result = await showModal({
// title: '权限申请',
// content: contentText,
// confirmText: '去设置',
// cancelText: '取消'
// })
// if (result.confirm) {
// // 用户同意去设置页面
// const settingResult = await openSetting()
// // 检查用户是否在设置页面开启了权限
// const hasPermissionAfterSetting =
// (settingResult.authSetting as any)['scope.album'] === true ||
// settingResult.authSetting['scope.writePhotosAlbum'] === true
// if (hasPermissionAfterSetting) {
// console.log('用户在设置页面开启了权限')
// return true
// } else {
// showToast({
// title: '仍未获得权限,无法保存',
// icon: 'none',
// duration: 2000
// })
// return false
// }
// } else {
// showToast({
// title: '需要相册权限才能保存',
// icon: 'none',
// duration: 2000
// })
// return false
// }
// } catch (modalError) {
// console.error('显示权限申请弹窗失败:', modalError)
// showToast({
// title: '权限申请失败',
// icon: 'none',
// duration: 2000
// })
// return false
// }
// }
// }
// 下载媒体文件到本地相册
const handleDownloadImages = async () => {
// 优先处理模板结果媒体文件
const mediaUrl = images.length > 0 ? images[0] : ''
if (!mediaUrl) return
const mediaUrl = images.length > 0 ? images[0] : '';
if (!mediaUrl) return;
console.log({ mediaUrl })
console.log({ mediaUrl });
try {
// 从 mediaUrl 中提取文件信息
const getFileInfoFromUrl = (url: string) => {
// 移除查询参数和锚点
const cleanUrl = url.split('?')[0].split('#')[0]
// 提取文件名
const fileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1) || 'download'
// 提取扩展名
const extensionMatch = fileName.match(/\.([^.]+)$/)
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : ''
const cleanUrl = url.split('?')[0].split('#')[0];
const fileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1) || 'download';
const extensionMatch = fileName.match(/\.([^.]+)$/);
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '';
return { fileName, extension, cleanUrl }
}
return { fileName, extension, cleanUrl };
};
const fileInfo = getFileInfoFromUrl(mediaUrl)
const fileInfo = getFileInfoFromUrl(mediaUrl);
// 根据文件类型确定正确的扩展名
let finalExtension = fileInfo.extension
let finalExtension = fileInfo.extension;
if (!finalExtension) {
// 如果URL中没有扩展名根据媒体类型推断
finalExtension = mediaType === 'video' ? 'mp4' : 'jpg'
finalExtension = mediaType === 'video' ? 'mp4' : 'jpg';
}
// 先下载文件到临时目录,指定文件路径
showToast({ title: '下载中...', icon: 'loading', duration: 1000 * 60 })
showToast({ title: '下载中...', icon: 'loading', duration: 1000 * 60 });
const downloadRes = await downloadFile({
url: mediaUrl,
header: {
'Accept': 'image/*, video/*',
'User-Agent': 'Mozilla/5.0 (compatible; MiniApp)'
}
})
hideToast()
// 使用实际的文件路径
const filePath = downloadRes.tempFilePath
Accept: 'image/*, video/*',
'User-Agent': 'Mozilla/5.0 (compatible; MiniApp)',
},
});
hideToast();
const filePath = downloadRes.tempFilePath;
showToast({ title: '保存中...', icon: 'loading' })
// 根据实际文件类型选择保存方法
showToast({ title: '保存中...', icon: 'loading' });
if (mediaType === 'video') {
console.log('保存为视频文件')
await saveVideoToPhotosAlbum({ filePath })
console.log('保存为视频文件');
await saveVideoToPhotosAlbum({ filePath });
} else if (mediaType === 'image') {
console.log('保存为图片文件')
await saveImageToPhotosAlbum({ filePath })
console.log('保存为图片文件');
await saveImageToPhotosAlbum({ filePath });
} else {
// 如果无法确定文件类型,默认尝试保存为图片
console.log('文件类型不确定,尝试保存为图片')
console.log('文件类型不确定,尝试保存为图片');
try {
await saveImageToPhotosAlbum({ filePath })
await saveImageToPhotosAlbum({ filePath });
} catch (imageError) {
console.log('图片保存失败,尝试视频保存')
await saveVideoToPhotosAlbum({ filePath })
console.log('图片保存失败,尝试视频保存');
await saveVideoToPhotosAlbum({ filePath });
}
}
showToast({
title: '保存成功',
icon: 'success',
duration: 2000
})
duration: 2000,
});
} catch (error) {
console.error('保存失败:', error)
console.error('保存失败:', error);
// 分析错误类型,提供更具体的错误提示
let errorMsg = '保存失败,请重试'
let errorMsg = '保存失败,请重试';
if (error && typeof error === 'object') {
const err = error as any
const err = error as any;
if (err.errMsg) {
console.error('详细错误信息:', err.errMsg)
console.error('详细错误信息:', err.errMsg);
if (err.errMsg.includes('auth')) {
errorMsg = '权限不足,请在设置中开启相册权限'
errorMsg = '权限不足,请在设置中开启相册权限';
} else if (err.errMsg.includes('network')) {
errorMsg = '网络错误,请检查网络连接'
errorMsg = '网络错误,请检查网络连接';
} else if (err.errMsg.includes('invalid file type')) {
errorMsg = '文件格式不支持,请联系客服'
errorMsg = '文件格式不支持,请联系客服';
} else if (err.errMsg.includes('file')) {
errorMsg = '文件格式不支持或已损坏'
errorMsg = '文件格式不支持或已损坏';
} else if (err.errMsg.includes('download')) {
errorMsg = '文件下载失败,请重试'
errorMsg = '文件下载失败,请重试';
}
}
}
@@ -241,92 +151,79 @@ const ResultPage: React.FC = () => {
showToast({
title: errorMsg,
icon: 'error',
duration: 3000
})
duration: 3000,
});
} finally {
}
}
};
// 再来一张
const handleRegenerate = () => {
navigateBack()
}
navigateBack();
};
// 图片生视频
const handleImageToVideo = async () => {
if (mediaType !== 'image' || !images[0]) {
showToast({
title: '仅支持图片生成视频',
icon: 'error',
duration: 2000
})
return
duration: 2000,
});
return;
}
try {
setLoading(true)
setLoading(true);
showToast({
title: '正在生成视频...',
icon: 'loading',
duration: 60000
})
duration: 60000,
});
const taskId = await serverSdk.executeTemplate({
imageUrl: images[0],
templateCode: 'character_figurine_v1'
})
templateCode: 'character_figurine_v1',
});
hideToast()
hideToast();
// 跳转到生成页面
navigateTo({
url: `/pages/generate/index?taskId=${taskId}&templateCode=character_figurine_v1`
})
url: `/pages/generate/index?taskId=${taskId}&templateCode=character_figurine_v1`,
});
} catch (error) {
hideToast()
console.error('图片生视频失败:', error)
hideToast();
console.error('图片生视频失败:', error);
showToast({
title: '生成失败,请重试',
icon: 'error',
duration: 2000
})
duration: 2000,
});
} finally {
setLoading(false)
setLoading(false);
}
}
};
// 兼容原有的多图片显示
if (!images.length) {
return null
return null;
}
return (
<View className='result-page'>
<View className="result-page">
{mediaType === 'video' ? (
<View className='result-video-container'>
<Video
className='result-video'
src={images[0]}
autoplay
muted
loop
controls
objectFit='contain'
/>
<View className="result-video-container">
<Video className="result-video" src={images[0]} autoplay controls={false} muted loop objectFit="contain" />
</View>
) : (
<View
className='result-image-container'
className="result-image-container"
style={{
backgroundImage: `url(${images[0]})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
backgroundRepeat: 'no-repeat',
}}
>
<View className='backdrop-blur'>
<Image className='result-image' src={images[0]} mode='aspectFit' />
<View className="backdrop-blur">
<Image className="result-image" src={images[0]} mode="aspectFit" />
</View>
</View>
)}
@@ -340,7 +237,7 @@ const ResultPage: React.FC = () => {
imageToVideoLoading={loading}
/>
</View>
)
}
);
};
export default ResultPage
export default ResultPage;