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

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

View File

@@ -9,7 +9,7 @@ import { useTotalUnreadCount } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56; const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12; const TAB_BAR_FLOATING_MARGIN = 12;
const TAB_BAR_MARGIN = 24; const TAB_BAR_MARGIN = 20;
export default function TabsLayout() { export default function TabsLayout() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();

View File

@@ -75,8 +75,8 @@ export interface Post {
isPinned: boolean; isPinned: boolean;
/** 帖子状态 */ /** 帖子状态 */
status: PostStatus; status: PostStatus;
/** 所属社区ID */ /** 所属频道ID */
communityId?: string; channelId?: string;
/** 标签列表 */ /** 标签列表 */
tags: string[]; tags: string[];
/** 创建时间 */ /** 创建时间 */
@@ -113,8 +113,8 @@ export interface Post {
updated_at?: string; updated_at?: string;
/** @deprecated 请使用 contentEditedAt */ /** @deprecated 请使用 contentEditedAt */
content_edited_at?: string; content_edited_at?: string;
/** @deprecated 请使用 communityId */ /** @deprecated 请使用 channelId */
community_id?: string; channel_id?: string;
/** @deprecated 请使用 status */ /** @deprecated 请使用 status */
status_str?: string; status_str?: string;
/** 投票帖子标识 (部分旧代码使用) */ /** 投票帖子标识 (部分旧代码使用) */
@@ -168,7 +168,7 @@ export const createPost = (data: Partial<Post>): Post => ({
isFavorited: data.isFavorited || false, isFavorited: data.isFavorited || false,
isPinned: data.isPinned || false, isPinned: data.isPinned || false,
status: data.status || 'published', status: data.status || 'published',
communityId: data.communityId, channelId: data.channelId,
tags: data.tags || [], tags: data.tags || [],
createdAt: data.createdAt || new Date().toISOString(), createdAt: data.createdAt || new Date().toISOString(),
updatedAt: data.updatedAt || new Date().toISOString(), updatedAt: data.updatedAt || new Date().toISOString(),

View File

@@ -24,7 +24,7 @@ export class PostMapper {
isFavorited: response.is_favorited || false, isFavorited: response.is_favorited || false,
isTop: response.is_pinned || false, isTop: response.is_pinned || false,
status: (response.status || 'published') as 'published' | 'draft' | 'deleted', status: (response.status || 'published') as 'published' | 'draft' | 'deleted',
communityId: response.community_id, channelId: response.channel_id,
tags: [], tags: [],
createdAt: new Date(response.created_at || Date.now()), createdAt: new Date(response.created_at || Date.now()),
// 空字符串/缺省时用 created_at避免误用 Date.now() 导致全部显示「刚修改」 // 空字符串/缺省时用 created_at避免误用 Date.now() 导致全部显示「刚修改」
@@ -58,8 +58,8 @@ export class PostMapper {
if (model.images !== undefined) { if (model.images !== undefined) {
request.images = model.images; request.images = model.images;
} }
if (model.communityId !== undefined) { if (model.channelId !== undefined) {
request.community_id = model.communityId; request.channel_id = model.channelId;
} }
if (model.tags !== undefined) { if (model.tags !== undefined) {
request.tags = model.tags; request.tags = model.tags;
@@ -75,7 +75,7 @@ export class PostMapper {
title: string, title: string,
content: string, content: string,
images?: string[], images?: string[],
communityId?: string channelId?: string
): Record<string, any> { ): Record<string, any> {
const request: Record<string, any> = { const request: Record<string, any> = {
title, title,
@@ -84,8 +84,8 @@ export class PostMapper {
if (images && images.length > 0) { if (images && images.length > 0) {
request.images = images; request.images = images;
} }
if (communityId) { if (channelId) {
request.community_id = communityId; request.channel_id = channelId;
} }
return request; return request;
} }

View File

@@ -85,7 +85,7 @@ export interface PostModel {
isFavorited: boolean; isFavorited: boolean;
isTop: boolean; isTop: boolean;
status: 'published' | 'draft' | 'deleted'; status: 'published' | 'draft' | 'deleted';
communityId?: string; channelId?: string;
tags?: string[]; tags?: string[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -37,7 +37,7 @@ interface PostApiResponse {
is_favorited: boolean; is_favorited: boolean;
is_pinned: boolean; is_pinned: boolean;
status: string; status: string;
community_id?: string; channel_id?: string;
tags?: string[]; tags?: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -124,7 +124,7 @@ export class PostRepository implements IPostRepository {
isFavorited: model.isFavorited, isFavorited: model.isFavorited,
isPinned: model.isTop, isPinned: model.isTop,
status: model.status, status: model.status,
communityId: model.communityId, channelId: model.channelId,
tags: model.tags || [], tags: model.tags || [],
createdAt: model.createdAt.toISOString(), createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(), updatedAt: model.updatedAt.toISOString(),
@@ -143,7 +143,7 @@ export class PostRepository implements IPostRepository {
created_at: model.createdAt.toISOString(), created_at: model.createdAt.toISOString(),
updated_at: model.updatedAt.toISOString(), updated_at: model.updatedAt.toISOString(),
content_edited_at: response.content_edited_at, 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; queryParams.cursor = params.cursor;
} }
if (params?.post_type) queryParams.tab = params.post_type; 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?.authorId) queryParams.author_id = params.authorId;
if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(','); if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(',');
if (params?.status) queryParams.status = params.status; 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.page) queryParams.page = params.page;
if (params.pageSize) queryParams.page_size = params.pageSize; if (params.pageSize) queryParams.page_size = params.pageSize;
if (params.scope) queryParams.scope = params.scope; 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); const response = await this.api.get<PostsListApiResponse>('/posts/search', queryParams);
@@ -398,7 +398,7 @@ export class PostRepository implements IPostRepository {
data.title, data.title,
data.content, data.content,
data.images?.map(img => img.url), data.images?.map(img => img.url),
data.communityId data.channelId
); );
if (data.tags && data.tags.length > 0) { if (data.tags && data.tags.length > 0) {

View File

@@ -19,8 +19,8 @@ export interface GetPostsParams {
cursor?: string; cursor?: string;
/** 帖子类型筛选可选follow, hot, latest */ /** 帖子类型筛选可选follow, hot, latest */
post_type?: 'follow' | 'hot' | 'latest'; post_type?: 'follow' | 'hot' | 'latest';
/** 社区ID过滤 */ /** 频道ID过滤 */
communityId?: string; channelId?: string;
/** 作者ID过滤 */ /** 作者ID过滤 */
authorId?: string; authorId?: string;
/** 标签过滤 */ /** 标签过滤 */
@@ -45,8 +45,8 @@ export interface CreatePostData {
content: string; content: string;
/** 图片列表 */ /** 图片列表 */
images?: PostImage[]; images?: PostImage[];
/** 所属社区ID */ /** 所属频道ID */
communityId?: string; channelId?: string;
/** 标签列表 */ /** 标签列表 */
tags?: string[]; tags?: string[];
/** 帖子状态 */ /** 帖子状态 */
@@ -97,8 +97,8 @@ export interface SearchPostsParams {
pageSize?: number; pageSize?: number;
/** 搜索范围:标题、内容、或全部 */ /** 搜索范围:标题、内容、或全部 */
scope?: 'title' | 'content' | 'all'; scope?: 'title' | 'content' | 'all';
/** 社区ID过滤 */ /** 频道ID过滤 */
communityId?: string; channelId?: string;
} }
// ==================== Repository 接口 ==================== // ==================== Repository 接口 ====================

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { CommonActions } from '@react-navigation/native'; import { CommonActions } from '@react-navigation/native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { Platform } from 'react-native';
// 生产地址 https://bbs.littlelan.cn // 生产地址 https://bbs.littlelan.cn
const getBaseUrl = () => { const getBaseUrl = () => {
@@ -382,11 +383,19 @@ class ApiClient {
const formData = new FormData(); const formData = new FormData();
// 添加文件(使用 image 字段名) // 添加文件(使用 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', { formData.append('image', {
uri: file.uri, uri: file.uri,
name: file.name, name: file.name,
type: file.type, type: file.type,
} as any); } as any);
}
// 添加额外数据 // 添加额外数据
if (additionalData) { 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 { voteService } from './voteService';
export { channelService } from './channelService';
export type { ChannelItem } from './channelService';
// SSE 实时服务 // SSE 实时服务
export { sseService } from './sseService'; export { sseService } from './sseService';

View File

@@ -29,6 +29,7 @@ interface CreatePostRequest {
title: string; title: string;
content: string; content: string;
images?: string[]; images?: string[];
channel_id?: string;
} }
// 更新帖子请求 // 更新帖子请求
@@ -45,7 +46,7 @@ class PostService {
page = 1, page = 1,
pageSize = 20, pageSize = 20,
tab?: string, tab?: string,
communityId?: string channelId?: string
): Promise<PaginatedData<Post>> { ): Promise<PaginatedData<Post>> {
const params: Record<string, any> = { const params: Record<string, any> = {
page, page,
@@ -55,8 +56,8 @@ class PostService {
if (tab) { if (tab) {
params.tab = tab; params.tab = tab;
} }
if (communityId) { if (channelId) {
params.community_id = communityId; params.channel_id = channelId;
} }
const response = await api.get<PostListResponse>('/posts', params); const response = await api.get<PostListResponse>('/posts', params);

View File

@@ -80,7 +80,7 @@ export interface PostDTO {
is_liked: boolean; is_liked: boolean;
is_favorited: boolean; is_favorited: boolean;
// 额外字段 // 额外字段
community_id?: string; channel_id?: string;
top_comment?: CommentDTO | null; top_comment?: CommentDTO | null;
} }
@@ -780,7 +780,7 @@ export interface VoteResultDTO {
export interface CreateVotePostRequest { export interface CreateVotePostRequest {
title: string; title: string;
content?: string; content?: string;
community_id?: string; channel_id?: string;
images?: string[]; images?: string[];
vote_options: string[]; // 至少2个最多10个 vote_options: string[]; // 至少2个最多10个
} }

View File

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