dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
15 changed files with 439 additions and 260 deletions
Showing only changes of commit 126e204592 - Show all commits

View File

@@ -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();

View File

@@ -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>): 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(),

View File

@@ -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<string, any> {
const request: Record<string, any> = {
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;
}

View File

@@ -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;

View File

@@ -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<PostsListApiResponse>('/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) {

View File

@@ -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 接口 ====================

View File

@@ -113,7 +113,7 @@ export interface BasePostUpdate {
/** 更新时间戳 */
timestamp: number;
/** 社区 ID可选 */
communityId?: string;
channelId?: string;
/** 载荷数据(可选,用于合并更新) */
payload?: Record<string, any>;
}

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,
},

View File

@@ -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<ApiResponse<T>> {
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) {

View File

@@ -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<ChannelItem[]> {
try {
const res = await api.get<ChannelListResponse>('/channels');
return res.data?.list || [];
} catch (error) {
console.error('[channelService] 获取频道失败:', error);
return [];
}
}
}
export const channelService = new ChannelService();

View File

@@ -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';

View File

@@ -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<PaginatedData<Post>> {
const params: Record<string, any> = {
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<PostListResponse>('/posts', params);

View File

@@ -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个
}

View File

@@ -102,6 +102,7 @@ export interface CreatePostInput {
title: string;
content: string;
images?: string[]; // 本地图片路径或 base64
channel_id?: string;
}
// ============================================