feat(editor): implement deferred image uploading and pending state management
Some checks failed
Frontend CI / ota-ios (push) Successful in 2m3s
Frontend CI / ota-android (push) Successful in 2m4s
Frontend CI / build-and-push-web (push) Failing after 33m21s
Frontend CI / build-android-apk (push) Successful in 59m9s

Refactor the image handling workflow to decouple image selection from the upload process. This improves user experience by allowing users to continue composing posts or trades while images are queued for upload, and increases reliability by using a centralized pending image utility.

- **Deferred Uploads**: Replaced immediate image uploads in `CreatePostScreen`, `PostDetailScreen`, and `CreateTradeScreen` with a "pending" state. Images are now uploaded in bulk during the final submission phase.
- **BlockEditor Enhancements**:
    - Updated `useBlockEditor` to support asynchronous batch uploading of all pending images via a new `uploadPendingImages` method.
    - Removed inline uploading overlays in favor of a more robust state-driven approach.
    - Improved `BlockEditorHandle` to expose block retrieval and batch upload capabilities.
- **API Layer Improvements**:
    - Migrated native image uploads from standard `FormData` to `expo-file-system`'s multipart upload to resolve compatibility issues with recent React Native versions.
    - Maintained `fetch` + `Blob` logic for web platform compatibility.
- **New Utilities**: Introduced `src/utils/pendingImages.ts` to manage the lifecycle of local (pending) vs. remote (uploaded) images across the application.
This commit is contained in:
2026-06-07 00:40:35 +08:00
parent 5c81795d39
commit b15e0c0b0b
11 changed files with 331 additions and 313 deletions

View File

@@ -38,12 +38,18 @@ import {
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/auth';
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
import { postService, commentService, authService, showPrompt, voteService } from '../../services';
import { postSyncService } from '@/services/post';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
import {
type PendingOrRemoteImage,
makePendingImageFromAsset,
getImageDisplayUri,
uploadAllPendingImages,
} from '../../utils/pendingImages';
import { handleError } from '@/services/core';
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
import * as hrefs from '../../navigation/hrefs';
@@ -251,7 +257,7 @@ export const PostDetailScreen: React.FC = () => {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
// 评论图片相关状态
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
const [commentImages, setCommentImages] = useState<PendingOrRemoteImage[]>([]);
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
const [isFollowingMe, setIsFollowingMe] = useState(false);
@@ -688,31 +694,8 @@ export const PostDetailScreen: React.FC = () => {
});
if (!result.canceled && result.assets) {
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ci'));
setCommentImages(prev => [...prev, ...newImages]);
// 上传图片
for (let i = 0; i < newImages.length; i++) {
const asset = result.assets[i];
const uploadResult = await uploadService.uploadImage({
uri: asset.uri,
type: asset.mimeType || 'image/jpeg',
});
if (uploadResult) {
setCommentImages(prev => {
const updated = [...prev];
const index = prev.findIndex(img => img.uri === asset.uri);
if (index !== -1) {
updated[index] = { uri: uploadResult.url, uploading: false };
}
return updated;
});
} else {
Alert.alert('上传失败', '图片上传失败,请重试');
setCommentImages(prev => prev.filter(img => img.uri !== asset.uri));
}
}
}
};
@@ -727,11 +710,17 @@ export const PostDetailScreen: React.FC = () => {
const hasContent = commentText.trim() || commentImages.length > 0;
if (!hasContent || !post) return;
// 检查是否有图片正在上传
const uploadingImages = commentImages.filter(img => img.uploading);
if (uploadingImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
// 上传所有 pending 图片
let uploadedImageUrls: string[] = [];
if (commentImages.length > 0) {
const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => {
setCommentImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
});
if (!uploadResult.success) {
Alert.alert('上传失败', '部分图片上传失败,请重试');
return;
}
uploadedImageUrls = uploadResult.urls;
}
const tempId = `new-${Date.now()}`;
@@ -741,9 +730,6 @@ export const PostDetailScreen: React.FC = () => {
// 获取根评论 ID如果被回复评论有 root_id则使用 root_id否则使用 parent_id即被回复的是顶级评论
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
// 获取已上传图片的URL列表
const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri);
// 先创建临时评论显示在列表中
const tempComment: Comment = {
id: tempId,
@@ -1501,13 +1487,12 @@ export const PostDetailScreen: React.FC = () => {
{commentImages.length > 0 && (
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
{commentImages.map((image, index) => (
<View key={index} style={styles.commentImageWrapper}>
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" />
{image.uploading && (
<View style={styles.uploadingOverlay}>
<Loading size="sm" />
</View>
)}
<View key={image.id} style={styles.commentImageWrapper}>
<Image
source={{ uri: getImageDisplayUri(image) }}
style={styles.commentImageThumbnail}
resizeMode="cover"
/>
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => handleRemoveCommentImage(index)}
@@ -2185,17 +2170,6 @@ function createPostDetailStyles(colors: AppColors) {
height: 60,
borderRadius: borderRadius.sm,
},
uploadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.sm,
},
removeImageButton: {
position: 'absolute',
top: -6,