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_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();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 接口 ====================
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
@@ -381,12 +382,20 @@ class ApiClient {
|
|||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
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) {
|
||||||
|
|||||||
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 { voteService } from './voteService';
|
||||||
|
export { channelService } from './channelService';
|
||||||
|
export type { ChannelItem } from './channelService';
|
||||||
|
|
||||||
// SSE 实时服务
|
// SSE 实时服务
|
||||||
export { sseService } from './sseService';
|
export { sseService } from './sseService';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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个
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export interface CreatePostInput {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
images?: string[]; // 本地图片路径或 base64
|
images?: string[]; // 本地图片路径或 base64
|
||||||
|
channel_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user