diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index 26024a9..22b8a14 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -9,7 +9,7 @@ import { useTotalUnreadCount } from '../../../src/stores'; const TAB_BAR_HEIGHT = 56; const TAB_BAR_FLOATING_MARGIN = 12; -const TAB_BAR_MARGIN = 24; +const TAB_BAR_MARGIN = 20; export default function TabsLayout() { const { width } = useWindowDimensions(); diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 90e2946..4d42131 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -75,8 +75,8 @@ export interface Post { isPinned: boolean; /** 帖子状态 */ status: PostStatus; - /** 所属社区ID */ - communityId?: string; + /** 所属频道ID */ + channelId?: string; /** 标签列表 */ tags: string[]; /** 创建时间 */ @@ -113,8 +113,8 @@ export interface Post { updated_at?: string; /** @deprecated 请使用 contentEditedAt */ content_edited_at?: string; - /** @deprecated 请使用 communityId */ - community_id?: string; + /** @deprecated 请使用 channelId */ + channel_id?: string; /** @deprecated 请使用 status */ status_str?: string; /** 投票帖子标识 (部分旧代码使用) */ @@ -168,7 +168,7 @@ export const createPost = (data: Partial): Post => ({ isFavorited: data.isFavorited || false, isPinned: data.isPinned || false, status: data.status || 'published', - communityId: data.communityId, + channelId: data.channelId, tags: data.tags || [], createdAt: data.createdAt || new Date().toISOString(), updatedAt: data.updatedAt || new Date().toISOString(), diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts index 3f4a2e0..6e7c745 100644 --- a/src/data/mappers/PostMapper.ts +++ b/src/data/mappers/PostMapper.ts @@ -24,7 +24,7 @@ export class PostMapper { isFavorited: response.is_favorited || false, isTop: response.is_pinned || false, status: (response.status || 'published') as 'published' | 'draft' | 'deleted', - communityId: response.community_id, + channelId: response.channel_id, tags: [], createdAt: new Date(response.created_at || Date.now()), // 空字符串/缺省时用 created_at,避免误用 Date.now() 导致全部显示「刚修改」 @@ -58,8 +58,8 @@ export class PostMapper { if (model.images !== undefined) { request.images = model.images; } - if (model.communityId !== undefined) { - request.community_id = model.communityId; + if (model.channelId !== undefined) { + request.channel_id = model.channelId; } if (model.tags !== undefined) { request.tags = model.tags; @@ -75,7 +75,7 @@ export class PostMapper { title: string, content: string, images?: string[], - communityId?: string + channelId?: string ): Record { const request: Record = { title, @@ -84,8 +84,8 @@ export class PostMapper { if (images && images.length > 0) { request.images = images; } - if (communityId) { - request.community_id = communityId; + if (channelId) { + request.channel_id = channelId; } return request; } diff --git a/src/data/models/index.ts b/src/data/models/index.ts index cfbf362..f2f2a41 100644 --- a/src/data/models/index.ts +++ b/src/data/models/index.ts @@ -85,7 +85,7 @@ export interface PostModel { isFavorited: boolean; isTop: boolean; status: 'published' | 'draft' | 'deleted'; - communityId?: string; + channelId?: string; tags?: string[]; createdAt: Date; updatedAt: Date; diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 6ab6631..14177fe 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -37,7 +37,7 @@ interface PostApiResponse { is_favorited: boolean; is_pinned: boolean; status: string; - community_id?: string; + channel_id?: string; tags?: string[]; created_at: string; updated_at: string; @@ -124,7 +124,7 @@ export class PostRepository implements IPostRepository { isFavorited: model.isFavorited, isPinned: model.isTop, status: model.status, - communityId: model.communityId, + channelId: model.channelId, tags: model.tags || [], createdAt: model.createdAt.toISOString(), updatedAt: model.updatedAt.toISOString(), @@ -143,7 +143,7 @@ export class PostRepository implements IPostRepository { created_at: model.createdAt.toISOString(), updated_at: model.updatedAt.toISOString(), content_edited_at: response.content_edited_at, - community_id: model.communityId, + channel_id: model.channelId, }; } @@ -250,7 +250,7 @@ export class PostRepository implements IPostRepository { queryParams.cursor = params.cursor; } if (params?.post_type) queryParams.tab = params.post_type; - if (params?.communityId) queryParams.community_id = params.communityId; + if (params?.channelId) queryParams.channel_id = params.channelId; if (params?.authorId) queryParams.author_id = params.authorId; if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(','); if (params?.status) queryParams.status = params.status; @@ -364,7 +364,7 @@ export class PostRepository implements IPostRepository { if (params.page) queryParams.page = params.page; if (params.pageSize) queryParams.page_size = params.pageSize; if (params.scope) queryParams.scope = params.scope; - if (params.communityId) queryParams.community_id = params.communityId; + if (params.channelId) queryParams.channel_id = params.channelId; const response = await this.api.get('/posts/search', queryParams); @@ -398,7 +398,7 @@ export class PostRepository implements IPostRepository { data.title, data.content, data.images?.map(img => img.url), - data.communityId + data.channelId ); if (data.tags && data.tags.length > 0) { diff --git a/src/data/repositories/interfaces/IPostRepository.ts b/src/data/repositories/interfaces/IPostRepository.ts index eb81a14..6423134 100644 --- a/src/data/repositories/interfaces/IPostRepository.ts +++ b/src/data/repositories/interfaces/IPostRepository.ts @@ -19,8 +19,8 @@ export interface GetPostsParams { cursor?: string; /** 帖子类型筛选(可选):follow, hot, latest */ post_type?: 'follow' | 'hot' | 'latest'; - /** 社区ID过滤 */ - communityId?: string; + /** 频道ID过滤 */ + channelId?: string; /** 作者ID过滤 */ authorId?: string; /** 标签过滤 */ @@ -45,8 +45,8 @@ export interface CreatePostData { content: string; /** 图片列表 */ images?: PostImage[]; - /** 所属社区ID */ - communityId?: string; + /** 所属频道ID */ + channelId?: string; /** 标签列表 */ tags?: string[]; /** 帖子状态 */ @@ -97,8 +97,8 @@ export interface SearchPostsParams { pageSize?: number; /** 搜索范围:标题、内容、或全部 */ scope?: 'title' | 'content' | 'all'; - /** 社区ID过滤 */ - communityId?: string; + /** 频道ID过滤 */ + channelId?: string; } // ==================== Repository 接口 ==================== diff --git a/src/infrastructure/diff/postTypes.ts b/src/infrastructure/diff/postTypes.ts index 0968b83..3b6634c 100644 --- a/src/infrastructure/diff/postTypes.ts +++ b/src/infrastructure/diff/postTypes.ts @@ -113,7 +113,7 @@ export interface BasePostUpdate { /** 更新时间戳 */ timestamp: number; /** 社区 ID(可选) */ - communityId?: string; + channelId?: string; /** 载荷数据(可选,用于合并更新) */ payload?: Record; } diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index efc2db1..906c5d8 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -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 = (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([]); - const [tagInput, setTagInput] = useState(''); - const [showTagInput, setShowTagInput] = useState(false); + const [channelOptions, setChannelOptions] = useState([]); + const [selectedChannelId, setSelectedChannelId] = useState(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 = (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 = (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 ? () => ( - - ) : undefined, + headerShown: false, }); - }, [navigation, isEditMode, onClose]); + }, [navigation]); React.useEffect(() => { if (!isEditMode || !editPostID) { @@ -264,20 +278,11 @@ export const CreatePostScreen: React.FC = (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 = (props) => { // 关闭所有面板 const closeAllPanels = () => { setShowEmojiPanel(false); - setShowTagInput(false); + setShowChannelPicker(false); }; // 切换表情面板 @@ -299,10 +304,10 @@ export const CreatePostScreen: React.FC = (props) => { setShowEmojiPanel(!showEmojiPanel); }; - // 切换标签输入 - const handleToggleTagInput = () => { + // 切换频道选择 + const handleToggleChannelPicker = () => { closeAllPanels(); - setShowTagInput(!showTagInput); + setShowChannelPicker(!showChannelPicker); }; // 切换投票模式 @@ -373,6 +378,7 @@ export const CreatePostScreen: React.FC = (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 = (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 = (props) => { ); }; - // 渲染标签 - const renderTags = () => { - if (tags.length === 0 && !showTagInput) return null; + // 渲染频道(单选) + const renderChannelPicker = () => { + if (!showChannelPicker) return null; return ( - {tags.map((tag, index) => ( - - - {tag} - {/* 独立的删除按钮 */} + {channelOptions.map((channel) => { + const isSelected = selectedChannelId === channel.id; + return ( handleRemoveTag(tag)} - hitSlop={{ top: 5, right: 5, bottom: 5, left: 5 }} + key={channel.id} + style={[styles.tag, isSelected && styles.channelSelected]} + onPress={() => handleSelectChannel(channel.id)} > - + + {channel.name} + - - ))} - {tags.length < 5 && showTagInput && ( - - - - { setTagInput(''); setShowTagInput(false); }} style={styles.tagCancelButton}> - - - - - - - )} - {tags.length < 5 && !showTagInput && ( - setShowTagInput(true)}> - - 话题 - - )} + ); + })} + {channelOptions.length === 0 && ( + 暂无可用频道 + )} ); }; + const selectedChannelName = React.useMemo(() => { + if (!selectedChannelId) return ''; + return channelOptions.find(c => c.id === selectedChannelId)?.name || ''; + }, [selectedChannelId, channelOptions]); + + const renderChannelEntryRow = () => ( + + + 发表至: + + + + {selectedChannelName || '选择频道'} + + + + + ); + // 渲染投票编辑器 const renderVoteEditor = () => { if (!isVotePost) return null; return ( + + + 已插入投票 + + setIsVotePost(false)} activeOpacity={0.7}> + 移除投票 + + = (props) => { // 渲染内容输入区 const renderContentSection = () => ( - + {/* 标题输入 */} = (props) => { style={[ styles.contentInput, isWideScreen && styles.contentInputWide, + { minHeight: contentInputMinHeight }, ]} value={content} onChangeText={setContent} @@ -579,6 +606,10 @@ export const CreatePostScreen: React.FC = (props) => { maxLength={MAX_CONTENT_LENGTH} onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} /> + + {/* 作为正文容器的子模块,追加在文本后面 */} + {renderImageGrid()} + {renderVoteEditor()} ); @@ -642,25 +673,6 @@ export const CreatePostScreen: React.FC = (props) => { - = 5 || posting} - > - - = 5 ? colors.text.disabled : (showTagInput ? colors.primary.main : colors.text.secondary)} - /> - {tags.length > 0 && ( - - {tags.length} - - )} - - - = (props) => { + + ); - {/* 右侧发布按钮 */} - - + const renderTopActionBar = () => ( + + (onClose ? onClose() : router.back())} + style={styles.topActionButton} + activeOpacity={0.7} + > + 取消 + + + + + {isEditMode ? '编辑帖子' : '发布帖子'} + + {content.length}/{MAX_CONTENT_LENGTH} - - {posting ? ( - - ) : ( - - {isEditMode ? '保存' : '发布'} - - )} - + + + + {posting ? '发布中' : '发布'} + + ); // 渲染主内容 const renderMainContent = () => ( <> + {renderTopActionBar()} + = (props) => { > {/* 内容输入区 */} {renderContentSection()} - - {/* 投票编辑器 */} - {renderVoteEditor()} - - {/* 图片网格 */} - {renderImageGrid()} - - {/* 标签 */} - {renderTags()} + {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, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 65320dc..14d3f33 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -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('list'); + const [latestCapsules, setLatestCapsules] = useState([{ 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(); @@ -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 ( + + + {latestCapsules.map((item) => { + const isActive = item.id === activeCapsuleId; + return ( + setActiveCapsuleId(item.id)} + style={styles.capsuleItem} + > + + {item.name} + + + ); + })} + + + ); + }; + // 渲染帖子卡片(列表模式) const keyExtractor = useCallback((item: Post) => item.id, []); @@ -559,6 +652,7 @@ export const HomeScreen: React.FC = () => { /> } > + {renderLatestCapsules()} {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} @@ -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 ? : 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, }, diff --git a/src/services/api.ts b/src/services/api.ts index 98e5fff..75e475c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -8,6 +8,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { CommonActions } from '@react-navigation/native'; import Constants from 'expo-constants'; +import { Platform } from 'react-native'; // 生产地址 https://bbs.littlelan.cn const getBaseUrl = () => { @@ -381,12 +382,20 @@ class ApiClient { ): Promise> { const formData = new FormData(); - // 添加文件(使用image字段名) - formData.append('image', { - uri: file.uri, - name: file.name, - type: file.type, - } as any); + // 添加文件(使用 image 字段名) + // Web 端需要 append Blob/File;原生端可以直接传 { uri, name, type }。 + if (Platform.OS === 'web') { + const fileResponse = await fetch(file.uri); + const blob = await fileResponse.blob(); + const uploadFile = new File([blob], file.name, { type: file.type || blob.type || 'application/octet-stream' }); + formData.append('image', uploadFile); + } else { + formData.append('image', { + uri: file.uri, + name: file.name, + type: file.type, + } as any); + } // 添加额外数据 if (additionalData) { diff --git a/src/services/channelService.ts b/src/services/channelService.ts new file mode 100644 index 0000000..5ed927f --- /dev/null +++ b/src/services/channelService.ts @@ -0,0 +1,29 @@ +import { api } from './api'; + +export interface ChannelItem { + id: string; + name: string; + slug: string; + description?: string; + sort_order: number; + is_active: boolean; +} + +interface ChannelListResponse { + list: ChannelItem[]; +} + +class ChannelService { + async list(): Promise { + try { + const res = await api.get('/channels'); + return res.data?.list || []; + } catch (error) { + console.error('[channelService] 获取频道失败:', error); + return []; + } + } +} + +export const channelService = new ChannelService(); + diff --git a/src/services/index.ts b/src/services/index.ts index 53b2d34..f7478d4 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -44,6 +44,8 @@ export { pushService, registerDevice, getDevices, unregisterDevice, updateDevice // 投票服务 export { voteService } from './voteService'; +export { channelService } from './channelService'; +export type { ChannelItem } from './channelService'; // SSE 实时服务 export { sseService } from './sseService'; diff --git a/src/services/postService.ts b/src/services/postService.ts index 9989122..e50f58f 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -29,6 +29,7 @@ interface CreatePostRequest { title: string; content: string; images?: string[]; + channel_id?: string; } // 更新帖子请求 @@ -45,7 +46,7 @@ class PostService { page = 1, pageSize = 20, tab?: string, - communityId?: string + channelId?: string ): Promise> { const params: Record = { page, @@ -55,8 +56,8 @@ class PostService { if (tab) { params.tab = tab; } - if (communityId) { - params.community_id = communityId; + if (channelId) { + params.channel_id = channelId; } const response = await api.get('/posts', params); diff --git a/src/types/dto.ts b/src/types/dto.ts index 3e55a2d..0cfb1a1 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -80,7 +80,7 @@ export interface PostDTO { is_liked: boolean; is_favorited: boolean; // 额外字段 - community_id?: string; + channel_id?: string; top_comment?: CommentDTO | null; } @@ -780,7 +780,7 @@ export interface VoteResultDTO { export interface CreateVotePostRequest { title: string; content?: string; - community_id?: string; + channel_id?: string; images?: string[]; vote_options: string[]; // 至少2个,最多10个 } diff --git a/src/types/index.ts b/src/types/index.ts index 08c41cf..3eb3c6f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -102,6 +102,7 @@ export interface CreatePostInput { title: string; content: string; images?: string[]; // 本地图片路径或 base64 + channel_id?: string; } // ============================================