Merge branch 'dev' of https://code.littlelan.cn/carrot_bbs/frontend into dev
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -364,7 +364,7 @@ export const HomeScreen: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (homeTabPressCount > 0) {
|
||||
// 滚动 FlashList 到顶部
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
// 滚动 ScrollView 到顶部(网格模式)
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
// 重置底部 Tab 栏状态
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -208,11 +208,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.96)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
elevation: 4,
|
||||
},
|
||||
jumpToLatestFabText: {
|
||||
fontSize: 13,
|
||||
|
||||
@@ -1104,7 +1104,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)];
|
||||
// 有文本或回复时才构建文本段,纯图片时不塞空 text
|
||||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||
: [];
|
||||
for (const url of uploadedUrls) {
|
||||
segments.push({
|
||||
type: 'image',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => {
|
||||
<UserProfileScreen
|
||||
mode={isSelfProfile ? 'self' : 'other'}
|
||||
userId={isSelfProfile ? undefined : userId}
|
||||
hasHeader={false}
|
||||
hasHeader
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user