feat(editor): implement deferred image uploading and pending state management
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:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "with_you",
|
||||
"version": "0.0.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@expo/ui": "^56.0.15",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface BlockEditorHandle {
|
||||
insertTextAtCursor: (text: string) => void;
|
||||
getSegments: () => MessageSegment[];
|
||||
getContent: () => string;
|
||||
hasUploadingImages: () => boolean;
|
||||
getBlocks: () => EditorBlock[];
|
||||
uploadPendingImages: () => Promise<boolean>;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
@@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
||||
insertTextAtCursor: editor.insertTextAtCursor,
|
||||
getSegments: editor.getSegments,
|
||||
getContent: editor.getContent,
|
||||
hasUploadingImages: editor.hasUploadingImages,
|
||||
getBlocks: editor.getBlocks,
|
||||
uploadPendingImages: editor.uploadPendingImages,
|
||||
focus: () => {
|
||||
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
||||
if (firstTextBlock) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
|
||||
import { View, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
@@ -41,11 +41,6 @@ const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
/>
|
||||
{block.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={() => onRemove(block.id)}
|
||||
@@ -66,13 +61,6 @@ const styles = StyleSheet.create({
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ImageBlock {
|
||||
type: 'image';
|
||||
localUri: string;
|
||||
remoteUrl?: string;
|
||||
uploading: boolean;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
|
||||
type: 'image',
|
||||
localUri: seg.data.url,
|
||||
remoteUrl: seg.data.url,
|
||||
uploading: false,
|
||||
width: seg.data.width,
|
||||
height: seg.data.height,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
|
||||
const inputRefs = useRef<Map<string, TextInput>>(new Map());
|
||||
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
|
||||
const blocksRef = useRef<EditorBlock[]>(blocks);
|
||||
blocksRef.current = blocks;
|
||||
|
||||
const updateTextBlock = useCallback(
|
||||
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
||||
@@ -87,51 +89,47 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
});
|
||||
}, [focusBlock]);
|
||||
|
||||
const uploadImageByLocalUri = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
const uploadPendingImages = useCallback(async (): Promise<boolean> => {
|
||||
let allSuccess = true;
|
||||
|
||||
if (uploadResult) {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? {
|
||||
...b,
|
||||
remoteUrl: uploadResult.url,
|
||||
uploading: false,
|
||||
width: uploadResult.width ?? b.width,
|
||||
height: uploadResult.height ?? b.height,
|
||||
}
|
||||
: b
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Upload failed
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
const uploadBlock = async () => {
|
||||
const currentBlocks = blocksRef.current;
|
||||
const pendingBlocks = currentBlocks.filter(
|
||||
(b): b is ImageBlock => b.type === 'image' && !b.remoteUrl,
|
||||
);
|
||||
|
||||
for (const block of pendingBlocks) {
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: block.localUri,
|
||||
type: block.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.id === block.id && b.type === 'image'
|
||||
? {
|
||||
...b,
|
||||
remoteUrl: uploadResult.url,
|
||||
width: uploadResult.width ?? b.width,
|
||||
height: uploadResult.height ?? b.height,
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
allSuccess = false;
|
||||
}
|
||||
} catch {
|
||||
allSuccess = false;
|
||||
}
|
||||
} catch {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
await uploadBlock();
|
||||
return allSuccess;
|
||||
}, []);
|
||||
|
||||
const insertImagesFromAssets = useCallback(
|
||||
(assets: ImagePicker.ImagePickerAsset[]) => {
|
||||
@@ -152,13 +150,12 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
||||
: 0;
|
||||
|
||||
// Create image block
|
||||
const imageBlockId = generateBlockId();
|
||||
const imageBlock: ImageBlock = {
|
||||
id: imageBlockId,
|
||||
type: 'image',
|
||||
localUri: asset.uri,
|
||||
uploading: true,
|
||||
mimeType: asset.mimeType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
@@ -169,7 +166,6 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
|
||||
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
|
||||
|
||||
// Create new text block for text after cursor
|
||||
const newTextBlockId = generateBlockId();
|
||||
const newTextBlock: TextBlock = {
|
||||
id: newTextBlockId,
|
||||
@@ -178,24 +174,19 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
activeMentions: mentionsAfter,
|
||||
};
|
||||
|
||||
// Update current block to text before cursor
|
||||
updated[insertAfterIndex] = {
|
||||
...activeBlock,
|
||||
text: textBefore,
|
||||
activeMentions: mentionsBefore,
|
||||
};
|
||||
|
||||
// Insert image block and new text block
|
||||
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
|
||||
|
||||
// Update index for next image insertion
|
||||
insertAfterIndex = insertAfterIndex + 2;
|
||||
|
||||
// Focus the new text block after the image
|
||||
setActiveBlockId(newTextBlockId);
|
||||
requestAnimationFrame(() => focusBlock(newTextBlockId));
|
||||
} else {
|
||||
// Insert after the current block (which is an image)
|
||||
updated.splice(insertAfterIndex + 1, 0, imageBlock);
|
||||
insertAfterIndex = insertAfterIndex + 1;
|
||||
}
|
||||
@@ -203,13 +194,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Start async uploads
|
||||
for (const asset of assets) {
|
||||
uploadImageByLocalUri(asset);
|
||||
}
|
||||
},
|
||||
[activeBlockId, focusBlock, uploadImageByLocalUri],
|
||||
[activeBlockId, focusBlock],
|
||||
);
|
||||
|
||||
const insertImage = useCallback(async () => {
|
||||
@@ -282,7 +268,7 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
|
||||
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
|
||||
const getContent = useCallback(() => blocksToContent(blocks), [blocks]);
|
||||
const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]);
|
||||
const getBlocks = useCallback(() => blocks, [blocks]);
|
||||
|
||||
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
|
||||
if (ref) {
|
||||
@@ -308,7 +294,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
focusBlock,
|
||||
getSegments,
|
||||
getContent,
|
||||
hasUploadingImages,
|
||||
getBlocks,
|
||||
uploadPendingImages,
|
||||
registerInputRef,
|
||||
updateCursor,
|
||||
};
|
||||
|
||||
@@ -38,15 +38,26 @@ import {
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { channelService, postService, showPrompt, voteService } from '../../services';
|
||||
import { ApiError } from '@/services/core';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import VoteEditor from '../../components/business/VoteEditor';
|
||||
import PostMentionInput from '../../components/business/PostMentionInput';
|
||||
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
|
||||
import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments';
|
||||
import type { EditorBlock } from '../../components/business/BlockEditor/blockEditorTypes';
|
||||
import {
|
||||
generateBlockId,
|
||||
type EditorBlock,
|
||||
type ImageBlock,
|
||||
} from '../../components/business/BlockEditor/blockEditorTypes';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { MessageSegment } from '../../types';
|
||||
import {
|
||||
type PendingOrRemoteImage,
|
||||
type RemoteImage,
|
||||
generatePendingImageId,
|
||||
makePendingImageFromAsset,
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../utils/pendingImages';
|
||||
|
||||
// Props 接口
|
||||
interface CreatePostScreenProps {
|
||||
@@ -130,7 +141,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [segments, setSegments] = useState<MessageSegment[]>([]);
|
||||
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
||||
// 图片状态:pending 表示本地待上传,remote 表示已是服务器 URL(编辑模式下的已存在图片)
|
||||
const [images, setImages] = useState<PendingOrRemoteImage[]>([]);
|
||||
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
const [showChannelPicker, setShowChannelPicker] = useState(false);
|
||||
@@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
}
|
||||
setTitle(existingPost.title || '');
|
||||
setContent(existingPost.content || '');
|
||||
setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false })));
|
||||
setImages((existingPost.images || []).map((img): RemoteImage => ({ id: generatePendingImageId(), kind: 'remote', url: img.url })));
|
||||
|
||||
// 自动判断长文模式:segments 中包含图片块即为长文帖子
|
||||
const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image');
|
||||
@@ -233,7 +245,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
loadPostForEdit();
|
||||
}, [isEditMode, editPostID, router]);
|
||||
|
||||
// 选择图片(普通模式)
|
||||
// 选择图片(普通模式)—— 仅选择,不立即上传
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
@@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
|
||||
const newImages = result.assets.map(asset => makePendingImageFromAsset(asset));
|
||||
setImages(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) {
|
||||
setImages(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('上传失败', '图片上传失败,请重试');
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 拍照
|
||||
// 拍照 —— 仅添加,不立即上传
|
||||
const handleTakePhoto = async () => {
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
|
||||
@@ -294,33 +283,13 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
setImages(prev => [...prev, { uri: asset.uri, uploading: true }]);
|
||||
|
||||
// 上传图片
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setImages(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('上传失败', '图片上传失败,请重试');
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
setImages(prev => [...prev, makePendingImageFromAsset(asset)]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
setImages(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 选择频道(单选,可再次点击取消)
|
||||
@@ -350,26 +319,44 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
// 切换长文/非长文模式时保留数据
|
||||
const handleToggleLongPostMode = useCallback(() => {
|
||||
if (!isLongPostMode) {
|
||||
// 非长文 -> 长文:将当前文本和图片转为 blocks
|
||||
const segs: MessageSegment[] = [];
|
||||
// 非长文 -> 长文:将当前文本和图片(包括 pending)转为 blocks
|
||||
const blocks: EditorBlock[] = [];
|
||||
if (content.trim()) {
|
||||
segs.push({ type: 'text', data: { text: content } });
|
||||
blocks.push({
|
||||
id: generateBlockId(),
|
||||
type: 'text',
|
||||
text: content,
|
||||
activeMentions: new Map(),
|
||||
});
|
||||
}
|
||||
for (const img of images) {
|
||||
if (img.uri && !img.uploading) {
|
||||
segs.push({ type: 'image', data: { url: img.uri } });
|
||||
const imageBlock: ImageBlock = img.kind === 'remote'
|
||||
? { id: generateBlockId(), type: 'image', localUri: img.url, remoteUrl: img.url }
|
||||
: { id: generateBlockId(), type: 'image', localUri: img.localUri, mimeType: img.mimeType };
|
||||
blocks.push(imageBlock);
|
||||
}
|
||||
setInitialBlocks(blocks.length > 0 ? blocks : undefined);
|
||||
} else {
|
||||
// 长文 -> 非长文:从 BlockEditor 提取文本和所有图片(包括 pending)
|
||||
const editorContent = blockEditorRef.current?.getContent() || '';
|
||||
const editorBlocks = blockEditorRef.current?.getBlocks() || [];
|
||||
if (editorContent) setContent(editorContent);
|
||||
|
||||
const editorImages: PendingOrRemoteImage[] = [];
|
||||
for (const block of editorBlocks) {
|
||||
if (block.type !== 'image') continue;
|
||||
if (block.remoteUrl) {
|
||||
editorImages.push({ id: generatePendingImageId(), kind: 'remote', url: block.remoteUrl });
|
||||
} else {
|
||||
editorImages.push({
|
||||
id: generatePendingImageId(),
|
||||
kind: 'pending',
|
||||
localUri: block.localUri,
|
||||
mimeType: block.mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
setInitialBlocks(segs.length > 0 ? segmentsToBlocks(segs) : undefined);
|
||||
} else {
|
||||
// 长文 -> 非长文:从 BlockEditor 提取文本和图片
|
||||
const editorContent = blockEditorRef.current?.getContent() || '';
|
||||
const editorSegments = blockEditorRef.current?.getSegments() || [];
|
||||
if (editorContent) setContent(editorContent);
|
||||
const editorImages = editorSegments
|
||||
.filter((s): s is Extract<typeof s, { type: 'image' }> => s.type === 'image')
|
||||
.map(s => ({ uri: s.data.url, uploading: false }));
|
||||
if (editorImages.length > 0) setImages(editorImages);
|
||||
setImages(editorImages);
|
||||
}
|
||||
setIsLongPostMode(prev => !prev);
|
||||
}, [isLongPostMode, content, images]);
|
||||
@@ -436,25 +423,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有图片正在上传
|
||||
if (isLongPostMode) {
|
||||
if (blockEditorRef.current?.hasUploadingImages()) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const uploadingImages = images.filter(img => img.uploading);
|
||||
if (uploadingImages.length > 0) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 检查 BlockEditor 中是否有正在上传的图片
|
||||
// (已无此状态,发布时会统一上传,无需检查)
|
||||
|
||||
setPosting(true);
|
||||
|
||||
try {
|
||||
// 普通模式下,发布前上传所有待上传的图片
|
||||
let uploadedImageUrls: string[] = [];
|
||||
if (!isLongPostMode && images.length > 0) {
|
||||
const uploadResult = await uploadAllPendingImages(images, (id, url) => {
|
||||
setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
|
||||
});
|
||||
if (!uploadResult.success) {
|
||||
setPosting(false);
|
||||
Alert.alert('上传失败', '部分图片上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
uploadedImageUrls = uploadResult.urls;
|
||||
}
|
||||
|
||||
// 长文模式下,发布前上传 BlockEditor 中所有 pending 图片
|
||||
if (isLongPostMode) {
|
||||
const uploadOk = await blockEditorRef.current?.uploadPendingImages();
|
||||
if (uploadOk === false) {
|
||||
setPosting(false);
|
||||
Alert.alert('上传失败', '部分图片上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVotePost) {
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
// 验证投票选项
|
||||
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
|
||||
if (validOptions.length < 2) {
|
||||
@@ -467,7 +466,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
await voteService.createVotePost({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||
});
|
||||
@@ -506,13 +505,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
}
|
||||
} else {
|
||||
// 普通模式
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
if (isEditMode && editPostID) {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error('更新帖子失败');
|
||||
@@ -524,7 +522,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
});
|
||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||
@@ -547,27 +545,25 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img, index) => (
|
||||
<Animated.View
|
||||
key={`${img.uri}-${index}`}
|
||||
key={img.id}
|
||||
style={[
|
||||
styles.imageGridItem,
|
||||
{
|
||||
width: imageSize,
|
||||
{
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: fadeAnim }]
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: fadeAnim }]
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: img.uri }} style={styles.gridImage} resizeMode="cover" />
|
||||
{img.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<MaterialCommunityIcons name="loading" size={20} color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<Image
|
||||
source={{ uri: getImageDisplayUri(img) }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveImage(index)}
|
||||
disabled={img.uploading}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeImageButtonInner}>
|
||||
@@ -1044,12 +1040,6 @@ function createCreatePostStyles(colors: AppColors) {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,15 +24,21 @@ import {
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Loading } from '../../components/common';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useAppColors, type AppColors } from '../../theme';
|
||||
import { Text } from '../../components/common';
|
||||
import { tradeService } from '../../services/trade/tradeService';
|
||||
import { uploadService } from '../../services';
|
||||
import type { TradeType, TradeCategory, TradeCondition, TradeItemDetailDTO } from '../../types/trade';
|
||||
import { ALL_TRADE_CATEGORIES, ALL_TRADE_CONDITIONS } from '../../types/trade';
|
||||
import {
|
||||
type PendingOrRemoteImage,
|
||||
type RemoteImage,
|
||||
generatePendingImageId,
|
||||
makePendingImageFromAsset,
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../utils/pendingImages';
|
||||
|
||||
interface CreateTradeScreenProps {
|
||||
onClose: () => void;
|
||||
@@ -258,13 +264,6 @@ function createStyles(colors: AppColors) {
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 14,
|
||||
},
|
||||
|
||||
// 提交按钮
|
||||
submitBtn: {
|
||||
@@ -323,8 +322,8 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
||||
const [condition, setCondition] = useState<TradeCondition | undefined>(
|
||||
editItem?.condition as TradeCondition | undefined
|
||||
);
|
||||
const [images, setImages] = useState<{ uri: string; uploading: boolean }[]>(
|
||||
editItem?.images?.map(img => ({ uri: img.url, uploading: false })) || []
|
||||
const [images, setImages] = useState<PendingOrRemoteImage[]>(
|
||||
editItem?.images?.map((img): RemoteImage => ({ id: generatePendingImageId('ti'), kind: 'remote', url: img.url })) || []
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -352,19 +351,29 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
||||
Alert.alert('提示', '请输入标题');
|
||||
return;
|
||||
}
|
||||
if (images.some(img => img.uploading)) {
|
||||
Alert.alert('提示', '请等待图片上传完成');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 上传所有 pending 图片
|
||||
let uploadedImageUrls: string[] = [];
|
||||
if (images.length > 0) {
|
||||
const uploadResult = await uploadAllPendingImages(images, (id, url) => {
|
||||
setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
|
||||
});
|
||||
if (!uploadResult.success) {
|
||||
setSubmitting(false);
|
||||
Alert.alert('上传失败', '部分图片上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
uploadedImageUrls = uploadResult.urls;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
trade_type: tradeType,
|
||||
category,
|
||||
title: title.trim(),
|
||||
content: content.trim() || title.trim(),
|
||||
images: images.map(img => img.uri),
|
||||
images: uploadedImageUrls,
|
||||
price: price ? parseFloat(price) : null,
|
||||
condition,
|
||||
};
|
||||
@@ -403,37 +412,16 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
|
||||
const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ti'));
|
||||
setImages(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) {
|
||||
setImages(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 {
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [images.length]);
|
||||
|
||||
const removeImage = useCallback((uri: string) => {
|
||||
setImages(prev => prev.filter(img => img.uri !== uri));
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setImages(prev => prev.filter(img => img.id !== id));
|
||||
}, []);
|
||||
|
||||
const canSubmit = title.trim().length > 0 && !submitting && !images.some(i => i.uploading);
|
||||
const canSubmit = title.trim().length > 0 && !submitting;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
@@ -558,14 +546,9 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
||||
<Text style={styles.inputLabel}>图片 ({images.length}/9)</Text>
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img) => (
|
||||
<View key={img.uri} style={styles.imageItem}>
|
||||
<Image source={{ uri: img.uri.startsWith('http') ? img.uri : img.uri }} style={styles.imagePreview} />
|
||||
{img.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
)}
|
||||
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.uri)}>
|
||||
<View key={img.id} style={styles.imageItem}>
|
||||
<Image source={{ uri: getImageDisplayUri(img) }} style={styles.imagePreview} />
|
||||
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.id)}>
|
||||
<MaterialCommunityIcons name="close" size={16} color="#fff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Constants from 'expo-constants';
|
||||
import { File as FSFile, UploadType } from 'expo-file-system';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { eventBus } from '@/core/events/EventBus';
|
||||
@@ -437,35 +438,10 @@ class ApiClient {
|
||||
file: { uri: string; name: string; type: string },
|
||||
additionalData?: Record<string, string>
|
||||
): Promise<ApiResponse<T>> {
|
||||
const formData = new FormData();
|
||||
|
||||
// 添加文件(使用 image 字段名)
|
||||
// Web 端需要 append Blob/File;原生端可以直接传 { uri, name, type }。
|
||||
if (Platform.OS === 'web') {
|
||||
const fileResponse = await fetch(file.uri);
|
||||
const blob = await fileResponse.blob();
|
||||
const uploadFile = new File([blob], file.name, { type: file.type || blob.type || 'application/octet-stream' });
|
||||
formData.append('image', uploadFile);
|
||||
} else {
|
||||
formData.append('image', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
// 添加额外数据
|
||||
if (additionalData) {
|
||||
Object.keys(additionalData).forEach(key => {
|
||||
formData.append(key, additionalData[key]);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 token 并检查是否需要刷新
|
||||
const token = await this.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
// 检查 token 是否快过期
|
||||
if (this.isTokenExpiringSoon(token)) {
|
||||
const refreshed = await this.refreshToken();
|
||||
if (refreshed) {
|
||||
@@ -479,14 +455,45 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
let data: ApiResponse<T>;
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
// Web 端:fetch + Blob/File + FormData
|
||||
const formData = new FormData();
|
||||
const fileResponse = await fetch(file.uri);
|
||||
const blob = await fileResponse.blob();
|
||||
const uploadFile = new File([blob], file.name, {
|
||||
type: file.type || blob.type || 'application/octet-stream',
|
||||
});
|
||||
formData.append('image', uploadFile);
|
||||
|
||||
if (additionalData) {
|
||||
Object.keys(additionalData).forEach(key => {
|
||||
formData.append(key, additionalData[key]);
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
data = await response.json();
|
||||
} else {
|
||||
// 原生端:使用 expo-file-system 原生 multipart 上传
|
||||
// RN 0.85 的 FormData/Blob 不支持从 URI 创建文件 part
|
||||
const fsFile = new FSFile(file.uri);
|
||||
const result = await fsFile.upload(`${this.baseUrl}${path}`, {
|
||||
httpMethod: 'POST',
|
||||
uploadType: UploadType.MULTIPART,
|
||||
fieldName: 'image',
|
||||
mimeType: file.type,
|
||||
headers,
|
||||
parameters: additionalData,
|
||||
});
|
||||
data = JSON.parse(result.body) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.code, data.message);
|
||||
}
|
||||
|
||||
89
src/utils/pendingImages.ts
Normal file
89
src/utils/pendingImages.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 通用「待上传图片」抽象
|
||||
*
|
||||
* 用于发帖、评论、发布商品等场景:
|
||||
* - 用户选择图片时仅保存本地 URI(pending)
|
||||
* - 提交时统一上传,失败可重试已上传的不会丢失
|
||||
*/
|
||||
|
||||
import type * as ImagePicker from 'expo-image-picker';
|
||||
import { uploadService } from '@/services/upload';
|
||||
|
||||
export type PendingImage = {
|
||||
id: string;
|
||||
kind: 'pending';
|
||||
localUri: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
export type RemoteImage = {
|
||||
id: string;
|
||||
kind: 'remote';
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type PendingOrRemoteImage = PendingImage | RemoteImage;
|
||||
|
||||
let _imgIdCounter = 0;
|
||||
export const generatePendingImageId = (prefix = 'img'): string =>
|
||||
`${prefix}-${++_imgIdCounter}-${Date.now()}`;
|
||||
|
||||
/** 从 ImagePicker 选择结果构造 pending 图片 */
|
||||
export const makePendingImageFromAsset = (
|
||||
asset: ImagePicker.ImagePickerAsset,
|
||||
idPrefix?: string,
|
||||
): PendingImage => ({
|
||||
id: generatePendingImageId(idPrefix),
|
||||
kind: 'pending',
|
||||
localUri: asset.uri,
|
||||
mimeType: asset.mimeType,
|
||||
});
|
||||
|
||||
/** 获取图片的展示 URI(pending 用本地,remote 用远程) */
|
||||
export const getImageDisplayUri = (img: PendingOrRemoteImage): string =>
|
||||
img.kind === 'pending' ? img.localUri : img.url;
|
||||
|
||||
/** 转换为 remote 图片(保持 id 不变) */
|
||||
export const toRemoteImage = (id: string, url: string): RemoteImage => ({
|
||||
id,
|
||||
kind: 'remote',
|
||||
url,
|
||||
});
|
||||
|
||||
export type UploadAllResult =
|
||||
| { success: true; urls: string[] }
|
||||
| { success: false; urls: string[]; failedId: string };
|
||||
|
||||
/**
|
||||
* 批量上传 pending 图片,已是 remote 的会跳过。
|
||||
* 顺序串行执行,遇到失败立即中断并返回。
|
||||
*
|
||||
* @param images 图片列表
|
||||
* @param onItemUploaded 单张图片上传成功后的回调(用于增量更新 state,
|
||||
* 外部可据此将该 id 的图片标记为 remote 以便重试时跳过)
|
||||
*/
|
||||
export const uploadAllPendingImages = async (
|
||||
images: PendingOrRemoteImage[],
|
||||
onItemUploaded?: (id: string, url: string) => void,
|
||||
): Promise<UploadAllResult> => {
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const img of images) {
|
||||
if (img.kind === 'remote') {
|
||||
urls.push(img.url);
|
||||
continue;
|
||||
}
|
||||
const result = await uploadService.uploadImage({
|
||||
uri: img.localUri,
|
||||
type: img.mimeType || 'image/jpeg',
|
||||
});
|
||||
if (result) {
|
||||
urls.push(result.url);
|
||||
onItemUploaded?.(img.id, result.url);
|
||||
} else {
|
||||
return { success: false, urls, failedId: img.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, urls };
|
||||
};
|
||||
Reference in New Issue
Block a user