refactor(Post, PostMapper, PostRepository, CreatePostScreen, HomeScreen): update communityId to channelId for improved clarity
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m48s
Frontend CI / ota-android (push) Successful in 11m3s
Frontend CI / build-android-apk (push) Has been cancelled

- Renamed communityId to channelId across Post entity, PostMapper, and PostRepository for consistency and clarity.
- Updated CreatePostScreen to include channel selection functionality, enhancing user experience when creating posts.
- Adjusted HomeScreen to support filtering posts by channel, improving content organization.
- Refactored related interfaces and services to align with the new channelId terminology, ensuring a cohesive codebase.
This commit is contained in:
lafay
2026-03-24 22:27:33 +08:00
parent b49cc0f3bd
commit 126e204592
15 changed files with 439 additions and 260 deletions

View File

@@ -19,14 +19,15 @@ import {
Platform,
Animated,
Image,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
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, AppBackButton } from '../../components/common';
import { postService, showPrompt, voteService } from '../../services';
import { Text, ResponsiveContainer } from '../../components/common';
import { channelService, postService, showPrompt, voteService } from '../../services';
import { ApiError } from '../../services/api';
import { uploadService } from '../../services/uploadService';
import VoteEditor from '../../components/business/VoteEditor';
@@ -40,6 +41,10 @@ interface CreatePostScreenProps {
const MAX_TITLE_LENGTH = 100;
const MAX_CONTENT_LENGTH = 2000;
type ChannelOption = {
id: string;
name: string;
};
// 表情面板高度
const EMOJI_PANEL_HEIGHT = 280;
@@ -88,13 +93,14 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 响应式布局
const { isWideScreen, width } = useResponsive();
const { height: windowHeight } = useWindowDimensions();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
const [showTagInput, setShowTagInput] = useState(false);
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const [showChannelPicker, setShowChannelPicker] = useState(false);
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [posting, setPosting] = useState(false);
const [loadingPost, setLoadingPost] = useState(false);
@@ -118,6 +124,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
? Math.min(width, 800) - spacing.lg * 2
: width - spacing.lg * 2;
const imageSize = (availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow;
const contentInputMinHeight = Math.max(
isWideScreen ? 460 : 320,
Math.floor(windowHeight * (isWideScreen ? 0.56 : 0.5))
);
const contentSectionMinHeight = Math.floor(windowHeight * (isWideScreen ? 0.68 : 0.62));
React.useEffect(() => {
Animated.parallel([
@@ -134,16 +145,19 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
]).start();
}, []);
React.useEffect(() => {
const loadChannels = async () => {
const list = await channelService.list();
setChannelOptions(list.map(item => ({ id: item.id, name: item.name })));
};
loadChannels();
}, []);
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
title: isEditMode ? '编辑帖子' : '发布帖子',
// 当作为 Modal 使用时,显示关闭按钮
headerLeft: onClose ? () => (
<AppBackButton onPress={onClose} icon="close" />
) : undefined,
headerShown: false,
});
}, [navigation, isEditMode, onClose]);
}, [navigation]);
React.useEffect(() => {
if (!isEditMode || !editPostID) {
@@ -264,20 +278,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
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 handleSelectChannel = useCallback((channelId: string) => {
setSelectedChannelId(prev => (prev === channelId ? null : channelId));
setShowChannelPicker(false);
}, []);
// 插入表情
const handleInsertEmoji = (emoji: string) => {
@@ -290,7 +295,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 关闭所有面板
const closeAllPanels = () => {
setShowEmojiPanel(false);
setShowTagInput(false);
setShowChannelPicker(false);
};
// 切换表情面板
@@ -299,10 +304,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
setShowEmojiPanel(!showEmojiPanel);
};
// 切换标签输入
const handleToggleTagInput = () => {
// 切换频道选择
const handleToggleChannelPicker = () => {
closeAllPanels();
setShowTagInput(!showTagInput);
setShowChannelPicker(!showChannelPicker);
};
// 切换投票模式
@@ -373,6 +378,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
title: title.trim(),
content: content.trim(),
images: imageUrls,
channel_id: selectedChannelId || undefined,
vote_options: validOptions,
});
showPrompt({
@@ -405,6 +411,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
title: title.trim(),
content: content.trim(),
images: imageUrls,
channel_id: selectedChannelId || undefined,
});
showPrompt({
type: 'info',
@@ -474,66 +481,80 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
);
};
// 渲染标签
const renderTags = () => {
if (tags.length === 0 && !showTagInput) return null;
// 渲染频道(单选)
const renderChannelPicker = () => {
if (!showChannelPicker) return null;
return (
<View style={styles.tagsSection}>
<View style={styles.tagsContainer}>
{tags.map((tag, index) => (
<View key={index} style={styles.tag}>
<MaterialCommunityIcons name="pound" size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.tagText}>{tag}</Text>
{/* 独立的删除按钮 */}
{channelOptions.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
<TouchableOpacity
style={styles.tagDeleteButton}
onPress={() => handleRemoveTag(tag)}
hitSlop={{ top: 5, right: 5, bottom: 5, left: 5 }}
key={channel.id}
style={[styles.tag, isSelected && styles.channelSelected]}
onPress={() => handleSelectChannel(channel.id)}
>
<MaterialCommunityIcons name="close-circle" size={14} color={colors.primary.main} />
<Text
variant="caption"
color={isSelected ? colors.primary.contrast : colors.text.secondary}
style={styles.tagText}
>
{channel.name}
</Text>
</TouchableOpacity>
</View>
))}
{tags.length < 5 && showTagInput && (
<View style={styles.tagInputWrapper}>
<MaterialCommunityIcons name="pound" size={14} color={colors.primary.main} />
<TextInput
style={styles.tagInput}
value={tagInput}
onChangeText={setTagInput}
placeholder="输入话题"
placeholderTextColor={colors.text.hint}
onSubmitEditing={handleAddTag}
returnKeyType="done"
autoFocus
maxLength={20}
/>
<TouchableOpacity onPress={() => { setTagInput(''); setShowTagInput(false); }} style={styles.tagCancelButton}>
<MaterialCommunityIcons name="close" size={16} color={colors.text.secondary} />
</TouchableOpacity>
<TouchableOpacity onPress={handleAddTag} style={styles.tagConfirmButton}>
<MaterialCommunityIcons name="check" size={16} color={colors.primary.main} />
</TouchableOpacity>
</View>
)}
{tags.length < 5 && !showTagInput && (
<TouchableOpacity style={styles.addTagButton} onPress={() => setShowTagInput(true)}>
<MaterialCommunityIcons name="plus" size={14} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.addTagText}></Text>
</TouchableOpacity>
)}
);
})}
</View>
{channelOptions.length === 0 && (
<Text variant="caption" color={colors.text.hint}></Text>
)}
</View>
);
};
const selectedChannelName = React.useMemo(() => {
if (!selectedChannelId) return '';
return channelOptions.find(c => c.id === selectedChannelId)?.name || '';
}, [selectedChannelId, channelOptions]);
const renderChannelEntryRow = () => (
<TouchableOpacity
style={styles.channelEntryRow}
onPress={handleToggleChannelPicker}
activeOpacity={0.8}
>
<Text variant="body" color={colors.text.secondary} style={styles.channelEntryLabel}>
</Text>
<View style={styles.channelEntryValueWrap}>
<Text
variant="body"
color={selectedChannelName ? colors.text.primary : colors.text.hint}
style={styles.channelEntryValue}
>
{selectedChannelName || '选择频道'}
</Text>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.text.hint} />
</View>
</TouchableOpacity>
);
// 渲染投票编辑器
const renderVoteEditor = () => {
if (!isVotePost) return null;
return (
<View style={isWideScreen && styles.voteEditorWide}>
<View style={styles.voteEditorHeaderRow}>
<Text variant="caption" color={colors.text.secondary}>
</Text>
<TouchableOpacity onPress={() => setIsVotePost(false)} activeOpacity={0.7}>
<Text variant="caption" color={colors.primary.main}></Text>
</TouchableOpacity>
</View>
<VoteEditor
options={voteOptions}
onAddOption={handleAddVoteOption}
@@ -549,7 +570,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 渲染内容输入区
const renderContentSection = () => (
<View style={styles.contentSection}>
<View
style={[
styles.contentSection,
contentSectionMinHeight ? { minHeight: contentSectionMinHeight } : null,
]}
>
{/* 标题输入 */}
<TextInput
style={[
@@ -569,6 +595,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
style={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
{ minHeight: contentInputMinHeight },
]}
value={content}
onChangeText={setContent}
@@ -579,6 +606,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
maxLength={MAX_CONTENT_LENGTH}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
/>
{/* 作为正文容器的子模块,追加在文本后面 */}
{renderImageGrid()}
{renderVoteEditor()}
</View>
);
@@ -642,25 +673,6 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.toolbarButton}
onPress={handleToggleTagInput}
disabled={tags.length >= 5 || posting}
>
<View style={styles.toolbarButtonInner}>
<MaterialCommunityIcons
name="pound"
size={24}
color={tags.length >= 5 ? colors.text.disabled : (showTagInput ? colors.primary.main : colors.text.secondary)}
/>
{tags.length > 0 && (
<View style={styles.tagBadge}>
<Text style={styles.tagBadgeText}>{tags.length}</Text>
</View>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.toolbarButton}
onPress={handleToggleEmojiPanel}
@@ -690,36 +702,54 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
</View>
</TouchableOpacity>
</View>
</View>
);
{/* 右侧发布按钮 */}
<View style={styles.toolbarRight}>
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
const renderTopActionBar = () => (
<View style={styles.topActionBar}>
<TouchableOpacity
onPress={() => (onClose ? onClose() : router.back())}
style={styles.topActionButton}
activeOpacity={0.7}
>
<Text variant="body" color={colors.text.secondary}></Text>
</TouchableOpacity>
<View style={styles.topActionCenter}>
<Text variant="body" color={colors.text.primary} style={styles.topActionTitle}>
{isEditMode ? '编辑帖子' : '发布帖子'}
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.topActionCount}>
{content.length}/{MAX_CONTENT_LENGTH}
</Text>
<TouchableOpacity
style={[
styles.postButton,
(!content.trim() || posting) && styles.postButtonDisabled
]}
onPress={handlePost}
disabled={!content.trim() || posting}
activeOpacity={0.8}
>
{posting ? (
<MaterialCommunityIcons name="loading" size={18} color={colors.primary.contrast} />
) : (
<Text variant="body" color={colors.primary.contrast} style={styles.postButtonText}>
{isEditMode ? '保存' : '发布'}
</Text>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handlePost}
disabled={!content.trim() || posting}
style={[
styles.topActionButton,
styles.topActionButtonRight,
(!content.trim() || posting) && styles.topActionButtonDisabled,
]}
activeOpacity={0.8}
>
<Text
variant="body"
color={!content.trim() || posting ? colors.text.hint : colors.primary.main}
style={styles.topActionPublishText}
>
{posting ? '发布中' : '发布'}
</Text>
</TouchableOpacity>
</View>
);
// 渲染主内容
const renderMainContent = () => (
<>
{renderTopActionBar()}
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
@@ -727,17 +757,13 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
>
{/* 内容输入区 */}
{renderContentSection()}
{/* 投票编辑器 */}
{renderVoteEditor()}
{/* 图片网格 */}
{renderImageGrid()}
{/* 标签 */}
{renderTags()}
</ScrollView>
{renderChannelEntryRow()}
{/* 频道选择面板 */}
{renderChannelPicker()}
{/* 表情面板 - 位于底部工具栏上方 */}
{renderEmojiPanel()}
@@ -788,10 +814,48 @@ const styles = StyleSheet.create({
backgroundColor: colors.background.paper,
},
scrollContent: {
flexGrow: 1,
paddingBottom: spacing.xl,
},
topActionBar: {
height: 52,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
backgroundColor: colors.background.paper,
},
topActionButton: {
minWidth: 44,
height: 36,
justifyContent: 'center',
},
topActionButtonRight: {
alignItems: 'flex-end',
},
topActionButtonDisabled: {
opacity: 0.7,
},
topActionTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
},
topActionCenter: {
alignItems: 'center',
justifyContent: 'center',
},
topActionCount: {
marginTop: 2,
fontSize: fontSizes.xs,
},
topActionPublishText: {
fontWeight: '600',
},
// 内容输入区
contentSection: {
flexGrow: 1,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
@@ -806,22 +870,21 @@ const styles = StyleSheet.create({
fontSize: fontSizes.xl,
},
contentInput: {
flexGrow: 1,
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,
paddingHorizontal: 0,
paddingTop: spacing.md,
gap: 8,
},
@@ -866,7 +929,11 @@ const styles = StyleSheet.create({
// 标签
tagsSection: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
backgroundColor: colors.background.paper,
},
tagsContainer: {
flexDirection: 'row',
@@ -874,60 +941,19 @@ const styles = StyleSheet.create({
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,
channelSelected: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
tagText: {
fontWeight: '500',
},
// 投票编辑器宽屏样式
voteEditorWide: {
@@ -935,7 +961,36 @@ const styles = StyleSheet.create({
alignSelf: 'center',
width: '100%',
},
voteEditorHeaderRow: {
marginHorizontal: spacing.lg,
marginTop: spacing.md,
marginBottom: -spacing.sm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
// 底部工具栏
channelEntryRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
backgroundColor: colors.background.paper,
},
channelEntryLabel: {
fontSize: fontSizes.md,
},
channelEntryValueWrap: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
channelEntryValue: {
fontSize: fontSizes.md,
},
toolbar: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -951,11 +1006,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: spacing.xs,
},
toolbarRight: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
toolbarButton: {
position: 'relative',
},
@@ -966,41 +1016,6 @@ const styles = StyleSheet.create({
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,