/** * 发帖页 CreatePostScreen(响应式适配) * 胡萝卜BBS - 发布新帖子 * 参考微博发帖界面设计 * 表单在宽屏下居中显示 * 图片选择器在宽屏下显示更大的预览 * 投票编辑器在宽屏下优化布局 */ import React, { useState, useCallback } from 'react'; import { View, ScrollView, StyleSheet, TouchableOpacity, TextInput, Alert, KeyboardAvoidingView, Platform, Animated, Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import { Text, Button, ResponsiveContainer } from '../../components/common'; import { postService, showPrompt, voteService } from '../../services'; import { ApiError } from '../../services/api'; import { uploadService } from '../../services/uploadService'; import VoteEditor from '../../components/business/VoteEditor'; import { useResponsive, useResponsiveValue } from '../../hooks'; const MAX_TITLE_LENGTH = 100; const MAX_CONTENT_LENGTH = 2000; // 表情面板高度 const EMOJI_PANEL_HEIGHT = 280; // 常用表情列表 const EMOJIS = [ '😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', '👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️\u200d🔥', '❤️\u200d🩹', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥', '💯', '💢', '💥', '💫', '💦', '💨', '🕳️', ]; // 动画值 const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const getPublishErrorMessage = (error: unknown): string => { if (error instanceof ApiError && error.message?.trim()) { return error.message.trim(); } return '发布失败,请重试'; }; export const CreatePostScreen: React.FC = () => { const navigation = useNavigation(); // 响应式布局 const { isWideScreen, width } = useResponsive(); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(''); const [showTagInput, setShowTagInput] = useState(false); const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [posting, setPosting] = useState(false); // 投票相关状态 const [isVotePost, setIsVotePost] = useState(false); const [voteOptions, setVoteOptions] = useState(['', '']); // 默认2个选项 // 内容输入框引用 const contentInputRef = React.useRef(null); const [selection, setSelection] = useState({ start: 0, end: 0 }); // 动画值 const fadeAnim = React.useRef(new Animated.Value(0)).current; const slideAnim = React.useRef(new Animated.Value(20)).current; // 响应式图片网格配置 const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 }); const imageGap = 8; const availableWidth = isWideScreen ? Math.min(width, 800) - spacing.lg * 2 : width - spacing.lg * 2; const imageSize = (availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow; React.useEffect(() => { Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true, }), Animated.timing(slideAnim, { toValue: 0, duration: 250, useNativeDriver: true, }), ]).start(); }, []); // 选择图片 const handlePickImage = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permissionResult.granted) { Alert.alert('权限不足', '需要访问相册权限来选择图片'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: 'images', allowsMultipleSelection: true, selectionLimit: 0, quality: 1, }); if (!result.canceled && result.assets) { const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); 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(); if (!permissionResult.granted) { Alert.alert('权限不足', '需要访问相机权限来拍照'); return; } const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, quality: 0.8, }); 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)); } } }; // 删除图片 const handleRemoveImage = (index: number) => { setImages(images.filter((_, i) => i !== index)); }; // 添加标签 const handleAddTag = useCallback(() => { const tag = tagInput.trim().replace(/^#/, ''); if (tag && !tags.includes(tag) && tags.length < 5) { setTags([...tags, tag]); setTagInput(''); setShowTagInput(false); } }, [tagInput, tags]); // 删除标签 const handleRemoveTag = (tag: string) => { setTags(tags.filter(t => t !== tag)); }; // 插入表情 const handleInsertEmoji = (emoji: string) => { const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end); setContent(newContent); const newPosition = selection.start + emoji.length; setSelection({ start: newPosition, end: newPosition }); }; // 关闭所有面板 const closeAllPanels = () => { setShowEmojiPanel(false); setShowTagInput(false); }; // 切换表情面板 const handleToggleEmojiPanel = () => { closeAllPanels(); setShowEmojiPanel(!showEmojiPanel); }; // 切换标签输入 const handleToggleTagInput = () => { closeAllPanels(); setShowTagInput(!showTagInput); }; // 切换投票模式 const handleToggleVote = () => { closeAllPanels(); setIsVotePost(!isVotePost); }; // 添加投票选项 const handleAddVoteOption = () => { if (voteOptions.length < 10) { setVoteOptions([...voteOptions, '']); } }; // 删除投票选项 const handleRemoveVoteOption = (index: number) => { if (voteOptions.length > 2) { setVoteOptions(voteOptions.filter((_, i) => i !== index)); } }; // 更新投票选项 const handleUpdateVoteOption = (index: number, value: string) => { const newOptions = [...voteOptions]; newOptions[index] = value; setVoteOptions(newOptions); }; // 发布帖子 const handlePost = async () => { if (!content.trim()) { Alert.alert('错误', '请输入帖子内容'); return; } // 检查是否有图片正在上传 const uploadingImages = images.filter(img => img.uploading); if (uploadingImages.length > 0) { Alert.alert('请稍候', '图片正在上传中,请稍后再试'); return; } setPosting(true); try { const imageUrls = images.map(img => img.uri); if (isVotePost) { // 验证投票选项 const validOptions = voteOptions.filter(opt => opt.trim() !== ''); if (validOptions.length < 2) { Alert.alert('错误', '投票选项至少需要2个'); setPosting(false); return; } // 创建投票帖子 await voteService.createVotePost({ title: title.trim() || '无标题', content: content.trim(), images: imageUrls, vote_options: validOptions, }); showPrompt({ type: 'info', title: '审核中', message: '投票帖已提交,内容审核中,稍后展示', duration: 2600, }); navigation.goBack(); } else { // 创建普通帖子 await postService.createPost({ title: title.trim() || '无标题', content: content.trim(), images: imageUrls, }); showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600, }); navigation.goBack(); } } catch (error) { console.error('发布帖子失败:', error); Alert.alert('错误', getPublishErrorMessage(error)); } finally { setPosting(false); } }; // 渲染图片网格 const renderImageGrid = () => { if (images.length === 0) return null; return ( {images.map((img, index) => ( {img.uploading && ( )} handleRemoveImage(index)} disabled={img.uploading} hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} > ))} {/* 添加图片按钮 */} ); }; // 渲染标签 const renderTags = () => { if (tags.length === 0 && !showTagInput) return null; return ( {tags.map((tag, index) => ( {tag} {/* 独立的删除按钮 */} handleRemoveTag(tag)} hitSlop={{ top: 5, right: 5, bottom: 5, left: 5 }} > ))} {tags.length < 5 && showTagInput && ( { setTagInput(''); setShowTagInput(false); }} style={styles.tagCancelButton}> )} {tags.length < 5 && !showTagInput && ( setShowTagInput(true)}> 话题 )} ); }; // 渲染投票编辑器 const renderVoteEditor = () => { if (!isVotePost) return null; return ( ); }; // 渲染内容输入区 const renderContentSection = () => ( {/* 标题输入 */} {/* 正文输入 */} setSelection(e.nativeEvent.selection)} /> ); // 渲染表情面板 const renderEmojiPanel = () => { if (!showEmojiPanel) return null; return ( {EMOJIS.map((emoji, index) => ( handleInsertEmoji(emoji)} > {emoji} ))} ); }; // 渲染底部工具栏 const renderToolbar = () => ( {/* 左侧功能按钮组 */} = 5 || posting} > = 5 ? colors.text.disabled : (showTagInput ? colors.primary.main : colors.text.secondary)} /> {tags.length > 0 && ( {tags.length} )} {/* 投票按钮 */} {/* 右侧发布按钮 */} {content.length}/{MAX_CONTENT_LENGTH} {posting ? ( ) : ( 发布 )} ); // 渲染主内容 const renderMainContent = () => ( <> {/* 内容输入区 */} {renderContentSection()} {/* 投票编辑器 */} {renderVoteEditor()} {/* 图片网格 */} {renderImageGrid()} {/* 标签 */} {renderTags()} {/* 表情面板 - 位于底部工具栏上方 */} {renderEmojiPanel()} {/* 底部工具栏 - 重新设计 */} {renderToolbar()} ); return ( {isWideScreen ? ( {renderMainContent()} ) : ( renderMainContent() )} ); }; const styles = StyleSheet.create({ flex: { flex: 1, }, container: { flex: 1, backgroundColor: colors.background.paper, }, scrollContent: { paddingBottom: spacing.xl, }, // 内容输入区 contentSection: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, }, titleInput: { fontSize: fontSizes.lg, fontWeight: '600', color: colors.text.primary, paddingVertical: spacing.sm, marginBottom: spacing.sm, }, titleInputWide: { fontSize: fontSizes.xl, }, contentInput: { fontSize: fontSizes.md, color: colors.text.primary, minHeight: 120, lineHeight: 22, paddingVertical: spacing.sm, }, contentInputWide: { fontSize: fontSizes.lg, lineHeight: 26, minHeight: 150, }, // 图片网格 imageGrid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: spacing.lg, paddingTop: spacing.md, gap: 8, }, imageGridItem: { borderRadius: borderRadius.md, overflow: 'hidden', backgroundColor: colors.background.disabled, }, gridImage: { width: '100%', height: '100%', }, uploadingOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center', }, removeImageButton: { position: 'absolute', top: 4, right: 4, zIndex: 10, }, removeImageButtonInner: { width: 20, height: 20, borderRadius: borderRadius.full, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', }, addImageGridButton: { borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.divider, borderStyle: 'dashed', justifyContent: 'center', alignItems: 'center', backgroundColor: colors.background.default, }, // 标签 tagsSection: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg, }, tagsContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, }, tag: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.primary.light + '15', borderRadius: borderRadius.full, paddingLeft: spacing.sm, paddingRight: spacing.xs, paddingVertical: spacing.xs, }, tagText: { marginLeft: spacing.xs, fontWeight: '500', }, tagDeleteButton: { marginLeft: spacing.xs, padding: 2, }, tagInputWrapper: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background.default, borderRadius: borderRadius.full, paddingLeft: spacing.sm, paddingRight: spacing.xs, paddingVertical: spacing.xs, borderWidth: 1, borderColor: colors.primary.main, }, tagInput: { fontSize: fontSizes.sm, color: colors.text.primary, minWidth: 80, marginLeft: spacing.xs, padding: 0, }, tagCancelButton: { padding: spacing.xs, marginRight: spacing.xs, }, tagConfirmButton: { padding: spacing.xs, }, addTagButton: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background.default, borderRadius: borderRadius.full, paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderWidth: 1, borderColor: colors.divider, borderStyle: 'dashed', }, addTagText: { marginLeft: spacing.xs, }, // 投票编辑器宽屏样式 voteEditorWide: { maxWidth: 600, alignSelf: 'center', width: '100%', }, // 底部工具栏 toolbar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, backgroundColor: colors.background.paper, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, }, toolbarLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, }, toolbarRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, }, toolbarButton: { position: 'relative', }, toolbarButtonInner: { width: 40, height: 40, borderRadius: borderRadius.full, justifyContent: 'center', alignItems: 'center', }, tagBadge: { position: 'absolute', top: 4, right: 4, minWidth: 16, height: 16, borderRadius: 8, backgroundColor: colors.primary.main, justifyContent: 'center', alignItems: 'center', }, tagBadgeText: { color: colors.primary.contrast, fontSize: fontSizes.xs, fontWeight: '600', }, charCount: { fontSize: fontSizes.xs, }, postButton: { backgroundColor: colors.primary.main, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, borderRadius: borderRadius.full, minWidth: 64, alignItems: 'center', justifyContent: 'center', }, postButtonDisabled: { backgroundColor: colors.text.disabled, }, postButtonText: { fontWeight: '600', fontSize: fontSizes.sm, }, // 表情面板 emojiPanel: { height: EMOJI_PANEL_HEIGHT, backgroundColor: colors.background.paper, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, }, emojiPanelWide: { height: 320, }, emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: spacing.md, justifyContent: 'flex-start', }, emojiItem: { width: '12.5%', aspectRatio: 1, justifyContent: 'center', alignItems: 'center', }, emojiText: { fontSize: 24, }, }); export default CreatePostScreen;