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,

View File

@@ -9,6 +9,9 @@ import {
View,
FlatList,
ScrollView,
LayoutAnimation,
Platform,
UIManager,
StyleSheet,
RefreshControl,
StatusBar,
@@ -26,7 +29,7 @@ import { colors, spacing, borderRadius, shadows } from '../../theme';
import { Post } from '../../types';
import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore';
import { postService } from '../../services';
import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
@@ -37,17 +40,19 @@ import { SearchScreen } from './SearchScreen';
import { CreatePostScreen } from '../create/CreatePostScreen';
import * as hrefs from '../../navigation/hrefs';
const TABS = ['最新', '关注', '热门'];
const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire'];
const TABS = ['关注', '最新', '热门'];
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
const DEFAULT_PAGE_SIZE = 20;
const SWIPE_TRANSLATION_THRESHOLD = 40;
const SWIPE_COOLDOWN_MS = 300;
const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12;
const MOBILE_FAB_GAP = 12;
const CAPSULE_HIDE_THRESHOLD = 24;
type ViewMode = 'list' | 'grid';
type PostType = 'follow' | 'hot' | 'latest';
type LatestCapsule = { id: string; name: string };
export const HomeScreen: React.FC = () => {
const router = useRouter();
@@ -68,8 +73,11 @@ export const HomeScreen: React.FC = () => {
const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 });
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const [activeIndex, setActiveIndex] = useState(0);
const [activeIndex, setActiveIndex] = useState(1);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [latestCapsules, setLatestCapsules] = useState<LatestCapsule[]>([{ id: '', name: '全部' }]);
const [activeCapsuleId, setActiveCapsuleId] = useState('');
const [showLatestCapsules, setShowLatestCapsules] = useState(true);
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
@@ -88,8 +96,13 @@ export const HomeScreen: React.FC = () => {
// 网格模式滚动位置检测
const gridScrollYRef = useRef(0);
const listScrollYRef = useRef(0);
const isLoadingMoreRef = useRef(false);
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
// 构建一个以 postId 为 key 的 map用于快速查找
const postsMap = useMemo(() => {
const map = new Map<string, Post>();
@@ -102,15 +115,36 @@ export const HomeScreen: React.FC = () => {
// 获取当前 tab 对应的帖子类型
const getPostType = useCallback((): PostType => {
switch (activeIndex) {
case 0: return 'latest';
case 1: return 'follow';
case 0: return 'follow';
case 1: return 'latest';
case 2: return 'hot';
default: return 'latest';
}
}, [activeIndex]);
const isLatestTab = activeIndex === 1;
const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined;
const updateCapsuleVisibilityByScroll = useCallback((nextY: number, previousY: number) => {
if (!isLatestTab) return;
const delta = nextY - previousY;
const isScrollingDown = delta > CAPSULE_HIDE_THRESHOLD;
const isScrollingUp = delta < -CAPSULE_HIDE_THRESHOLD;
if (isScrollingDown && showLatestCapsules) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setShowLatestCapsules(false);
} else if (isScrollingUp && !showLatestCapsules) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setShowLatestCapsules(true);
}
}, [isLatestTab, showLatestCapsules]);
// 使用差异更新 Hook 获取帖子列表
const listKey = useMemo(() => `home_${getPostType()}`, [getPostType]);
const listKey = useMemo(
() => `home_${getPostType()}_${currentChannelId || 'all'}`,
[getPostType, currentChannelId]
);
const {
posts,
@@ -162,27 +196,34 @@ export const HomeScreen: React.FC = () => {
if (scrollY + visibleHeight >= contentHeight - 200) {
loadMore();
}
}, [loadMore]);
updateCapsuleVisibilityByScroll(scrollY, gridScrollYRef.current);
gridScrollYRef.current = scrollY;
}, [loadMore, updateCapsuleVisibilityByScroll]);
// 刷新方法 - 先获取正确类型的帖子,再刷新
const refresh = useCallback(async () => {
try {
// 先获取正确类型的帖子
await processPostUseCase.fetchPosts(
{ page: 1, pageSize: DEFAULT_PAGE_SIZE, post_type: getPostType() },
{
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
post_type: getPostType(),
channelId: currentChannelId,
},
listKey
);
} catch (err) {
console.error('刷新失败:', err);
}
}, [listKey, getPostType]);
}, [listKey, getPostType, currentChannelId]);
// Tab 切换时刷新数据并重置列表
useEffect(() => {
reset();
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex]);
}, [activeIndex, currentChannelId]);
// 将获取到的原始帖子与 store 中的状态合并
// 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等)
@@ -262,6 +303,24 @@ export const HomeScreen: React.FC = () => {
setActiveIndex(nextIndex);
}, [activeIndex]);
useEffect(() => {
setShowLatestCapsules(true);
listScrollYRef.current = 0;
gridScrollYRef.current = 0;
}, [activeIndex]);
useEffect(() => {
const loadChannels = async () => {
const list = await channelService.list();
const capsules: LatestCapsule[] = [
{ id: '', name: '全部' },
...list.map(item => ({ id: item.id, name: item.name })),
];
setLatestCapsules(capsules);
};
loadChannels();
}, []);
const handleSwipeTabChange = useCallback((translationX: number) => {
setActiveIndex(prev => (
translationX < 0
@@ -421,6 +480,40 @@ export const HomeScreen: React.FC = () => {
setShowCreatePost(true);
};
const renderLatestCapsules = () => {
if (!isLatestTab || !showLatestCapsules) {
return null;
}
return (
<View style={[styles.capsuleWrapper, { paddingHorizontal: responsivePadding }]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.capsuleList}>
{latestCapsules.map((item) => {
const isActive = item.id === activeCapsuleId;
return (
<TouchableOpacity
key={item.id || 'all'}
activeOpacity={0.85}
onPress={() => setActiveCapsuleId(item.id)}
style={styles.capsuleItem}
>
<Text
style={
isActive
? { ...styles.capsuleText, ...styles.capsuleTextActive }
: styles.capsuleText
}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
};
// 渲染帖子卡片(列表模式)
const keyExtractor = useCallback((item: Post) => item.id, []);
@@ -559,6 +652,7 @@ export const HomeScreen: React.FC = () => {
/>
}
>
{renderLatestCapsules()}
<View style={styles.waterfallColumnsRow}>
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
</View>
@@ -621,6 +715,7 @@ export const HomeScreen: React.FC = () => {
data={displayPosts}
renderItem={renderPostList}
keyExtractor={keyExtractor}
ListHeaderComponent={renderLatestCapsules()}
contentContainerStyle={[
styles.listContent,
{
@@ -640,6 +735,12 @@ export const HomeScreen: React.FC = () => {
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
onScroll={(event) => {
const nextY = event.nativeEvent.contentOffset.y;
updateCapsuleVisibilityByScroll(nextY, listScrollYRef.current);
listScrollYRef.current = nextY;
}}
scrollEventThrottle={16}
ListEmptyComponent={renderEmpty}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
initialNumToRender={8}
@@ -793,6 +894,27 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
capsuleWrapper: {
paddingBottom: spacing.sm,
backgroundColor: colors.background.paper,
},
capsuleList: {
gap: spacing.xs,
paddingRight: spacing.lg,
},
capsuleItem: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 999,
},
capsuleText: {
fontSize: 13,
fontWeight: '600',
color: colors.text.secondary,
},
capsuleTextActive: {
color: colors.primary.main,
},
listContent: {
flexGrow: 1,
},