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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user