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

@@ -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);
}