feat(business): implement block-based content editing and rich text rendering
All checks were successful
Frontend CI / ota-android (push) Successful in 1m18s
Frontend CI / ota-ios (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / build-android-apk (push) Successful in 1h18m20s

Introduce a new `BlockEditor` component and upgrade the post/message
rendering system to support rich text segments (images, @mentions,
votes, and post references).

- Implement `BlockEditor` for long-form post creation with image
  embedding support.
- Upgrade `PostContentRenderer` and `SegmentRenderer` to handle
  complex segment types including inline images and block elements.
- Refactor `PostCard` to use segment-based content and image
  signatures for optimized memoization.
- Centralize segment partitioning logic in `segmentUtils.ts` to ensure
  consistent rendering across chat and post modules.
- Update `PostRepository` and `Post` entity to support the new
  `segments` data structure.
This commit is contained in:
2026-05-08 01:57:05 +08:00
parent ea9e51b0b0
commit d4c3e1f268
18 changed files with 1416 additions and 234 deletions

View File

@@ -41,6 +41,7 @@ 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 { useResponsive, useResponsiveValue } from '../../hooks';
import * as hrefs from '../../navigation/hrefs';
import { MessageSegment } from '../../types';
@@ -135,12 +136,16 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const [posting, setPosting] = useState(false);
const [loadingPost, setLoadingPost] = useState(false);
// 长文模式:图片作为 image segment 嵌入文本
const [isLongPostMode, setIsLongPostMode] = useState(false);
// 投票相关状态
const [isVotePost, setIsVotePost] = useState(false);
const [voteOptions, setVoteOptions] = useState<string[]>(['', '']); // 默认2个选项
// 内容输入框引用
const contentInputRef = React.useRef<TextInput>(null);
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
const [selection, setSelection] = useState({ start: 0, end: 0 });
// 动画值
@@ -218,7 +223,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
loadPostForEdit();
}, [isEditMode, editPostID, router]);
// 选择图片
// 选择图片(普通模式)
const handlePickImage = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -308,7 +313,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
setImages(images.filter((_, i) => i !== index));
};
// 选择频道(单选,可再次点击取消)
// 选择频道(单选,可再次点击取消)
const handleSelectChannel = useCallback((channelId: string) => {
setSelectedChannelId(prev => (prev === channelId ? null : channelId));
setShowChannelPicker(false);
@@ -316,6 +321,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 插入表情
const handleInsertEmoji = (emoji: string) => {
if (isLongPostMode) {
blockEditorRef.current?.insertTextAtCursor(emoji);
return;
}
const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end);
setContent(newContent);
const newPosition = selection.start + emoji.length;
@@ -381,24 +390,34 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
return;
}
if (!content.trim()) {
const contentToCheck = isLongPostMode
? (blockEditorRef.current?.getContent() || content)
: content;
if (!contentToCheck.trim()) {
Alert.alert('错误', '请输入帖子内容');
return;
}
// 检查是否有图片正在上传
const uploadingImages = images.filter(img => img.uploading);
if (uploadingImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
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;
}
}
setPosting(true);
try {
const imageUrls = images.map(img => img.uri);
if (isVotePost) {
const imageUrls = images.map(img => img.uri);
// 验证投票选项
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
if (validOptions.length < 2) {
@@ -406,7 +425,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
setPosting(false);
return;
}
// 创建投票帖子
await voteService.createVotePost({
title: title.trim(),
@@ -422,7 +441,35 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
duration: 2600,
});
router.replace(hrefs.hrefHome());
} else if (isLongPostMode) {
// 长文模式:从 BlockEditor 获取 segments
const editorSegments = blockEditorRef.current?.getSegments() || [];
const editorContent = blockEditorRef.current?.getContent() || '';
const finalSegments = editorSegments.length > 0 ? editorSegments : undefined;
if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, {
title: title.trim(),
content: editorContent.trim(),
segments: finalSegments,
});
if (!updated) {
throw new Error('更新帖子失败');
}
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
router.replace(hrefs.hrefHome());
} else {
await postService.createPost({
title: title.trim(),
content: editorContent.trim(),
segments: finalSegments,
channel_id: selectedChannelId || undefined,
});
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
router.replace(hrefs.hrefHome());
}
} else {
// 普通模式
const imageUrls = images.map(img => img.uri);
if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, {
title: title.trim(),
@@ -433,12 +480,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
if (!updated) {
throw new Error('更新帖子失败');
}
showPrompt({
type: 'success',
title: '修改成功',
message: '帖子内容已更新',
duration: 2200,
});
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
router.replace(hrefs.hrefHome());
} else {
await postService.createPost({
@@ -448,12 +490,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
images: imageUrls.length > 0 ? imageUrls : undefined,
channel_id: selectedChannelId || undefined,
});
showPrompt({
type: 'info',
title: '审核中',
message: '帖子已提交,内容审核中,稍后展示',
duration: 2600,
});
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
router.replace(hrefs.hrefHome());
}
}
@@ -619,23 +656,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
maxLength={MAX_TITLE_LENGTH}
/>
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
<PostMentionInput
value={content}
onChangeText={setContent}
onSegmentsChange={setSegments}
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
minHeight={contentInputMinHeight}
autoExpand
style={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
]}
/>
{isLongPostMode ? (
<BlockEditor
ref={blockEditorRef}
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
textStyle={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
]}
onContentChange={setContent}
/>
) : (
<>
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
<PostMentionInput
value={content}
onChangeText={setContent}
onSegmentsChange={setSegments}
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
minHeight={contentInputMinHeight}
autoExpand
style={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
]}
/>
{renderImageGrid()}
</>
)}
{/* 作为正文容器的子模块,追加在文本后面 */}
{renderImageGrid()}
{renderVoteEditor()}
</View>
);
@@ -688,7 +739,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
<View style={styles.toolbarLeft}>
<TouchableOpacity
style={styles.toolbarButton}
onPress={handlePickImage}
onPress={isLongPostMode ? () => blockEditorRef.current?.insertImage() : handlePickImage}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
@@ -700,19 +751,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.toolbarButton}
onPress={handleTakePhoto}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
<MaterialCommunityIcons
name="camera-outline"
size={24}
color={colors.text.secondary}
/>
</View>
</TouchableOpacity>
{!isLongPostMode && (
<TouchableOpacity
style={styles.toolbarButton}
onPress={handleTakePhoto}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
<MaterialCommunityIcons
name="camera-outline"
size={24}
color={colors.text.secondary}
/>
</View>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.toolbarButton}
@@ -742,6 +795,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
/>
</View>
</TouchableOpacity>
{/* 长文模式开关 */}
<TouchableOpacity
style={styles.toolbarButton}
onPress={() => setIsLongPostMode(prev => !prev)}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
<MaterialCommunityIcons
name={isLongPostMode ? "text-box" : "text-box-outline"}
size={24}
color={isLongPostMode ? colors.primary.main : colors.text.secondary}
/>
</View>
</TouchableOpacity>
</View>
</View>
);

View File

@@ -1125,11 +1125,12 @@ export const PostDetailScreen: React.FC = () => {
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
}
]}
onImagePress={(url) => handleImagePress([{ uri: url }], 0)}
/>
</View>
{/* 图片 - 详情页显示所有图片,不限制数量 */}
{postImages.length > 0 && (
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
{!post.segments?.some(s => s.type === 'image') && postImages.length > 0 && (
<ImageGrid
images={postImages}
mode="auto"

View File

@@ -39,43 +39,7 @@ const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */
type ContentChunk =
| { kind: 'inline'; parts: MessageSegment[] }
| { kind: 'images'; parts: MessageSegment[] }
| { kind: 'block'; segment: MessageSegment };
function partitionMessageSegments(segments: MessageSegment[]): ContentChunk[] {
const out: ContentChunk[] = [];
let inlineBuf: MessageSegment[] = [];
const flushInline = () => {
if (inlineBuf.length) {
out.push({ kind: 'inline', parts: [...inlineBuf] });
inlineBuf = [];
}
};
for (const s of segments) {
if (s.type === 'image') {
flushInline();
const last = out[out.length - 1];
if (last?.kind === 'images') {
last.parts.push(s);
} else {
out.push({ kind: 'images', parts: [s] });
}
} else if (s.type === 'video' || s.type === 'file' || s.type === 'link' || s.type === 'voice') {
flushInline();
out.push({ kind: 'block', segment: s });
} else if (s.type === 'text' || s.type === 'at' || s.type === 'face') {
inlineBuf.push(s);
} else {
inlineBuf.push(s);
}
}
flushInline();
return out;
}
import { partitionSegments, type ContentChunk } from '../../../../types/dto';
// Segment 渲染器 Props
export interface SegmentRendererProps {
@@ -741,7 +705,7 @@ const MessageSegmentsRendererInner: React.FC<{
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
const chunks = useMemo(() => partitionSegments(otherSegments), [otherSegments]);
const renderProps = useMemo(() => ({
isMe,

View File

@@ -296,11 +296,11 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
alignItems: 'flex-start',
},
senderName: {
fontSize: 14,
color: textPrimary,
fontSize: 12,
color: textSecondary,
marginBottom: 4,
marginLeft: 4,
fontWeight: '600',
fontWeight: '500',
},
mySenderName: {
marginLeft: 0,