dev #5
@@ -61,15 +61,6 @@ export default function TabsLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
|
||||||
name="schedule"
|
|
||||||
options={{
|
|
||||||
title: '课表',
|
|
||||||
tabBarIcon: ({ color, focused }) => (
|
|
||||||
<MaterialCommunityIcons name="calendar-today" size={focused ? 24 : 22} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="messages"
|
name="messages"
|
||||||
options={{
|
options={{
|
||||||
@@ -84,6 +75,19 @@ export default function TabsLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="apps"
|
||||||
|
options={{
|
||||||
|
title: '应用',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={focused ? 'view-grid' : 'view-grid-outline'}
|
||||||
|
size={focused ? 24 : 22}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
5
app/(app)/(tabs)/apps/_layout.tsx
Normal file
5
app/(app)/(tabs)/apps/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AppsStackLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/index.tsx
Normal file
5
app/(app)/(tabs)/apps/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AppsScreen } from '../../../../src/screens/apps';
|
||||||
|
|
||||||
|
export default function AppsRoute() {
|
||||||
|
return <AppsScreen />;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CourseDetailScreen } from '../../../../src/screens/schedule';
|
import { CourseDetailScreen } from '../../../../../src/screens/schedule';
|
||||||
|
|
||||||
export default function CourseDetailRoute() {
|
export default function CourseDetailRoute() {
|
||||||
return <CourseDetailScreen />;
|
return <CourseDetailScreen />;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ScheduleScreen } from '../../../../src/screens/schedule';
|
import { ScheduleScreen } from '../../../../../src/screens/schedule';
|
||||||
|
|
||||||
export default function ScheduleRoute() {
|
export default function ScheduleRoute() {
|
||||||
return <ScheduleScreen />;
|
return <ScheduleScreen />;
|
||||||
@@ -15,7 +15,7 @@ import { colors, shadows } from '../theme';
|
|||||||
import { useTotalUnreadCount } from '../stores';
|
import { useTotalUnreadCount } from '../stores';
|
||||||
import { AppRouteStack } from './AppRouteStack';
|
import { AppRouteStack } from './AppRouteStack';
|
||||||
|
|
||||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
type TabName = 'HomeTab' | 'MessageTab' | 'AppsTab' | 'ProfileTab';
|
||||||
|
|
||||||
const SIDEBAR_WIDTH_DESKTOP = 240;
|
const SIDEBAR_WIDTH_DESKTOP = 240;
|
||||||
const SIDEBAR_WIDTH_TABLET = 200;
|
const SIDEBAR_WIDTH_TABLET = 200;
|
||||||
@@ -24,13 +24,13 @@ const SIDEBAR_COLLAPSED_WIDTH = 72;
|
|||||||
const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [
|
const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [
|
||||||
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' },
|
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' },
|
||||||
{ name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' },
|
{ name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' },
|
||||||
{ name: 'ScheduleTab', label: '课表', href: '/schedule', icon: 'calendar-today', iconOutline: 'calendar-today' },
|
{ name: 'AppsTab', label: '应用', href: '/apps', icon: 'view-grid', iconOutline: 'view-grid-outline' },
|
||||||
{ name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' },
|
{ name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function pathToTab(pathname: string): TabName {
|
function pathToTab(pathname: string): TabName {
|
||||||
if (pathname.startsWith('/messages')) return 'MessageTab';
|
if (pathname.startsWith('/messages')) return 'MessageTab';
|
||||||
if (pathname.startsWith('/schedule')) return 'ScheduleTab';
|
if (pathname.startsWith('/apps')) return 'AppsTab';
|
||||||
if (pathname.startsWith('/profile')) return 'ProfileTab';
|
if (pathname.startsWith('/profile')) return 'ProfileTab';
|
||||||
if (pathname.startsWith('/home')) return 'HomeTab';
|
if (pathname.startsWith('/home')) return 'HomeTab';
|
||||||
return 'HomeTab';
|
return 'HomeTab';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* CommentItem 评论项组件 - QQ频道风格
|
* CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表
|
||||||
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
@@ -14,8 +14,6 @@ import Text from '../common/Text';
|
|||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface CommentItemProps {
|
interface CommentItemProps {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
onUserPress: () => void;
|
onUserPress: () => void;
|
||||||
@@ -181,11 +179,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.floorTag}>
|
<Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
|
{getFloorText(floorNumber)}
|
||||||
{getFloorText(floorNumber)}
|
</Text>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,8 +272,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.subRepliesContainer}>
|
<View style={styles.subRepliesContainer}>
|
||||||
{comment.replies.map((reply) => {
|
{comment.replies.map((reply, replyIndex) => {
|
||||||
const replyAuthorId = reply.author?.id || '';
|
const replyAuthorId = reply.author?.id || '';
|
||||||
|
const isLastReply = replyIndex === comment.replies!.length - 1;
|
||||||
// 根据 target_id 获取被回复的用户昵称
|
// 根据 target_id 获取被回复的用户昵称
|
||||||
const targetId = reply.target_id;
|
const targetId = reply.target_id;
|
||||||
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
|
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
|
||||||
@@ -287,7 +284,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={reply.id}
|
key={reply.id}
|
||||||
style={styles.subReplyItem}
|
style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
|
||||||
onPress={() => onReplyPress?.(reply)}
|
onPress={() => onReplyPress?.(reply)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -320,7 +317,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
{/* 显示回复内容(如果有文字) */}
|
{/* 显示回复内容(如果有文字) */}
|
||||||
{reply.content ? (
|
{reply.content ? (
|
||||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}>
|
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
||||||
{reply.content}
|
{reply.content}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -375,11 +372,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 用户头像 */}
|
{/* 用户头像 - 略大便于点击,与正文左对齐 */}
|
||||||
<TouchableOpacity onPress={onUserPress}>
|
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
||||||
<Avatar
|
<Avatar
|
||||||
source={comment.author?.avatar}
|
source={comment.author?.avatar}
|
||||||
size={36}
|
size={40}
|
||||||
name={comment.author?.nickname}
|
name={comment.author?.nickname}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -389,12 +386,13 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 用户信息行 - QQ频道风格 */}
|
{/* 用户信息行 - QQ频道风格 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.userInfo}>
|
<View style={styles.userInfo}>
|
||||||
<TouchableOpacity onPress={onUserPress}>
|
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
||||||
<Text variant="body" style={styles.username}>
|
<Text variant="body" style={styles.username} numberOfLines={1}>
|
||||||
{comment.author?.nickname}
|
{comment.author?.nickname || '用户'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{renderBadges()}
|
{renderBadges()}
|
||||||
|
<Text style={styles.metaDot}>·</Text>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||||
{formatTime(comment.created_at || '')}
|
{formatTime(comment.created_at || '')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -477,31 +475,35 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingTop: spacing.md,
|
paddingTop: spacing.md,
|
||||||
paddingBottom: spacing.xs,
|
paddingBottom: spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.md,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: colors.background.paper,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.divider,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: spacing.sm,
|
marginLeft: spacing.sm,
|
||||||
backgroundColor: colors.background.paper,
|
minWidth: 0,
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.xs,
|
||||||
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
userInfo: {
|
userInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
metaDot: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
color: colors.text.hint,
|
||||||
|
marginHorizontal: 2,
|
||||||
},
|
},
|
||||||
username: {
|
username: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -537,42 +539,35 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
timeText: {
|
timeText: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
floorTag: {
|
floorPlain: {
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
paddingHorizontal: spacing.xs,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
},
|
|
||||||
floorText: {
|
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
replyReference: {
|
replyReference: {
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
commentContent: {
|
commentContent: {
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
lineHeight: 20,
|
lineHeight: 22,
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.lg,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingVertical: 4,
|
||||||
paddingVertical: 5,
|
paddingRight: spacing.xs,
|
||||||
borderRadius: 999,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
},
|
},
|
||||||
actionText: {
|
actionText: {
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
@@ -580,16 +575,25 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
subRepliesContainer: {
|
subRepliesContainer: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
|
marginLeft: 0,
|
||||||
|
paddingLeft: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: colors.divider,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
padding: spacing.sm,
|
|
||||||
},
|
},
|
||||||
subReplyItem: {
|
subReplyItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
subReplyItemLast: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
subReplyBody: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
subReplyContent: {
|
subReplyContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ function topCommentSignature(tc: PostCardProps['post']['top_comment']): string {
|
|||||||
return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f');
|
return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelSignature(ch: PostCardProps['post']['channel']): string {
|
||||||
|
if (!ch) return '';
|
||||||
|
return [ch.id, ch.name ?? ''].join('\u001f');
|
||||||
|
}
|
||||||
|
|
||||||
function featuresComparable(f: PostCardProps['features']): string {
|
function featuresComparable(f: PostCardProps['features']): string {
|
||||||
if (f == null) return '';
|
if (f == null) return '';
|
||||||
if (typeof f === 'string') return f;
|
if (typeof f === 'string') return f;
|
||||||
@@ -103,6 +108,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
|||||||
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
||||||
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||||
|
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -302,6 +308,15 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!!post.channel?.name && (
|
||||||
|
<View style={styles.gridChannelRow}>
|
||||||
|
<MaterialCommunityIcons name="tag-outline" size={12} color={colors.text.hint} />
|
||||||
|
<Text style={styles.gridChannelText} numberOfLines={1}>
|
||||||
|
{post.channel.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.gridFooter}>
|
<View style={styles.gridFooter}>
|
||||||
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
|
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
|
||||||
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
|
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
|
||||||
@@ -331,6 +346,14 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
{!showGrid && (
|
{!showGrid && (
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
|
<PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
|
||||||
|
{!!post.channel?.name && (
|
||||||
|
<View style={styles.channelTag}>
|
||||||
|
<MaterialCommunityIcons name="tag-outline" size={11} color={colors.primary.main} />
|
||||||
|
<Text style={styles.channelTagText} numberOfLines={1}>
|
||||||
|
{post.channel.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{post.is_pinned && (
|
{post.is_pinned && (
|
||||||
<View style={styles.pinnedTag}>
|
<View style={styles.pinnedTag}>
|
||||||
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
|
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
|
||||||
@@ -459,6 +482,23 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
channelTag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
maxWidth: 140,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
backgroundColor: `${colors.primary.main}12`,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
channelTagText: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
color: colors.primary.main,
|
||||||
|
fontWeight: '600',
|
||||||
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
pinnedTag: {
|
pinnedTag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -606,6 +646,21 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
},
|
},
|
||||||
|
gridChannelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 2,
|
||||||
|
},
|
||||||
|
gridChannelText: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
gridFooter: {
|
gridFooter: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -29,12 +29,16 @@ export function hrefNotifications(): string {
|
|||||||
return '/messages/notifications';
|
return '/messages/notifications';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hrefApps(): string {
|
||||||
|
return '/apps';
|
||||||
|
}
|
||||||
|
|
||||||
export function hrefSchedule(): string {
|
export function hrefSchedule(): string {
|
||||||
return '/schedule';
|
return '/apps/schedule';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hrefScheduleCourse(courseId: string): string {
|
export function hrefScheduleCourse(courseId: string): string {
|
||||||
return `/schedule/course?courseId=${encodeURIComponent(courseId)}`;
|
return `/apps/schedule/course?courseId=${encodeURIComponent(courseId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hrefProfileSettings(): string {
|
export function hrefProfileSettings(): string {
|
||||||
|
|||||||
248
src/screens/apps/AppsScreen.tsx
Normal file
248
src/screens/apps/AppsScreen.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 应用中心:聚合站内轻应用入口
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { View, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
|
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
import { Text, ResponsiveContainer } from '../../components/common';
|
||||||
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
|
type AppItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
||||||
|
gradient: readonly [string, string, ...string[]];
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const APP_ENTRIES: AppItem[] = [
|
||||||
|
{
|
||||||
|
id: 'schedule',
|
||||||
|
title: '课表',
|
||||||
|
subtitle: '周视图 · 教务同步',
|
||||||
|
icon: 'calendar-week',
|
||||||
|
gradient: [colors.primary.main, colors.primary.light],
|
||||||
|
href: hrefs.hrefSchedule(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AppsScreen: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isMobile, isTablet, width } = useResponsive();
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
if (width >= 900) return 4;
|
||||||
|
if (isTablet || width >= 600) return 3;
|
||||||
|
return 2;
|
||||||
|
}, [isTablet, width]);
|
||||||
|
|
||||||
|
const gap = spacing.md;
|
||||||
|
const [gridWidth, setGridWidth] = useState(0);
|
||||||
|
const cardWidth =
|
||||||
|
gridWidth > 0 ? (gridWidth - gap * (columns - 1)) / columns : undefined;
|
||||||
|
|
||||||
|
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
|
||||||
|
|
||||||
|
const onOpenApp = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
router.push(href);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer maxWidth={960}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[`${colors.primary.main}18`, `${colors.primary.light}10`, 'transparent']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.hero}
|
||||||
|
>
|
||||||
|
<View style={styles.heroIconWrap}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.primary.main, colors.primary.light]}
|
||||||
|
style={styles.heroIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="apps" size={28} color={colors.primary.contrast} />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.heroTitle}>应用</Text>
|
||||||
|
<Text style={styles.heroSubtitle}>学习与生活常用工具,持续扩充中</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
|
||||||
|
<Text style={styles.sectionLabel}>全部应用</Text>
|
||||||
|
<View
|
||||||
|
style={[styles.grid, { gap }]}
|
||||||
|
onLayout={e => setGridWidth(e.nativeEvent.layout.width)}
|
||||||
|
>
|
||||||
|
{APP_ENTRIES.map(item => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
style={[styles.card, cardWidth != null ? { width: cardWidth } : styles.cardFlex, shadows.md]}
|
||||||
|
onPress={() => onOpenApp(item.href)}
|
||||||
|
activeOpacity={0.88}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={item.gradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.cardIconRing}
|
||||||
|
>
|
||||||
|
<View style={styles.cardIconInner}>
|
||||||
|
<MaterialCommunityIcons name={item.icon} size={26} color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.cardSubtitle} numberOfLines={2}>
|
||||||
|
{item.subtitle}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={styles.cardAction}>打开</Text>
|
||||||
|
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.hint}>更多应用敬请期待</Text>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppsScreen;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
borderRadius: borderRadius['2xl'],
|
||||||
|
paddingVertical: spacing['2xl'],
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
marginBottom: spacing['2xl'],
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
heroIconWrap: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
heroIconGradient: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
...shadows.md,
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
fontSize: fontSizes['3xl'],
|
||||||
|
fontWeight: '800',
|
||||||
|
color: colors.text.primary,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
heroSubtitle: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
maxWidth: 320,
|
||||||
|
},
|
||||||
|
sectionLabel: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.secondary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
cardFlex: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 140,
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${colors.divider}99`,
|
||||||
|
},
|
||||||
|
cardIconRing: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: 2,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
cardIconInner: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 18,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
cardFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
cardAction: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.primary.main,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
marginTop: spacing['3xl'],
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.hint,
|
||||||
|
},
|
||||||
|
});
|
||||||
2
src/screens/apps/index.ts
Normal file
2
src/screens/apps/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AppsScreen } from './AppsScreen';
|
||||||
|
export { default } from './AppsScreen';
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* 支持列表和多列网格模式(响应式布局)
|
* 支持列表和多列网格模式(响应式布局)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
FlatList,
|
FlatList,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
StatusBar,
|
StatusBar,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
|
NativeScrollEvent,
|
||||||
Alert,
|
Alert,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -92,6 +93,24 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
|
|
||||||
|
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
|
||||||
|
const capsuleHScrollRef = useRef<ScrollView | null>(null);
|
||||||
|
const capsuleScrollXRef = useRef(0);
|
||||||
|
|
||||||
|
const restoreCapsuleStripScroll = useCallback(() => {
|
||||||
|
const x = capsuleScrollXRef.current;
|
||||||
|
if (x <= 0) return;
|
||||||
|
const scroll = () => capsuleHScrollRef.current?.scrollTo({ x, animated: false });
|
||||||
|
scroll();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(scroll);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCapsuleHorizontalScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
capsuleScrollXRef.current = e.nativeEvent.contentOffset.x;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 构建一个以 postId 为 key 的 map,用于快速查找
|
// 构建一个以 postId 为 key 的 map,用于快速查找
|
||||||
const postsMap = useMemo(() => {
|
const postsMap = useMemo(() => {
|
||||||
const map = new Map<string, Post>();
|
const map = new Map<string, Post>();
|
||||||
@@ -114,6 +133,11 @@ export const HomeScreen: React.FC = () => {
|
|||||||
const isLatestTab = activeIndex === 1;
|
const isLatestTab = activeIndex === 1;
|
||||||
const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined;
|
const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isLatestTab) return;
|
||||||
|
restoreCapsuleStripScroll();
|
||||||
|
}, [isLatestTab, activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]);
|
||||||
|
|
||||||
// 使用差异更新 Hook 获取帖子列表
|
// 使用差异更新 Hook 获取帖子列表
|
||||||
const listKey = useMemo(
|
const listKey = useMemo(
|
||||||
() => `home_${getPostType()}_${currentChannelId || 'all'}`,
|
() => `home_${getPostType()}_${currentChannelId || 'all'}`,
|
||||||
@@ -453,7 +477,15 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.capsuleWrapper, { paddingHorizontal: responsivePadding }]}>
|
<View style={[styles.capsuleWrapper, { paddingHorizontal: responsivePadding }]}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.capsuleList}>
|
<ScrollView
|
||||||
|
ref={capsuleHScrollRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.capsuleList}
|
||||||
|
onScroll={onCapsuleHorizontalScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
onContentSizeChange={restoreCapsuleStripScroll}
|
||||||
|
>
|
||||||
{latestCapsules.map((item) => {
|
{latestCapsules.map((item) => {
|
||||||
const isActive = item.id === activeCapsuleId;
|
const isActive = item.id === activeCapsuleId;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2054,30 +2054,23 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.md,
|
||||||
},
|
},
|
||||||
// 空评论状态样式
|
// 空评论状态样式(与平铺评论区一致,无卡片气泡)
|
||||||
emptyCommentsContainer: {
|
emptyCommentsContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.md,
|
||||||
marginTop: spacing.lg,
|
marginTop: spacing.md,
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.lg,
|
||||||
paddingVertical: spacing.xl + spacing.md,
|
paddingVertical: spacing.xl,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
},
|
},
|
||||||
emptyCommentsIconWrapper: {
|
emptyCommentsIconWrapper: {
|
||||||
width: 44,
|
width: 40,
|
||||||
height: 44,
|
height: 40,
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
emptyCommentsTitle: {
|
emptyCommentsTitle: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
|
|||||||
@@ -379,6 +379,16 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
// 渲染周选择器
|
// 渲染周选择器
|
||||||
const renderWeekSelector = () => (
|
const renderWeekSelector = () => (
|
||||||
<View style={styles.weekSelector}>
|
<View style={styles.weekSelector}>
|
||||||
|
{router.canGoBack() ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.weekBarIconButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="返回"
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="chevron-left" size={28} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.settingsButton}
|
style={styles.settingsButton}
|
||||||
onPress={() => setIsSettingsModalVisible(true)}
|
onPress={() => setIsSettingsModalVisible(true)}
|
||||||
@@ -1066,6 +1076,13 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
weekBarIconButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
},
|
||||||
settingsButton: {
|
settingsButton: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export interface PostImageDTO {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 帖子所属频道(列表卡片展示) */
|
||||||
|
export interface PostChannelBriefDTO {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostDTO {
|
export interface PostDTO {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -81,6 +87,8 @@ export interface PostDTO {
|
|||||||
is_favorited: boolean;
|
is_favorited: boolean;
|
||||||
// 额外字段
|
// 额外字段
|
||||||
channel_id?: string;
|
channel_id?: string;
|
||||||
|
/** 频道摘要(与 channel_id 对应,服务端批量填充) */
|
||||||
|
channel?: PostChannelBriefDTO | null;
|
||||||
top_comment?: CommentDTO | null;
|
top_comment?: CommentDTO | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user