feat(Apps): introduce Apps tab and related screens for enhanced navigation
- Added a new "Apps" tab in the TabsLayout, providing users with access to various applications. - Created AppsScreen to display app entries, including a schedule feature with a calendar icon. - Implemented routing for the schedule and course screens under the new Apps tab structure. - Updated navigation hrefs to reflect the new Apps section, improving overall user experience. - Refactored HomeScreen to manage bottom tab visibility based on scroll events, enhancing usability.
This commit is contained in:
@@ -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
|
||||
name="messages"
|
||||
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
|
||||
name="profile"
|
||||
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() {
|
||||
return <CourseDetailScreen />;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ScheduleScreen } from '../../../../src/screens/schedule';
|
||||
import { ScheduleScreen } from '../../../../../src/screens/schedule';
|
||||
|
||||
export default function ScheduleRoute() {
|
||||
return <ScheduleScreen />;
|
||||
@@ -15,7 +15,7 @@ import { colors, shadows } from '../theme';
|
||||
import { useTotalUnreadCount } from '../stores';
|
||||
import { AppRouteStack } from './AppRouteStack';
|
||||
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'AppsTab' | 'ProfileTab';
|
||||
|
||||
const SIDEBAR_WIDTH_DESKTOP = 240;
|
||||
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 }[] = [
|
||||
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-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' },
|
||||
];
|
||||
|
||||
function pathToTab(pathname: string): TabName {
|
||||
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('/home')) return 'HomeTab';
|
||||
return 'HomeTab';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* CommentItem 评论项组件 - QQ频道风格
|
||||
* CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表
|
||||
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
||||
*/
|
||||
|
||||
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 { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
@@ -14,8 +14,6 @@ import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
@@ -181,11 +179,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.floorTag}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
|
||||
{getFloorText(floorNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
|
||||
{getFloorText(floorNumber)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -276,8 +272,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
return (
|
||||
<View style={styles.subRepliesContainer}>
|
||||
{comment.replies.map((reply) => {
|
||||
{comment.replies.map((reply, replyIndex) => {
|
||||
const replyAuthorId = reply.author?.id || '';
|
||||
const isLastReply = replyIndex === comment.replies!.length - 1;
|
||||
// 根据 target_id 获取被回复的用户昵称
|
||||
const targetId = reply.target_id;
|
||||
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
|
||||
@@ -287,7 +284,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={reply.id}
|
||||
style={styles.subReplyItem}
|
||||
style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -320,7 +317,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</View>
|
||||
{/* 显示回复内容(如果有文字) */}
|
||||
{reply.content ? (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
||||
{reply.content}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -375,11 +372,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 用户头像 */}
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
{/* 用户头像 - 略大便于点击,与正文左对齐 */}
|
||||
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
||||
<Avatar
|
||||
source={comment.author?.avatar}
|
||||
size={36}
|
||||
size={40}
|
||||
name={comment.author?.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
@@ -389,12 +386,13 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{/* 用户信息行 - QQ频道风格 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.userInfo}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Text variant="body" style={styles.username}>
|
||||
{comment.author?.nickname}
|
||||
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
||||
<Text variant="body" style={styles.username} numberOfLines={1}>
|
||||
{comment.author?.nickname || '用户'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{renderBadges()}
|
||||
<Text style={styles.metaDot}>·</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(comment.created_at || '')}
|
||||
</Text>
|
||||
@@ -477,31 +475,35 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.lg,
|
||||
backgroundColor: 'transparent',
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
minWidth: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
metaDot: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
@@ -537,42 +539,35 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
floorTag: {
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
floorText: {
|
||||
floorPlain: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 20,
|
||||
lineHeight: 22,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.background.default,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
marginRight: spacing.lg,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 4,
|
||||
@@ -580,16 +575,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
marginLeft: 0,
|
||||
paddingLeft: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.divider,
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
subReplyItemLast: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
subReplyBody: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
marginTop: 2,
|
||||
},
|
||||
subReplyContent: {
|
||||
flex: 1,
|
||||
|
||||
@@ -63,6 +63,11 @@ function topCommentSignature(tc: PostCardProps['post']['top_comment']): string {
|
||||
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 {
|
||||
if (f == null) return '';
|
||||
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 (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -302,6 +308,15 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
</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}>
|
||||
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
|
||||
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
|
||||
@@ -331,6 +346,14 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
{!showGrid && (
|
||||
<View style={styles.metaRow}>
|
||||
<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 && (
|
||||
<View style={styles.pinnedTag}>
|
||||
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
|
||||
@@ -459,6 +482,23 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
@@ -606,6 +646,21 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 8,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -29,12 +29,16 @@ export function hrefNotifications(): string {
|
||||
return '/messages/notifications';
|
||||
}
|
||||
|
||||
export function hrefApps(): string {
|
||||
return '/apps';
|
||||
}
|
||||
|
||||
export function hrefSchedule(): string {
|
||||
return '/schedule';
|
||||
return '/apps/schedule';
|
||||
}
|
||||
|
||||
export function hrefScheduleCourse(courseId: string): string {
|
||||
return `/schedule/course?courseId=${encodeURIComponent(courseId)}`;
|
||||
return `/apps/schedule/course?courseId=${encodeURIComponent(courseId)}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
View,
|
||||
FlatList,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
StatusBar,
|
||||
TouchableOpacity,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
Alert,
|
||||
Clipboard,
|
||||
Modal,
|
||||
@@ -92,6 +93,24 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
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,用于快速查找
|
||||
const postsMap = useMemo(() => {
|
||||
const map = new Map<string, Post>();
|
||||
@@ -114,6 +133,11 @@ export const HomeScreen: React.FC = () => {
|
||||
const isLatestTab = activeIndex === 1;
|
||||
const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isLatestTab) return;
|
||||
restoreCapsuleStripScroll();
|
||||
}, [isLatestTab, activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]);
|
||||
|
||||
// 使用差异更新 Hook 获取帖子列表
|
||||
const listKey = useMemo(
|
||||
() => `home_${getPostType()}_${currentChannelId || 'all'}`,
|
||||
@@ -453,7 +477,15 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
const isActive = item.id === activeCapsuleId;
|
||||
return (
|
||||
|
||||
@@ -2054,30 +2054,23 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
// 空评论状态样式
|
||||
// 空评论状态样式(与平铺评论区一致,无卡片气泡)
|
||||
emptyCommentsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.xl + spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
paddingVertical: spacing.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
emptyCommentsIconWrapper: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background.default,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
opacity: 0.85,
|
||||
},
|
||||
emptyCommentsTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
|
||||
@@ -379,6 +379,16 @@ export const ScheduleScreen: React.FC = () => {
|
||||
// 渲染周选择器
|
||||
const renderWeekSelector = () => (
|
||||
<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
|
||||
style={styles.settingsButton}
|
||||
onPress={() => setIsSettingsModalVisible(true)}
|
||||
@@ -1066,6 +1076,13 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
weekBarIconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
settingsButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface PostImageDTO {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 帖子所属频道(列表卡片展示) */
|
||||
export interface PostChannelBriefDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PostDTO {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -81,6 +87,8 @@ export interface PostDTO {
|
||||
is_favorited: boolean;
|
||||
// 额外字段
|
||||
channel_id?: string;
|
||||
/** 频道摘要(与 channel_id 对应,服务端批量填充) */
|
||||
channel?: PostChannelBriefDTO | null;
|
||||
top_comment?: CommentDTO | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user