From 81ee9ba75b85d7101ef8cea70d8f476596f3999e Mon Sep 17 00:00:00 2001 From: lan Date: Fri, 8 May 2026 13:19:40 +0800 Subject: [PATCH] feat(business): enhance block editor with segment conversion and data persistence - Implement `segmentsToBlocks` utility to convert message segments into editable blocks. - Update `BlockEditor` and `useBlockEditor` to support initial block loading and improved image upload handling. - Add logic to `CreatePostScreen` to automatically switch to long-post mode when image segments are detected and preserve data when toggling modes. - Update `AboutScreen` to make the ICP filing number clickable. - Refactor mapper imports to use the updated data model paths. --- .../business/BlockEditor/BlockEditor.tsx | 8 +- .../business/BlockEditor/blocksToSegments.ts | 42 ++++- .../business/BlockEditor/useBlockEditor.ts | 154 ++++++++++-------- src/screens/create/CreatePostScreen.tsx | 70 ++++++-- src/screens/profile/AboutScreen.tsx | 3 +- src/services/mappers/ConversationMapper.ts | 2 +- src/services/mappers/MessageMapper.ts | 2 +- src/services/mappers/PostMapper.ts | 2 +- src/services/mappers/UserMapper.ts | 2 +- 9 files changed, 196 insertions(+), 89 deletions(-) diff --git a/src/components/business/BlockEditor/BlockEditor.tsx b/src/components/business/BlockEditor/BlockEditor.tsx index e02f8c6..db9cde5 100644 --- a/src/components/business/BlockEditor/BlockEditor.tsx +++ b/src/components/business/BlockEditor/BlockEditor.tsx @@ -3,12 +3,14 @@ import { View, ScrollView, StyleSheet } from 'react-native'; import { useAppColors, spacing } from '../../../theme'; import { MessageSegment } from '../../../types'; +import { EditorBlock } from './blockEditorTypes'; import TextBlockInput from './TextBlockInput'; import ImageBlockView from './ImageBlockView'; import { useBlockEditor } from './useBlockEditor'; export interface BlockEditorHandle { insertImage: () => Promise; + insertCameraPhoto: () => Promise; insertTextAtCursor: (text: string) => void; getSegments: () => MessageSegment[]; getContent: () => string; @@ -22,15 +24,17 @@ interface BlockEditorProps { style?: any; textStyle?: any; onContentChange?: (content: string) => void; + initialBlocks?: EditorBlock[]; } const BlockEditor = React.forwardRef( - ({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange }, ref) => { + ({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange, initialBlocks }, ref) => { const colors = useAppColors(); - const editor = useBlockEditor(); + const editor = useBlockEditor(initialBlocks); useImperativeHandle(ref, () => ({ insertImage: editor.insertImage, + insertCameraPhoto: editor.insertCameraPhoto, insertTextAtCursor: editor.insertTextAtCursor, getSegments: editor.getSegments, getContent: editor.getContent, diff --git a/src/components/business/BlockEditor/blocksToSegments.ts b/src/components/business/BlockEditor/blocksToSegments.ts index 6e047e4..2c9d0e0 100644 --- a/src/components/business/BlockEditor/blocksToSegments.ts +++ b/src/components/business/BlockEditor/blocksToSegments.ts @@ -1,5 +1,45 @@ import { MessageSegment } from '../../../types'; -import { EditorBlock, blockTextToSegments } from './blockEditorTypes'; +import { EditorBlock, TextBlock, ImageBlock, blockTextToSegments, generateBlockId } from './blockEditorTypes'; + +export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] { + if (!segments || segments.length === 0) return []; + + const blocks: EditorBlock[] = []; + + for (const seg of segments) { + if (seg.type === 'image' && seg.data?.url) { + const imageBlock: ImageBlock = { + id: generateBlockId(), + type: 'image', + localUri: seg.data.url, + remoteUrl: seg.data.url, + uploading: false, + width: seg.data.width, + height: seg.data.height, + }; + blocks.push(imageBlock); + } else if (seg.type === 'text') { + const text = seg.data?.text || seg.data?.content || ''; + if (text) { + const textBlock: TextBlock = { + id: generateBlockId(), + type: 'text', + text, + activeMentions: new Map(), + }; + blocks.push(textBlock); + } + } + // Skip at/other segment types — mentions from long posts are not editable in block editor + } + + // Ensure at least one text block + if (blocks.length === 0) { + blocks.push({ id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() }); + } + + return blocks; +} export function blocksToSegments(blocks: EditorBlock[]): MessageSegment[] { const segments: MessageSegment[] = []; diff --git a/src/components/business/BlockEditor/useBlockEditor.ts b/src/components/business/BlockEditor/useBlockEditor.ts index ae5e31c..62eef8e 100644 --- a/src/components/business/BlockEditor/useBlockEditor.ts +++ b/src/components/business/BlockEditor/useBlockEditor.ts @@ -19,8 +19,8 @@ interface MentionData { nickname: string; } -export function useBlockEditor() { - const [blocks, setBlocks] = useState([createInitialTextBlock()]); +export function useBlockEditor(initialBlocks?: EditorBlock[]) { + const [blocks, setBlocks] = useState(initialBlocks && initialBlocks.length > 0 ? initialBlocks : [createInitialTextBlock()]); const [activeBlockId, setActiveBlockId] = useState(null); const inputRefs = useRef>(new Map()); const blockCursors = useRef>(new Map()); @@ -87,21 +87,56 @@ export function useBlockEditor() { }); }, [focusBlock]); - const insertImage = useCallback(async () => { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!permissionResult.granted) { - Alert.alert('权限不足', '需要访问相册权限来选择图片'); - return; - } + const uploadImageByLocalUri = useCallback( + async (asset: ImagePicker.ImagePickerAsset) => { + try { + const uploadResult = await uploadService.uploadImage({ + uri: asset.uri, + type: asset.mimeType || 'image/jpeg', + }); - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: 'images', - allowsMultipleSelection: true, - selectionLimit: 0, - quality: 1, - }); + 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('上传失败', '图片上传失败,请重试'); + } + } catch { + setBlocks(prev => + prev.map(b => + b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl + ? { ...b, uploading: false } + : b + ) + ); + } + }, + [], + ); + + const insertImagesFromAssets = useCallback( + (assets: ImagePicker.ImagePickerAsset[]) => { + if (!assets.length) return; - if (!result.canceled && result.assets) { setBlocks(prev => { let updated = [...prev]; let currentActiveId = activeBlockId; @@ -111,7 +146,7 @@ export function useBlockEditor() { insertAfterIndex = updated.length - 1; } - for (const asset of result.assets) { + for (const asset of assets) { const activeBlock = updated[insertAfterIndex]; const cursorPos = activeBlock && activeBlock.type === 'text' ? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length) @@ -170,59 +205,49 @@ export function useBlockEditor() { }); // Start async uploads - for (const asset of result.assets) { - // Find the newly created image block by localUri + for (const asset of assets) { uploadImageByLocalUri(asset); } - } - }, [activeBlockId, focusBlock]); - - const uploadImageByLocalUri = useCallback( - async (asset: ImagePicker.ImagePickerAsset) => { - try { - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - 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('上传失败', '图片上传失败,请重试'); - } - } catch { - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { ...b, uploading: false } - : b - ) - ); - } }, - [], + [activeBlockId, focusBlock, uploadImageByLocalUri], ); + const insertImage = useCallback(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) { + insertImagesFromAssets(result.assets); + } + }, [insertImagesFromAssets]); + + const insertCameraPhoto = useCallback(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 && result.assets[0]) { + insertImagesFromAssets([result.assets[0]]); + } + }, [insertImagesFromAssets]); + const insertTextAtCursor = useCallback((text: string) => { const activeId = activeBlockId; if (!activeId) return; @@ -278,6 +303,7 @@ export function useBlockEditor() { updateTextBlock, removeImageBlock, insertImage, + insertCameraPhoto, insertTextAtCursor, focusBlock, getSegments, diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 832ad16..7c8cc83 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -42,6 +42,8 @@ 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 { useResponsive, useResponsiveValue } from '../../hooks'; import * as hrefs from '../../navigation/hrefs'; import { MessageSegment } from '../../types'; @@ -135,9 +137,10 @@ export const CreatePostScreen: React.FC = (props) => { const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [posting, setPosting] = useState(false); const [loadingPost, setLoadingPost] = useState(false); - + // 长文模式:图片作为 image segment 嵌入文本 const [isLongPostMode, setIsLongPostMode] = useState(false); + const [initialBlocks, setInitialBlocks] = useState(undefined); // 投票相关状态 const [isVotePost, setIsVotePost] = useState(false); @@ -211,6 +214,13 @@ export const CreatePostScreen: React.FC = (props) => { setTitle(existingPost.title || ''); setContent(existingPost.content || ''); setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false }))); + + // 自动判断长文模式:segments 中包含图片块即为长文帖子 + const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image'); + if (hasImageSegments) { + setIsLongPostMode(true); + setInitialBlocks(segmentsToBlocks(existingPost.segments || [])); + } } catch (error) { console.error('加载待编辑帖子失败:', error); Alert.alert('错误', '加载帖子失败,请稍后重试'); @@ -337,6 +347,33 @@ export const CreatePostScreen: React.FC = (props) => { setShowChannelPicker(false); }; + // 切换长文/非长文模式时保留数据 + const handleToggleLongPostMode = useCallback(() => { + if (!isLongPostMode) { + // 非长文 -> 长文:将当前文本和图片转为 blocks + const segs: MessageSegment[] = []; + if (content.trim()) { + segs.push({ type: 'text', data: { text: content } }); + } + for (const img of images) { + if (img.uri && !img.uploading) { + segs.push({ type: 'image', data: { url: img.uri } }); + } + } + 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 => s.type === 'image') + .map(s => ({ uri: s.data.url, uploading: false })); + if (editorImages.length > 0) setImages(editorImages); + } + setIsLongPostMode(prev => !prev); + }, [isLongPostMode, content, images]); + // 切换表情面板 const handleToggleEmojiPanel = () => { const willShow = !showEmojiPanel; @@ -666,6 +703,7 @@ export const CreatePostScreen: React.FC = (props) => { isWideScreen && styles.contentInputWide, ]} onContentChange={setContent} + initialBlocks={initialBlocks} /> ) : ( <> @@ -751,21 +789,19 @@ export const CreatePostScreen: React.FC = (props) => { - {!isLongPostMode && ( - - - - - - )} + blockEditorRef.current?.insertCameraPhoto() : handleTakePhoto} + disabled={posting} + > + + + + = (props) => { {/* 长文模式开关 */} setIsLongPostMode(prev => !prev)} + onPress={handleToggleLongPostMode} disabled={posting} > diff --git a/src/screens/profile/AboutScreen.tsx b/src/screens/profile/AboutScreen.tsx index 40b18d6..2e703e3 100644 --- a/src/screens/profile/AboutScreen.tsx +++ b/src/screens/profile/AboutScreen.tsx @@ -300,6 +300,7 @@ function createAboutStyles(colors: AppColors) { fontSize: 12, color: colors.text.hint, marginTop: spacing.xs, + textDecorationLine: 'underline', }, }); } @@ -571,7 +572,7 @@ export const AboutScreen: React.FC = () => { © 2026 青春之旅电子信息科技(威海)有限公司 - 鲁ICP备XXXXXXXX号-1 + Linking.openURL('https://beian.miit.gov.cn/')}>鲁ICP备2025144569号-2A diff --git a/src/services/mappers/ConversationMapper.ts b/src/services/mappers/ConversationMapper.ts index d3965a8..3ce6ffc 100644 --- a/src/services/mappers/ConversationMapper.ts +++ b/src/services/mappers/ConversationMapper.ts @@ -9,7 +9,7 @@ import { UserModel, GroupModel, MessageModel, -} from '../models'; +} from '../../data/models'; import type { ConversationResponse, ConversationDetailResponse, diff --git a/src/services/mappers/MessageMapper.ts b/src/services/mappers/MessageMapper.ts index e22cedd..510316c 100644 --- a/src/services/mappers/MessageMapper.ts +++ b/src/services/mappers/MessageMapper.ts @@ -7,7 +7,7 @@ import { MessageModel, MessageSegment, UserModel, -} from '../models'; +} from '../../data/models'; import type { MessageResponse, UserDTO, diff --git a/src/services/mappers/PostMapper.ts b/src/services/mappers/PostMapper.ts index c37ce4e..e0ee2a8 100644 --- a/src/services/mappers/PostMapper.ts +++ b/src/services/mappers/PostMapper.ts @@ -3,7 +3,7 @@ * 负责 Post 模型与 API 响应之间的转换 */ -import { PostModel, UserModel } from '../models'; +import { PostModel, UserModel } from '../../data/models'; import type { PostDTO } from '../../types/dto'; export class PostMapper { diff --git a/src/services/mappers/UserMapper.ts b/src/services/mappers/UserMapper.ts index f2e5259..1617e73 100644 --- a/src/services/mappers/UserMapper.ts +++ b/src/services/mappers/UserMapper.ts @@ -3,7 +3,7 @@ * 负责 User 模型与 API 响应、数据库记录之间的转换 */ -import { UserModel } from '../models'; +import { UserModel } from '../../data/models'; import type { UserDTO } from '../../types/dto'; // 数据库用户记录类型