refactor(Post, PostMapper, PostRepository, CreatePostScreen, HomeScreen): update communityId to channelId for improved clarity
- 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:
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 接口 ====================
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface BasePostUpdate {
|
||||
/** 更新时间戳 */
|
||||
timestamp: number;
|
||||
/** 社区 ID(可选) */
|
||||
communityId?: string;
|
||||
channelId?: string;
|
||||
/** 载荷数据(可选,用于合并更新) */
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
29
src/services/channelService.ts
Normal file
29
src/services/channelService.ts
Normal 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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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个
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface CreatePostInput {
|
||||
title: string;
|
||||
content: string;
|
||||
images?: string[]; // 本地图片路径或 base64
|
||||
channel_id?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user