feat(ui): unify design system to Twitter/X style across screens and improve message handling
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m9s
Frontend CI / ota-android (push) Successful in 10m29s
Frontend CI / build-android-apk (push) Successful in 43m4s

Redesign multiple screens and components from card-based responsive design to Twitter/X flat design:
- UserProfileHeader: adopt Twitter/X style with larger icons, simplified buttons, and block functionality
- AppBackButton: update to chevron-left icon with larger size, remove background styling
- Update SettingsScreen, EditProfileScreen, and UserProfileScreen with flat layout approach
- ConversationListRow: convert from bordered cards to flat list rows with updated typography

Improve message system with WebSocket notification handling:
- Add real-time system unread count updates via WebSocket
- Track lastSystemMessageAt for accurate system message timestamps
- Add notification deduplication and read-status filtering
- Combine system and conversation unread counts in background sync
- Clear system notifications from notification center on mark as read
This commit is contained in:
lafay
2026-04-25 00:39:40 +08:00
parent e0d28535f4
commit ad19bc2af7
26 changed files with 950 additions and 1121 deletions

View File

@@ -1,50 +1,50 @@
/** /**
* UserProfileHeader 用户资料头部组件 - 美化版(响应式适配) * UserProfileHeader 用户资料头部组件 - Twitter/X 风格
* 显示用户封面、头像、昵称、简介、关注/粉丝数 * 显示用户封面、头像、昵称、简介、关注/粉丝数
* 采用现代卡片式设计,渐变封面,悬浮头像 * 采用 Twitter/X 扁平化设计,左对齐布局
* 支持互关状态显示 * 支持互关状态显示
* 在宽屏下显示更大的头像和封面
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, useState, useRef, useCallback } from 'react';
import { import {
View, View,
Image, Image,
TouchableOpacity, TouchableOpacity,
StyleSheet, StyleSheet,
Modal,
Pressable,
Dimensions,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { User } from '../../types'; import { User } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
import Button from '../common/Button';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
interface UserProfileHeaderProps { interface UserProfileHeaderProps {
user: User; user: User;
isCurrentUser?: boolean; isCurrentUser?: boolean;
isBlocked?: boolean;
onFollow: () => void; onFollow: () => void;
onSettings?: () => void; onSettings?: () => void;
onEditProfile?: () => void; onEditProfile?: () => void;
onMessage?: () => void; onMessage?: () => void;
onMore?: () => void; // 点击更多按钮 onBlock?: () => void;
onPostsPress?: () => void; // 点击帖子数(可选) onFollowingPress?: () => void;
onFollowingPress?: () => void; // 点击关注数 onFollowersPress?: () => void;
onFollowersPress?: () => void; // 点击粉丝数 onAvatarPress?: () => void;
onAvatarPress?: () => void; // 点击头像编辑按钮
} }
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
user, user,
isCurrentUser = false, isCurrentUser = false,
isBlocked = false,
onFollow, onFollow,
onSettings, onSettings,
onEditProfile, onEditProfile,
onMessage, onMessage,
onMore, onBlock,
onPostsPress,
onFollowingPress, onFollowingPress,
onFollowersPress, onFollowersPress,
onAvatarPress, onAvatarPress,
@@ -52,108 +52,118 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
const colors = useAppColors(); const colors = useAppColors();
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]); const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
const { isWideScreen, isDesktop, width } = useResponsive(); const { isWideScreen, isDesktop, width } = useResponsive();
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const moreButtonRef = useRef<View>(null);
// 格式化数字
const formatCount = (count: number | undefined): string => { const formatCount = (count: number | undefined): string => {
if (count === undefined || count === null) { if (count === undefined || count === null) return '0';
return '0'; if (count >= 10000) return `${(count / 10000).toFixed(1)}w`;
} if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}w`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return count.toString(); return count.toString();
}; };
// 获取帖子数量 const getPostsCount = (): number => user.posts_count ?? 0;
const getPostsCount = (): number => { const getFollowersCount = (): number => user.followers_count ?? 0;
return user.posts_count ?? 0; const getFollowingCount = (): number => user.following_count ?? 0;
}; const getIsFollowing = (): boolean => user.is_following ?? false;
const getIsFollowingMe = (): boolean => user.is_following_me ?? false;
// 获取粉丝数量 const getButtonConfig = (): { title: string; variant: 'primary' | 'outline' | 'ghost'; icon?: string } => {
const getFollowersCount = (): number => {
return user.followers_count ?? 0;
};
// 获取关注数量
const getFollowingCount = (): number => {
return user.following_count ?? 0;
};
// 检查是否关注
const getIsFollowing = (): boolean => {
return user.is_following ?? false;
};
// 检查对方是否关注了我
const getIsFollowingMe = (): boolean => {
return user.is_following_me ?? false;
};
// 获取按钮配置类似B站的互关逻辑
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline'; icon?: string } => {
const isFollowing = getIsFollowing(); const isFollowing = getIsFollowing();
const isFollowingMe = getIsFollowingMe(); const isFollowingMe = getIsFollowingMe();
if (isFollowing && isFollowingMe) { if (isFollowing && isFollowingMe) {
// 已互关
return { title: '互相关注', variant: 'outline', icon: 'account-check' }; return { title: '互相关注', variant: 'outline', icon: 'account-check' };
} else if (isFollowing) { } else if (isFollowing) {
// 已关注但对方未回关
return { title: '已关注', variant: 'outline', icon: 'check' }; return { title: '已关注', variant: 'outline', icon: 'check' };
} else if (isFollowingMe) { } else if (isFollowingMe) {
// 对方关注了我,但我没关注对方 - 显示回关
return { title: '回关', variant: 'primary', icon: 'plus' }; return { title: '回关', variant: 'primary', icon: 'plus' };
} else { } else {
// 互不关注
return { title: '关注', variant: 'primary', icon: 'plus' }; return { title: '关注', variant: 'primary', icon: 'plus' };
} }
}; };
// 根据屏幕尺寸计算封面高度 const coverHeight = isDesktop ? 280 : isWideScreen ? 240 : (width * 10) / 16;
const coverHeight = isDesktop ? 240 : isWideScreen ? 200 : (width * 9) / 16; const avatarSize = isDesktop ? 140 : isWideScreen ? 120 : 88;
// 根据屏幕尺寸计算头像大小
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
const renderStatItem = ({ const handleMorePress = useCallback(() => {
value, moreButtonRef.current?.measure((_fx, _fy, _w, h, px, py) => {
label, const screenWidth = Dimensions.get('window').width;
onPress, const menuWidth = 180;
}: { let x = px + _w / 2 - menuWidth / 2;
value: string; if (x + menuWidth > screenWidth - 12) {
label: string; x = screenWidth - menuWidth - 12;
onPress?: () => void; }
}) => { if (x < 12) x = 12;
const content = ( setMenuPosition({ x, y: py + h + 4 });
<View style={styles.statContent}> setMenuVisible(true);
<Text variant="h3" style={styles.statNumber}>{value}</Text> });
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}> }, []);
{label}
</Text>
</View>
);
if (onPress) { const handleBlockPress = useCallback(() => {
setMenuVisible(false);
onBlock?.();
}, [onBlock]);
const renderStatLink = (count: number, label: string, onPress?: () => void) => {
if (!onPress) {
return ( return (
<TouchableOpacity <View style={styles.statItem}>
style={[styles.statItem, styles.statItemTouchable]} <Text style={styles.statNumber}>{formatCount(count)}</Text>
onPress={onPress} <Text style={styles.statLabel}>{label}</Text>
activeOpacity={0.75} </View>
>
{content}
</TouchableOpacity>
); );
} }
return (
return <View style={styles.statItem}>{content}</View>; <TouchableOpacity style={styles.statItem} onPress={onPress} activeOpacity={0.7}>
<Text style={styles.statNumber}>{formatCount(count)}</Text>
<Text style={styles.statLabel}>{label}</Text>
</TouchableOpacity>
);
}; };
const renderDropdownMenu = () => (
<Modal
visible={menuVisible}
transparent
animationType="fade"
onRequestClose={() => setMenuVisible(false)}
>
<Pressable style={styles.menuBackdrop} onPress={() => setMenuVisible(false)}>
<View
style={[
styles.menuContainer,
{ left: menuPosition.x, top: menuPosition.y },
]}
>
<TouchableOpacity
style={styles.menuItem}
onPress={handleBlockPress}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={isBlocked ? 'account-check-outline' : 'account-off-outline'}
size={18}
color={isBlocked ? colors.success.main : colors.error.main}
/>
<Text
style={[
styles.menuItemText,
{ color: isBlocked ? colors.success.main : colors.error.main },
]}
>
{isBlocked ? '取消拉黑' : '拉黑用户'}
</Text>
</TouchableOpacity>
</View>
</Pressable>
</Modal>
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 渐变封面背景 */} {/* 全宽封面背景 */}
<View style={[styles.coverContainer, { height: coverHeight }]}> <View style={[styles.coverContainer, { height: coverHeight }]}>
<View style={styles.coverTouchable}> <View style={styles.coverTouchable}>
{user.cover_url ? ( {user.cover_url ? (
@@ -163,382 +173,351 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
resizeMode="cover" resizeMode="cover"
/> />
) : ( ) : (
<LinearGradient <View style={[styles.coverFallback, { backgroundColor: colors.primary.main }]}>
colors={['#FF8F66', '#FF6B35', '#E5521D']} <MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
start={{ x: 0, y: 0 }} </View>
end={{ x: 1, y: 1 }}
style={styles.gradient}
/>
)} )}
</View> </View>
{/* 设置按钮 */}
{isCurrentUser && onSettings && (
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
</TouchableOpacity>
)}
{/* 装饰性波浪 */} {/* 顶部导航栏按钮 */}
<View style={styles.waveDecoration}> <View style={styles.topBar}>
<View style={styles.wave} /> {isCurrentUser && onSettings && (
<TouchableOpacity style={styles.iconButton} onPress={onSettings}>
<MaterialCommunityIcons name="cog-outline" size={20} color={colors.text.primary} />
</TouchableOpacity>
)}
</View> </View>
</View> </View>
{/* 用户信息卡片 */} {/* 用户信息 */}
<View style={[ <View style={styles.infoSection}>
styles.profileCard, {/* 头像与操作按钮行 */}
isWideScreen && styles.profileCardWide, <View style={styles.avatarActionRow}>
]}> <View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
{/* 悬浮头像 */} <View style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}>
<View style={[ <Avatar
styles.avatarWrapper, source={user.avatar}
isWideScreen && styles.avatarWrapperWide, size={avatarSize - 6}
]}> name={user.nickname}
<View style={styles.avatarContainer}> />
<Avatar {isCurrentUser && onAvatarPress && (
source={user.avatar} <TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
size={avatarSize} <MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
name={user.nickname} </TouchableOpacity>
/> )}
{isCurrentUser && onAvatarPress && ( </View>
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}> </View>
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
<View style={styles.actionButtons}>
{isCurrentUser ? (
<TouchableOpacity style={styles.editProfileButton} onPress={onEditProfile} activeOpacity={0.8}>
<Text style={styles.editProfileText}></Text>
</TouchableOpacity> </TouchableOpacity>
) : (
<View style={styles.buttonRow}>
<View ref={moreButtonRef} collapsable={false}>
<TouchableOpacity style={styles.iconButtonSmall} onPress={handleMorePress}>
<MaterialCommunityIcons name="dots-horizontal" size={20} color={colors.text.primary} />
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.iconButtonSmall} onPress={onMessage}>
<MaterialCommunityIcons name="email-outline" size={18} color={colors.text.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.followBtn,
getButtonConfig().variant === 'outline' && styles.followBtnOutline,
getButtonConfig().variant === 'primary' && styles.followBtnPrimary,
]}
onPress={onFollow}
activeOpacity={0.8}
>
<Text
style={[
styles.followBtnText,
...(getButtonConfig().variant === 'primary' ? [styles.followBtnTextPrimary] : []),
]}
>
{getButtonConfig().title}
</Text>
</TouchableOpacity>
</View>
)} )}
</View> </View>
</View> </View>
{/* 用户名和简介 */} {/* 用户名 */}
<View style={styles.userInfo}> <View style={styles.nameSection}>
<Text variant="h2" style={[ <Text style={styles.nickname}>{user.nickname}</Text>
styles.nickname, <Text style={styles.username}>@{user.username}</Text>
isWideScreen ? styles.nicknameWide : {},
]}>
{user.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
@{user.username}
</Text>
{user.bio ? (
<Text variant="body" color={colors.text.secondary} style={[
styles.bio,
isWideScreen ? styles.bioWide : {},
]}>
{user.bio}
</Text>
) : (
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
~
</Text>
)}
</View> </View>
{/* 个人信息标签 */} {/* 简介 */}
{user.bio ? (
<Text style={styles.bio}>{user.bio}</Text>
) : null}
{/* 元信息 */}
<View style={styles.metaInfo}> <View style={styles.metaInfo}>
{user.location?.trim() ? ( {user.location?.trim() ? (
<View style={styles.metaTag}> <View style={styles.metaItem}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} /> <MaterialCommunityIcons name="map-marker-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}> <Text style={styles.metaText}>{user.location}</Text>
{user.location}
</Text>
</View> </View>
) : null} ) : null}
{user.website?.trim() ? ( {user.website?.trim() ? (
<View style={styles.metaTag}> <View style={styles.metaItem}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} /> <MaterialCommunityIcons name="link-variant" size={14} color={colors.info.main} />
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}> <Text style={[styles.metaText, styles.metaLink]}>{user.website.replace(/^https?:\/\//, '')}</Text>
{user.website.replace(/^https?:\/\//, '')}
</Text>
</View> </View>
) : null} ) : null}
<View style={styles.metaTag}> <View style={styles.metaItem}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} /> <MaterialCommunityIcons name="calendar-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}> <Text style={styles.metaText}>
{new Date(user.created_at || Date.now()).getFullYear()} {new Date(user.created_at || Date.now()).getFullYear()}
{new Date(user.created_at || Date.now()).getMonth() + 1}
</Text> </Text>
</View> </View>
</View> </View>
{/* 统计数据 - 卡片式 */} {/* 统计数据 - Twitter 风格内联 */}
<View style={[ <View style={styles.statsRow}>
styles.statsCard, {renderStatLink(getFollowingCount(), '正在关注', onFollowingPress)}
isWideScreen && styles.statsCardWide, {renderStatLink(getFollowersCount(), '关注者', onFollowersPress)}
]}>
{renderStatItem({
value: formatCount(getPostsCount()),
label: '帖子',
onPress: onPostsPress,
})}
<View style={styles.statDivider} />
{renderStatItem({
value: formatCount(getFollowingCount()),
label: '关注',
onPress: onFollowingPress,
})}
<View style={styles.statDivider} />
{renderStatItem({
value: formatCount(getFollowersCount()),
label: '粉丝',
onPress: onFollowersPress,
})}
</View>
{/* 操作按钮 */}
<View style={styles.actionButtons}>
{isCurrentUser ? (
<View style={styles.buttonRow} />
) : (
<View style={StyleSheet.flatten([
styles.buttonRow,
isWideScreen && styles.buttonRowWide,
])}>
<Button
title={getButtonConfig().title}
onPress={onFollow}
variant={getButtonConfig().variant}
style={StyleSheet.flatten([
styles.followButton,
isWideScreen && styles.followButtonWide,
])}
icon={getButtonConfig().icon}
/>
<TouchableOpacity style={styles.messageButton} onPress={onMessage}>
<MaterialCommunityIcons name="message-text-outline" size={20} color={colors.primary.main} />
</TouchableOpacity>
<TouchableOpacity style={styles.moreButton} onPress={onMore}>
<MaterialCommunityIcons name="dots-horizontal" size={24} color={colors.text.secondary} />
</TouchableOpacity>
</View>
)}
</View> </View>
</View> </View>
{renderDropdownMenu()}
</View> </View>
); );
}; };
function createUserProfileHeaderStyles(colors: AppColors) { function createUserProfileHeaderStyles(colors: AppColors) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
backgroundColor: colors.background.default, backgroundColor: colors.background.paper,
}, },
coverContainer: { // 封面
position: 'relative', coverContainer: {
overflow: 'hidden', position: 'relative',
}, backgroundColor: colors.background.default,
coverTouchable: { },
width: '100%', coverTouchable: {
height: '100%', width: '100%',
}, height: '100%',
coverImage: { },
width: '100%', coverImage: {
height: '100%', width: '100%',
}, height: '100%',
gradient: { },
width: '100%', coverFallback: {
height: '100%', width: '100%',
}, height: '100%',
settingsButton: { justifyContent: 'center',
position: 'absolute', alignItems: 'center',
top: spacing.lg, },
right: spacing.lg, // 顶部栏
width: 40, topBar: {
height: 40, position: 'absolute',
borderRadius: 20, top: 0,
backgroundColor: 'rgba(0, 0, 0, 0.3)', left: 0,
justifyContent: 'center', right: 0,
alignItems: 'center', flexDirection: 'row',
}, justifyContent: 'flex-end',
waveDecoration: { paddingHorizontal: spacing.md,
position: 'absolute', paddingTop: spacing.md,
bottom: 0, },
left: 0, iconButton: {
right: 0, width: 36,
height: 40, height: 36,
}, borderRadius: borderRadius.full,
wave: { backgroundColor: 'rgba(255, 255, 255, 0.85)',
width: '100%', justifyContent: 'center',
height: '100%', alignItems: 'center',
backgroundColor: colors.background.default, ...shadows.sm,
borderTopLeftRadius: 30, },
borderTopRightRadius: 30, // 信息区
}, infoSection: {
profileCard: { paddingHorizontal: spacing.lg,
backgroundColor: colors.background.paper, paddingTop: spacing.sm,
marginHorizontal: spacing.md, paddingBottom: spacing.lg,
marginTop: -50, },
borderRadius: borderRadius.xl, avatarActionRow: {
padding: spacing.lg, flexDirection: 'row',
...shadows.md, justifyContent: 'space-between',
}, alignItems: 'flex-end',
profileCardWide: { marginBottom: spacing.sm,
marginHorizontal: spacing.lg, },
marginTop: -60, avatarWrapper: {
padding: spacing.xl, // 负边距在 inline style 中计算
}, },
avatarWrapper: { avatarContainer: {
alignItems: 'center', borderRadius: borderRadius.full,
marginTop: -60, backgroundColor: colors.background.paper,
marginBottom: spacing.md, padding: 3,
}, justifyContent: 'center',
avatarWrapperWide: { alignItems: 'center',
marginTop: -80, },
marginBottom: spacing.lg, editAvatarButton: {
}, position: 'absolute',
avatarContainer: { bottom: 4,
position: 'relative', right: 4,
padding: 4, width: 28,
backgroundColor: colors.background.paper, height: 28,
borderRadius: 50, borderRadius: 14,
}, backgroundColor: colors.primary.main,
editAvatarButton: { justifyContent: 'center',
position: 'absolute', alignItems: 'center',
bottom: 0, borderWidth: 2,
right: 0, borderColor: colors.background.paper,
width: 28, },
height: 28, // 操作按钮
borderRadius: 14, actionButtons: {
backgroundColor: colors.primary.main, flexDirection: 'row',
justifyContent: 'center', alignItems: 'center',
alignItems: 'center', marginBottom: spacing.sm,
borderWidth: 2, },
borderColor: colors.background.paper, buttonRow: {
}, flexDirection: 'row',
userInfo: { alignItems: 'center',
alignItems: 'center', gap: spacing.sm,
marginBottom: spacing.md, },
}, iconButtonSmall: {
nickname: { width: 38,
marginBottom: spacing.xs, height: 38,
fontWeight: '700', borderRadius: borderRadius.full,
}, borderWidth: 1,
nicknameWide: { borderColor: colors.divider,
fontSize: fontSizes['3xl'], justifyContent: 'center',
}, alignItems: 'center',
username: { },
marginBottom: spacing.sm, editProfileButton: {
}, paddingHorizontal: spacing.lg,
bio: { paddingVertical: spacing.sm,
textAlign: 'center', borderRadius: borderRadius.full,
marginTop: spacing.sm, borderWidth: 1,
lineHeight: 20, borderColor: colors.divider,
}, backgroundColor: colors.background.paper,
bioWide: { },
fontSize: fontSizes.md, editProfileText: {
lineHeight: 24, fontSize: fontSizes.md,
maxWidth: 600, fontWeight: '600',
}, color: colors.text.primary,
bioPlaceholder: { },
textAlign: 'center', followBtn: {
marginTop: spacing.sm, paddingHorizontal: spacing.lg,
fontStyle: 'italic', paddingVertical: spacing.sm,
}, borderRadius: borderRadius.full,
metaInfo: { minWidth: 80,
flexDirection: 'row', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flexWrap: 'wrap', },
marginBottom: spacing.md, followBtnPrimary: {
gap: spacing.sm, backgroundColor: colors.text.primary,
}, },
metaTag: { followBtnOutline: {
flexDirection: 'row', backgroundColor: colors.background.paper,
alignItems: 'center', borderWidth: 1,
backgroundColor: colors.background.default, borderColor: colors.divider,
paddingHorizontal: spacing.sm, },
paddingVertical: spacing.xs, followBtnText: {
borderRadius: borderRadius.md, fontSize: fontSizes.md,
}, fontWeight: '700',
metaTagText: { color: colors.text.primary,
marginLeft: spacing.xs, },
fontSize: fontSizes.xs, followBtnTextPrimary: {
}, color: colors.background.paper,
statsCard: { },
flexDirection: 'row', // 名字
justifyContent: 'space-around', nameSection: {
alignItems: 'center', marginBottom: spacing.sm,
backgroundColor: 'transparent', },
paddingHorizontal: spacing.xs, nickname: {
paddingVertical: spacing.xs, fontSize: fontSizes['2xl'],
marginBottom: spacing.md, fontWeight: '800',
}, color: colors.text.primary,
statsCardWide: { marginBottom: 2,
paddingHorizontal: spacing.sm, },
paddingVertical: spacing.sm, username: {
marginBottom: spacing.lg, fontSize: fontSizes.md,
}, color: colors.text.hint,
statItem: { },
flex: 1, // 简介
minHeight: 58, bio: {
justifyContent: 'center', fontSize: fontSizes.md,
}, color: colors.text.primary,
statItemTouchable: { lineHeight: 20,
borderRadius: borderRadius.md, marginBottom: spacing.sm,
}, },
statContent: { // 元信息
alignItems: 'center', metaInfo: {
paddingVertical: spacing.sm, flexDirection: 'row',
paddingHorizontal: spacing.xs, flexWrap: 'wrap',
}, gap: spacing.md,
statNumber: { marginBottom: spacing.sm,
fontWeight: '600', },
marginBottom: 0, metaItem: {
}, flexDirection: 'row',
statLabel: { alignItems: 'center',
fontSize: fontSizes.xs, gap: 4,
marginTop: 2, },
}, metaText: {
statDivider: { fontSize: fontSizes.sm,
width: 1, color: colors.text.hint,
height: 24, },
backgroundColor: colors.divider + '55', metaLink: {
}, color: colors.info.main,
actionButtons: { },
marginTop: spacing.sm, // 统计
}, statsRow: {
buttonRow: { flexDirection: 'row',
flexDirection: 'row', gap: spacing.lg,
alignItems: 'center', marginTop: spacing.xs,
gap: spacing.sm, },
}, statItem: {
buttonRowWide: { flexDirection: 'row',
justifyContent: 'center', alignItems: 'center',
gap: spacing.md, gap: 4,
}, },
editButton: { statNumber: {
flex: 1, fontSize: fontSizes.md,
}, fontWeight: '700',
followButton: { color: colors.text.primary,
flex: 1, },
}, statLabel: {
followButtonWide: { fontSize: fontSizes.sm,
flex: 0, color: colors.text.hint,
minWidth: 120, },
}, // 下拉菜单
messageButton: { menuBackdrop: {
width: 44, flex: 1,
height: 44, backgroundColor: 'transparent',
borderRadius: borderRadius.md, },
backgroundColor: colors.primary.light + '20', menuContainer: {
justifyContent: 'center', position: 'absolute',
alignItems: 'center', minWidth: 160,
}, backgroundColor: colors.background.paper,
moreButton: { borderRadius: borderRadius.lg,
width: 44, ...shadows.lg,
height: 44, borderWidth: StyleSheet.hairlineWidth,
borderRadius: borderRadius.md, borderColor: colors.divider,
backgroundColor: colors.background.default, overflow: 'hidden',
justifyContent: 'center', },
alignItems: 'center', menuItem: {
}, flexDirection: 'row',
settingsButtonOnly: { alignItems: 'center',
alignSelf: 'center', gap: spacing.sm,
padding: spacing.sm, paddingVertical: spacing.md,
}, paddingHorizontal: spacing.lg,
},
menuItemText: {
fontSize: fontSizes.md,
fontWeight: '600',
},
}); });
} }
// 使用 React.memo 避免不必要的重新渲染 import { shadows } from '../../theme';
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
export default MemoizedUserProfileHeader; export default MemoizedUserProfileHeader;

View File

@@ -2,56 +2,54 @@ import React from 'react';
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native'; import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { borderRadius, spacing, useAppColors } from '../../theme'; import { useAppColors } from '../../theme';
type AppBackButtonIcon = 'arrow-left' | 'close'; type AppBackButtonIcon = 'chevron-left' | 'arrow-left' | 'close';
interface AppBackButtonProps { interface AppBackButtonProps {
onPress: () => void; onPress: () => void;
icon?: AppBackButtonIcon; icon?: AppBackButtonIcon;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
iconColor?: string; iconColor?: string;
backgroundColor?: string;
hitSlop?: number; hitSlop?: number;
testID?: string; testID?: string;
} }
const AppBackButton: React.FC<AppBackButtonProps> = ({ const AppBackButton: React.FC<AppBackButtonProps> = ({
onPress, onPress,
icon = 'arrow-left', icon = 'chevron-left',
style, style,
iconColor: iconColorProp, iconColor: iconColorProp,
backgroundColor: backgroundColorProp,
hitSlop = 8, hitSlop = 8,
testID, testID,
}) => { }) => {
const colors = useAppColors(); const colors = useAppColors();
const iconColor = iconColorProp ?? colors.text.primary; const iconColor = iconColorProp ?? colors.text.primary;
const backgroundColor = backgroundColorProp ?? colors.background.paper;
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={onPress} onPress={onPress}
style={[styles.button, { backgroundColor }, style]} style={[styles.button, style]}
activeOpacity={0.75} activeOpacity={0.6}
hitSlop={hitSlop} hitSlop={hitSlop}
testID={testID} testID={testID}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={icon === 'close' ? '关闭' : '返回'} accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
> >
<MaterialCommunityIcons name={icon} size={22} color={iconColor} /> <MaterialCommunityIcons name={icon} size={36} color={iconColor} />
</TouchableOpacity> </TouchableOpacity>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
button: { button: {
width: 36, width: 40,
height: 36, height: 40,
borderRadius: borderRadius.full,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: spacing.xs, marginLeft: -12,
marginRight: 0,
padding: 0,
}, },
}); });

View File

@@ -32,7 +32,7 @@ function createQrCodeConfirmStyles(colors: AppColors) {
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
}, },
closeButton: { closeButton: {
padding: spacing.sm, marginLeft: -12,
}, },
headerTitle: { headerTitle: {
fontSize: fontSizes.lg, fontSize: fontSizes.lg,

View File

@@ -309,10 +309,10 @@ function createRegisterStyles(colors: AppColors) {
backIconButton: { backIconButton: {
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20,
backgroundColor: colors.background.default,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: -12,
padding: 0,
}, },
headerTitle: { headerTitle: {
fontSize: 18, fontSize: 18,

View File

@@ -327,6 +327,8 @@ function createStyles(colors: AppColors) {
height: 40, height: 40,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginLeft: -12,
padding: 0,
}, },
headerTitle: { headerTitle: {
fontSize: 18, fontSize: 18,

View File

@@ -1929,10 +1929,8 @@ function createPostDetailStyles(colors: AppColors) {
marginRight: spacing.sm, marginRight: spacing.sm,
}, },
headerBackButton: { headerBackButton: {
paddingHorizontal: spacing.xs, marginLeft: 0,
paddingVertical: spacing.xs, marginRight: 0,
marginLeft: spacing.xs,
marginRight: spacing.sm,
}, },
// 帖子容器 // 帖子容器
postContainer: { postContainer: {

View File

@@ -14,6 +14,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { AppBackButton } from '../../components/common';
import { import {
spacing, spacing,
fontSizes, fontSizes,
@@ -275,9 +276,7 @@ export const MaterialDetailScreen: React.FC = () => {
{/* Header */} {/* Header */}
<View style={[styles.header, isWideScreen && styles.headerWide]}> <View style={[styles.header, isWideScreen && styles.headerWide]}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}> <AppBackButton onPress={() => router.back()} />
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
</TouchableOpacity>
</View> </View>
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}> <Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
@@ -341,12 +340,9 @@ function createMaterialDetailStyles(colors: AppColors) {
paddingVertical: spacing.lg, paddingVertical: spacing.lg,
}, },
headerLeft: { headerLeft: {
width: 44, width: 40,
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
backButton: {
padding: spacing.xs,
},
headerCenter: { headerCenter: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -14,6 +14,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { AppBackButton } from '../../components/common';
import { import {
spacing, spacing,
fontSizes, fontSizes,
@@ -166,9 +167,7 @@ export const MaterialsScreen: React.FC = () => {
{/* Header */} {/* Header */}
<View style={[styles.header, isWideScreen && styles.headerWide]}> <View style={[styles.header, isWideScreen && styles.headerWide]}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}> <AppBackButton onPress={() => router.back()} />
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
</TouchableOpacity>
</View> </View>
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}> <Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
@@ -227,12 +226,9 @@ function createMaterialsStyles(colors: AppColors) {
paddingVertical: spacing.lg, paddingVertical: spacing.lg,
}, },
headerLeft: { headerLeft: {
width: 44, width: 40,
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
backButton: {
padding: spacing.xs,
},
headerCenter: { headerCenter: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -15,6 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { AppBackButton } from '../../components/common';
import { import {
spacing, spacing,
fontSizes, fontSizes,
@@ -255,9 +256,7 @@ export const SubjectMaterialsScreen: React.FC = () => {
{/* Header */} {/* Header */}
<View style={[styles.header, isWideScreen && styles.headerWide]}> <View style={[styles.header, isWideScreen && styles.headerWide]}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}> <AppBackButton onPress={() => router.back()} />
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
</TouchableOpacity>
</View> </View>
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}> <Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
@@ -302,12 +301,9 @@ function createSubjectMaterialsStyles(colors: AppColors) {
paddingVertical: spacing.lg, paddingVertical: spacing.lg,
}, },
headerLeft: { headerLeft: {
width: 44, width: 40,
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
backButton: {
padding: spacing.xs,
},
headerCenter: { headerCenter: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -1108,10 +1108,10 @@ function createGroupInfoStyles(colors: AppColors) {
fontSize: fontSizes.xl, fontSize: fontSizes.xl,
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
pageHeaderPlaceholder: { pageHeaderPlaceholder: {
width: 40, width: 40,
height: 40, height: 40,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
}, },

View File

@@ -690,6 +690,7 @@ function createGroupMembersStyles(colors: AppColors) {
}, },
pageHeaderPlaceholder: { pageHeaderPlaceholder: {
width: 40, width: 40,
height: 40,
}, },
section: { section: {
marginBottom: spacing.md, marginBottom: spacing.md,

View File

@@ -49,6 +49,7 @@ import {
useUnreadCount, useUnreadCount,
useMarkAsRead, useMarkAsRead,
useMessageManagerConversations, useMessageManagerConversations,
useMessageStore,
} from '../../stores'; } from '../../stores';
import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
@@ -113,6 +114,7 @@ export const MessageListScreen: React.FC = () => {
// 使用 MessageManager 获取未读数和系统通知数 // 使用 MessageManager 获取未读数和系统通知数
const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
const lastSystemMessageAt = useMessageStore(state => state.lastSystemMessageAt);
const { markAllAsRead, isMarking } = useMarkAsRead(null); const { markAllAsRead, isMarking } = useMarkAsRead(null);
// 【新架构】使用MessageManager的hook创建会话 // 【新架构】使用MessageManager的hook创建会话
@@ -160,14 +162,14 @@ export const MessageListScreen: React.FC = () => {
seq: 0, seq: 0,
segments: [{ type: 'text', data: { text: '系统通知' } }], segments: [{ type: 'text', data: { text: '系统通知' } }],
status: 'normal', status: 'normal',
created_at: new Date().toISOString(), created_at: lastSystemMessageAt || new Date().toISOString(),
}, },
last_message_at: new Date().toISOString(), last_message_at: lastSystemMessageAt || new Date().toISOString(),
unread_count: systemUnreadCount, unread_count: systemUnreadCount,
participants: [], participants: [],
created_at: new Date().toISOString(), created_at: lastSystemMessageAt || new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: lastSystemMessageAt || new Date().toISOString(),
}), [systemUnreadCount]); }), [systemUnreadCount, lastSystemMessageAt]);
// 动画值 // 动画值
const [scaleAnims] = useState(() => const [scaleAnims] = useState(() =>
@@ -451,6 +453,7 @@ export const MessageListScreen: React.FC = () => {
// 创建系统通知会话项 // 创建系统通知会话项
const createSystemMessageItem = (): ConversationResponse => { const createSystemMessageItem = (): ConversationResponse => {
const noticeContent = systemUnreadCount > 0 ? `您有 ${systemUnreadCount} 条未读通知` : '暂无新通知'; const noticeContent = systemUnreadCount > 0 ? `您有 ${systemUnreadCount} 条未读通知` : '暂无新通知';
const lastTime = lastSystemMessageAt || new Date().toISOString();
return { return {
id: SYSTEM_MESSAGE_CHANNEL_ID, id: SYSTEM_MESSAGE_CHANNEL_ID,
type: 'private', type: 'private',
@@ -462,13 +465,13 @@ export const MessageListScreen: React.FC = () => {
seq: 0, seq: 0,
segments: [{ type: 'text', data: { text: noticeContent } }], segments: [{ type: 'text', data: { text: noticeContent } }],
status: 'normal', status: 'normal',
created_at: new Date().toISOString(), created_at: lastTime,
}, },
last_message_at: new Date().toISOString(), last_message_at: lastTime,
unread_count: systemUnreadCount, unread_count: systemUnreadCount,
participants: [], participants: [],
created_at: new Date().toISOString(), created_at: lastTime,
updated_at: new Date().toISOString(), updated_at: lastTime,
}; };
}; };
@@ -936,7 +939,8 @@ function createMessageListStyles(colors: AppColors) {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
...shadows.sm, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider + '60',
}, },
headerWide: { headerWide: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
@@ -949,7 +953,7 @@ function createMessageListStyles(colors: AppColors) {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
}, },
listContentWide: { listContentWide: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.xs,
}, },
headerLeft: { headerLeft: {
width: 44, width: 44,
@@ -1028,6 +1032,7 @@ function createMessageListStyles(colors: AppColors) {
listContent: { listContent: {
flexGrow: 1, flexGrow: 1,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
paddingHorizontal: spacing.xs,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@@ -1086,11 +1091,9 @@ function createMessageListStyles(colors: AppColors) {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
padding: spacing.md, padding: spacing.md,
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
borderRadius: borderRadius.lg, borderBottomWidth: StyleSheet.hairlineWidth,
marginBottom: spacing.sm, borderBottomColor: `${colors.divider}50`,
borderWidth: StyleSheet.hairlineWidth,
borderColor: `${colors.divider}50`,
}, },
searchResultContent: { searchResultContent: {
flex: 1, flex: 1,

View File

@@ -32,9 +32,10 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme'; import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { SystemMessageResponse } from '../../types/dto'; import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '@/services/message'; import { messageService } from '@/services/message';
import { systemNotificationService } from '@/services/notification';
import { commentService } from '@/services/post'; import { commentService } from '@/services/post';
import { SystemMessageItem } from '../../components/business'; import { SystemMessageItem } from '../../components/business';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer, AppBackButton } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores'; import { useMessageManagerSystemUnreadCount } from '../../stores';
@@ -174,6 +175,8 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
setSystemUnreadCount(0); setSystemUnreadCount(0);
// 同步更新全局 TabBar 红点 // 同步更新全局 TabBar 红点
messageManager.fetchUnreadCount(); messageManager.fetchUnreadCount();
// 清除通知栏中已读的系统消息通知
systemNotificationService.clearAllNotifications().catch(() => {});
} catch (error) { } catch (error) {
console.error('一键已读失败:', error); console.error('一键已读失败:', error);
} }
@@ -381,11 +384,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return ( return (
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}> <View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
{shouldShowBackButton ? ( {shouldShowBackButton ? (
<TouchableOpacity style={styles.backButton} onPress={onBack} activeOpacity={0.8}> <View style={styles.backButton}>
<View style={styles.backIconButton}> <AppBackButton onPress={onBack} />
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.text.primary} /> </View>
</View>
</TouchableOpacity>
) : ( ) : (
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
)} )}
@@ -673,14 +674,6 @@ function createNotificationsStyles(colors: AppColors) {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
backIconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
},
backButtonPlaceholder: { backButtonPlaceholder: {
width: 40, width: 40,
}, },
@@ -735,7 +728,7 @@ function createNotificationsStyles(colors: AppColors) {
listContent: { listContent: {
flexGrow: 1, flexGrow: 1,
paddingTop: 8, paddingTop: 8,
paddingBottom: 24, paddingBottom: 88,
}, },
listContentWide: { listContentWide: {
paddingHorizontal: 32, paddingHorizontal: 32,

View File

@@ -106,12 +106,12 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
height: 56, height: 56,
}, },
backButton: { backButton: {
width: 36, width: 40,
height: 36, height: 40,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 10, marginLeft: -12,
backgroundColor: 'transparent', padding: 0,
}, },
headerCenter: { headerCenter: {
flex: 1, flex: 1,

View File

@@ -157,32 +157,27 @@ function createConversationRowStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: 14, paddingVertical: 14,
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
marginHorizontal: spacing.md, borderBottomWidth: StyleSheet.hairlineWidth,
marginTop: spacing.sm, borderBottomColor: colors.chat.borderLight,
borderRadius: borderRadius.lg,
borderWidth: 0.5,
borderColor: colors.divider + '40',
}, },
conversationItemSelected: { conversationItemSelected: {
backgroundColor: colors.primary.light + '12', backgroundColor: colors.chat.surfaceMuted,
borderColor: colors.primary.main + '60',
borderWidth: 1.5,
}, },
avatarContainer: { avatarContainer: {
position: 'relative', position: 'relative',
}, },
systemAvatar: { systemAvatar: {
width: 50, width: 56,
height: 50, height: 56,
borderRadius: borderRadius.md, borderRadius: borderRadius.full,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: colors.primary.main, backgroundColor: colors.primary.main,
}, },
conversationContent: { conversationContent: {
flex: 1, flex: 1,
marginLeft: spacing.md, marginLeft: spacing.lg,
}, },
conversationHeader: { conversationHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -197,49 +192,49 @@ function createConversationRowStyles(colors: AppColors) {
officialBadge: { officialBadge: {
backgroundColor: colors.primary.main, backgroundColor: colors.primary.main,
borderRadius: borderRadius.sm, borderRadius: borderRadius.sm,
paddingHorizontal: 6, paddingHorizontal: 5,
paddingVertical: 2, paddingVertical: 1,
marginRight: spacing.xs, marginRight: spacing.xs,
}, },
officialBadgeText: { officialBadgeText: {
color: colors.primary.contrast, color: colors.primary.contrast,
fontSize: 10, fontSize: 9,
fontWeight: '800', fontWeight: '800',
}, },
userName: { userName: {
fontWeight: '700', fontWeight: '600',
color: colors.text.primary, color: colors.text.primary,
fontSize: 16, fontSize: 16,
letterSpacing: 0.3, letterSpacing: 0.2,
}, },
groupIcon: { groupIcon: {
marginRight: 4, marginRight: 3,
}, },
memberCount: { memberCount: {
fontSize: 12, fontSize: 13,
color: colors.text.secondary, color: colors.text.secondary,
marginLeft: 2, marginLeft: 2,
fontWeight: '500', fontWeight: '500',
}, },
pinnedIcon: { pinnedIcon: {
marginLeft: 6, marginLeft: 5,
}, },
groupAvatar: { groupAvatar: {
width: 50, width: 56,
height: 50, height: 56,
}, },
groupAvatarPlaceholder: { groupAvatarPlaceholder: {
width: 50, width: 56,
height: 50, height: 56,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
backgroundColor: colors.primary.main, backgroundColor: colors.primary.main,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
timeText: { timeText: {
color: colors.text.secondary, color: colors.text.hint,
fontSize: 12, fontSize: 12,
fontWeight: '500', fontWeight: '400',
}, },
messageRow: { messageRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -250,25 +245,26 @@ function createConversationRowStyles(colors: AppColors) {
color: colors.text.secondary, color: colors.text.secondary,
fontSize: 14, fontSize: 14,
fontWeight: '400', fontWeight: '400',
lineHeight: 20,
}, },
unreadMessageText: { unreadMessageText: {
color: colors.text.primary, color: colors.text.primary,
fontWeight: '600', fontWeight: '600',
}, },
unreadBadge: { unreadBadge: {
minWidth: 22, minWidth: 20,
height: 22, height: 20,
borderRadius: 11, borderRadius: 10,
backgroundColor: colors.primary.main, backgroundColor: colors.error.main,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: spacing.sm, marginLeft: spacing.sm,
paddingHorizontal: 6, paddingHorizontal: 5,
}, },
unreadBadgeText: { unreadBadgeText: {
color: colors.primary.contrast, color: '#FFFFFF',
fontSize: 12, fontSize: 12,
fontWeight: '800', fontWeight: '700',
}, },
}); });
} }
@@ -321,12 +317,12 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
{isSystemChannel ? ( {isSystemChannel ? (
<View style={styles.systemAvatar}> <View style={styles.systemAvatar}>
<MaterialCommunityIcons name="bell-ring" size={24} color={colors.primary.contrast} /> <MaterialCommunityIcons name="bell-ring" size={26} color={colors.primary.contrast} />
</View> </View>
) : isGroupChat ? ( ) : isGroupChat ? (
<View style={styles.groupAvatar}> <View style={styles.groupAvatar}>
{displayAvatar ? ( {displayAvatar ? (
<Avatar source={displayAvatar} size={50} name={displayName} /> <Avatar source={displayAvatar} size={56} name={displayName} />
) : ( ) : (
<View style={styles.groupAvatarPlaceholder}> <View style={styles.groupAvatarPlaceholder}>
<MaterialCommunityIcons name="account-group" size={28} color={colors.primary.contrast} /> <MaterialCommunityIcons name="account-group" size={28} color={colors.primary.contrast} />
@@ -334,7 +330,7 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
)} )}
</View> </View>
) : ( ) : (
<Avatar source={displayAvatar} size={50} name={displayName} /> <Avatar source={displayAvatar} size={56} name={displayName} />
)} )}
</View> </View>

View File

@@ -1,8 +1,7 @@
/** /**
* 编辑资料页 EditProfileScreen(响应式适配) * 编辑资料页 EditProfileScreen - Twitter/X 风格
* 威友 - 编辑用户资料 * 威友 - 编辑用户资料
* 与用户资料页样式完全一致 * 与 Twitter/X 个人资料编辑页样式一致
* 表单在宽屏下居中显示
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
@@ -21,274 +20,199 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
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 { LinearGradient } from 'expo-linear-gradient';
import { import {
spacing, spacing,
fontSizes, fontSizes,
borderRadius, borderRadius,
shadows,
useAppColors, useAppColors,
type AppColors, type AppColors,
} from '../../theme'; } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { Avatar, Button, Text } from '../../components/common'; import { Avatar, Text } from '../../components/common';
import { authService, uploadService } from '../../services'; import { authService, uploadService } from '../../services';
import { useResponsive, useResponsiveSpacing } from '../../hooks'; import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
function createEditProfileStyles(colors: AppColors) { function createEditProfileStyles(colors: AppColors) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
}, },
keyboardView: { keyboardView: {
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
padding: spacing.lg, paddingBottom: spacing.xl,
}, },
// ===== 预览区域 - 与 UserProfileHeader 完全一致 ===== // ===== 封面区域 - Twitter 风格全宽 =====
previewContainer: { coverContainer: {
marginBottom: spacing.lg, position: 'relative',
}, backgroundColor: colors.background.default,
coverContainer: { },
position: 'relative', coverTouchable: {
overflow: 'hidden', width: '100%',
borderRadius: 14, height: '100%',
}, },
coverTouchable: { coverImage: {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
coverImage: { coverFallback: {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, backgroundColor: colors.primary.main,
gradient: { justifyContent: 'center',
width: '100%', alignItems: 'center',
height: '100%', },
}, coverOverlay: {
coverUploadingOverlay: { ...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.4)',
backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center',
justifyContent: 'center', alignItems: 'center',
alignItems: 'center', },
},
coverEditOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditText: {
color: colors.text.inverse,
fontSize: fontSizes.sm,
marginTop: spacing.xs,
fontWeight: '500',
},
waveDecoration: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
},
wave: {
width: '100%',
height: '100%',
backgroundColor: colors.background.paper,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
},
// 用户信息卡片 // ===== 信息区 =====
profileCard: { infoSection: {
backgroundColor: colors.background.paper, paddingHorizontal: spacing.lg,
marginHorizontal: spacing.md, paddingBottom: spacing.md,
marginTop: -50, backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl, },
padding: spacing.lg, avatarActionRow: {
}, flexDirection: 'row',
avatarWrapper: { justifyContent: 'space-between',
alignItems: 'center', alignItems: 'flex-end',
marginTop: -60, marginBottom: spacing.sm,
marginBottom: spacing.md, },
}, avatarWrapper: {
avatarContainer: { // 负边距在 inline style 中计算
position: 'relative', },
padding: 4, avatarContainer: {
backgroundColor: colors.background.paper, borderRadius: borderRadius.full,
borderRadius: 50, backgroundColor: colors.background.paper,
}, padding: 3,
avatarUploadingOverlay: { justifyContent: 'center',
position: 'absolute', alignItems: 'center',
top: 0, position: 'relative',
left: 0, },
right: 0, avatarOverlay: {
bottom: 0, position: 'absolute',
backgroundColor: 'rgba(0, 0, 0, 0.5)', top: 3,
borderRadius: 60, left: 3,
justifyContent: 'center', right: 3,
alignItems: 'center', bottom: 3,
}, borderRadius: borderRadius.full,
editAvatarButton: { backgroundColor: 'rgba(0, 0, 0, 0.4)',
position: 'absolute', justifyContent: 'center',
bottom: 0, alignItems: 'center',
right: 0, },
width: 28, editAvatarButton: {
height: 28, position: 'absolute',
borderRadius: 14, bottom: 4,
backgroundColor: colors.primary.main, right: 4,
justifyContent: 'center', width: 28,
alignItems: 'center', height: 28,
borderWidth: 2, borderRadius: 14,
borderColor: colors.background.paper, backgroundColor: colors.primary.main,
}, justifyContent: 'center',
userInfo: { alignItems: 'center',
alignItems: 'center', borderWidth: 2,
marginBottom: spacing.md, borderColor: colors.background.paper,
}, },
nickname: {
marginBottom: spacing.xs,
fontWeight: '700',
},
username: {
marginBottom: spacing.sm,
},
bio: {
textAlign: 'center',
marginTop: spacing.sm,
lineHeight: 20,
},
bioPlaceholder: {
textAlign: 'center',
marginTop: spacing.sm,
fontStyle: 'italic',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: spacing.md,
gap: spacing.sm,
},
metaTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
metaTagText: {
marginLeft: spacing.xs,
fontSize: fontSizes.xs,
},
editHint: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editHintText: {
fontSize: fontSizes.xs,
},
// ===== 表单区域 ===== // 名字预览
formCard: { nameSection: {
borderRadius: 14, marginBottom: spacing.sm,
padding: spacing.lg, },
marginBottom: spacing.lg, nickname: {
maxWidth: CONTENT_MAX_WIDTH, fontSize: fontSizes['2xl'],
alignSelf: 'center', fontWeight: '800',
width: '100%', color: colors.text.primary,
}, marginBottom: 2,
sectionTitle: { },
marginBottom: spacing.lg, username: {
}, fontSize: fontSizes.md,
formField: { color: colors.text.hint,
flexDirection: 'row', },
alignItems: 'flex-start',
},
fieldIconContainer: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
marginTop: spacing.xs,
},
fieldContent: {
flex: 1,
},
fieldLabel: {
marginBottom: spacing.xs,
fontWeight: '500',
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing.md,
marginLeft: 56,
},
// 保存按钮 // 编辑提示
buttonContainer: { editHint: {
marginTop: spacing.sm, flexDirection: 'row',
}, alignItems: 'center',
saveButton: { marginTop: spacing.xs,
height: 50, gap: spacing.xs,
backgroundColor: colors.primary.main, },
borderRadius: 14, editHintText: {
alignItems: 'center', fontSize: fontSizes.xs,
justifyContent: 'center', color: colors.text.hint,
}, },
saveButtonDisabled: {
opacity: 0.6, // ===== 表单区域 - Twitter 风格全宽卡片 =====
}, formSection: {
saveButtonContent: { backgroundColor: colors.background.paper,
flexDirection: 'row', marginTop: spacing.md,
justifyContent: 'center', borderTopWidth: StyleSheet.hairlineWidth,
alignItems: 'center', borderBottomWidth: StyleSheet.hairlineWidth,
}, borderColor: colors.divider,
buttonIcon: { },
marginRight: spacing.sm, formField: {
}, paddingHorizontal: spacing.lg,
saveButtonText: { paddingVertical: spacing.md,
color: colors.text.inverse, borderBottomWidth: StyleSheet.hairlineWidth,
}, borderBottomColor: colors.divider,
},
formFieldLast: {
borderBottomWidth: 0,
},
fieldLabel: {
fontSize: fontSizes.sm,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: spacing.xs,
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.xs,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
charCount: {
textAlign: 'right',
marginTop: spacing.xs,
},
// 保存按钮
buttonContainer: {
marginTop: spacing.lg,
paddingHorizontal: spacing.lg,
},
saveButton: {
height: 48,
backgroundColor: colors.text.primary,
borderRadius: borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
color: colors.background.paper,
fontSize: fontSizes.md,
fontWeight: '700',
},
}); });
} }
type EditProfileSheet = ReturnType<typeof createEditProfileStyles>; type EditProfileSheet = ReturnType<typeof createEditProfileStyles>;
// 表单输入项组件
interface FormFieldProps { interface FormFieldProps {
icon: string;
label: string; label: string;
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
@@ -301,10 +225,10 @@ interface FormFieldProps {
editable?: boolean; editable?: boolean;
sheet: EditProfileSheet; sheet: EditProfileSheet;
colors: AppColors; colors: AppColors;
isLast?: boolean;
} }
const FormField: React.FC<FormFieldProps> = ({ const FormField: React.FC<FormFieldProps> = ({
icon,
label, label,
value, value,
onChangeText, onChangeText,
@@ -317,32 +241,33 @@ const FormField: React.FC<FormFieldProps> = ({
editable = true, editable = true,
sheet, sheet,
colors, colors,
isLast = false,
}) => { }) => {
return ( return (
<View style={sheet.formField}> <View style={[sheet.formField, isLast && sheet.formFieldLast]}>
<View style={sheet.fieldIconContainer}> <Text style={sheet.fieldLabel}>{label}</Text>
<MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} /> <TextInput
</View> style={[
<View style={sheet.fieldContent}> sheet.fieldInput,
<Text variant="caption" style={sheet.fieldLabel}>{label}</Text> multiline && sheet.textArea,
<TextInput !editable && sheet.disabledInput
style={[ ]}
sheet.fieldInput, value={value}
multiline && sheet.textArea, onChangeText={onChangeText}
!editable && sheet.disabledInput placeholder={placeholder}
]} placeholderTextColor={colors.text.hint}
value={value} maxLength={maxLength}
onChangeText={onChangeText} multiline={multiline}
placeholder={placeholder} numberOfLines={numberOfLines}
placeholderTextColor={colors.text.hint} autoCapitalize={autoCapitalize}
maxLength={maxLength} keyboardType={keyboardType}
multiline={multiline} editable={editable}
numberOfLines={numberOfLines} />
autoCapitalize={autoCapitalize} {maxLength && (
keyboardType={keyboardType} <Text variant="caption" color={colors.text.hint} style={sheet.charCount}>
editable={editable} {value.length}/{maxLength}
/> </Text>
</View> )}
</View> </View>
); );
}; };
@@ -353,7 +278,7 @@ export const EditProfileScreen: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { currentUser, updateUser } = useAuthStore(); const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive(); const { isWideScreen, isMobile, width } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [nickname, setNickname] = useState(currentUser?.nickname || ''); const [nickname, setNickname] = useState(currentUser?.nickname || '');
@@ -367,16 +292,13 @@ export const EditProfileScreen: React.FC = () => {
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false); const [uploadingCover, setUploadingCover] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// 底部间距,避免被 TabBar 遮挡
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg; const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg;
const coverHeight = isWideScreen ? Math.min((width * 10) / 16, 320) : (width * 10) / 16;
const avatarSize = isWideScreen ? 120 : 88;
// 根据屏幕宽度计算封面高度
const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16;
// 选择头图
const handlePickCover = async () => { const handlePickCover = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) { if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头图'); Alert.alert('权限不足', '需要访问相册权限来选择头图');
return; return;
@@ -416,10 +338,8 @@ export const EditProfileScreen: React.FC = () => {
} }
}; };
// 选择头像
const handlePickAvatar = async () => { const handlePickAvatar = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) { if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头像'); Alert.alert('权限不足', '需要访问相册权限来选择头像');
return; return;
@@ -459,7 +379,6 @@ export const EditProfileScreen: React.FC = () => {
} }
}; };
// 保存资料
const handleSave = async () => { const handleSave = async () => {
if (!nickname.trim()) { if (!nickname.trim()) {
Alert.alert('错误', '昵称不能为空'); Alert.alert('错误', '昵称不能为空');
@@ -512,69 +431,56 @@ export const EditProfileScreen: React.FC = () => {
} }
}; };
// 渲染表单内容
const renderFormContent = () => ( const renderFormContent = () => (
<> <>
{/* ===== 用户资料预览区域 - 与 UserProfileHeader 完全一致 ===== */} {/* ===== 封面区域 - Twitter 风格全宽 ===== */}
<View style={styles.previewContainer}> <View style={[styles.coverContainer, { height: coverHeight }]}>
{/* 封面背景 */} <TouchableOpacity
<View style={[styles.coverContainer, { height: coverHeight }]}> style={styles.coverTouchable}
<TouchableOpacity onPress={handlePickCover}
style={styles.coverTouchable} activeOpacity={0.8}
onPress={handlePickCover} >
activeOpacity={0.8} {coverUrl ? (
> <Image
{coverUrl ? ( source={{ uri: coverUrl }}
<Image style={styles.coverImage}
source={{ uri: coverUrl }} resizeMode="cover"
style={styles.coverImage} />
resizeMode="cover" ) : (
/> <View style={styles.coverFallback}>
) : ( <MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
<LinearGradient </View>
colors={['#FF8F66', '#FF6B35', '#E5521D']} )}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
/>
)}
{/* 头图编辑蒙版 */} {uploadingCover ? (
{uploadingCover ? ( <View style={styles.coverOverlay}>
<View style={styles.coverUploadingOverlay}> <MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} /> </View>
</View> ) : (
) : ( <View style={styles.coverOverlay}>
<View style={styles.coverEditOverlay}> <MaterialCommunityIcons name="camera" size={28} color={colors.text.inverse} />
<MaterialCommunityIcons name="camera" size={28} color={colors.text.inverse} /> </View>
<Text style={styles.coverEditText}></Text> )}
</View> </TouchableOpacity>
)} </View>
</TouchableOpacity>
{/* 装饰性波浪 */} {/* ===== 用户信息区 - Twitter 风格左对齐 ===== */}
<View style={styles.waveDecoration}> <View style={styles.infoSection}>
<View style={styles.wave} /> <View style={styles.avatarActionRow}>
</View> <View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
</View>
{/* 用户信息卡片 */}
<View style={styles.profileCard}>
{/* 悬浮头像 */}
<View style={styles.avatarWrapper}>
<TouchableOpacity <TouchableOpacity
style={styles.avatarContainer} style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}
onPress={handlePickAvatar} onPress={handlePickAvatar}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Avatar <Avatar
source={avatar || null} source={avatar || null}
size={isWideScreen ? 110 : 90} size={avatarSize - 6}
name={nickname} name={nickname}
/> />
{uploadingAvatar && ( {uploadingAvatar && (
<View style={styles.avatarUploadingOverlay}> <View style={styles.avatarOverlay}>
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} /> <MaterialCommunityIcons name="loading" size={24} color={colors.text.inverse} />
</View> </View>
)} )}
<View style={styles.editAvatarButton}> <View style={styles.editAvatarButton}>
@@ -582,85 +488,22 @@ export const EditProfileScreen: React.FC = () => {
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
{/* 用户名和简介 */} <View style={styles.nameSection}>
<View style={styles.userInfo}> <Text style={styles.nickname}>{nickname || currentUser?.nickname || ''}</Text>
<Text variant="h2" style={styles.nickname}> <Text style={styles.username}>@{currentUser?.username || ''}</Text>
{nickname || currentUser?.nickname || ''} </View>
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
@{currentUser?.username || ''}
</Text>
{bio ? ( <View style={styles.editHint}>
<Text variant="body" color={colors.text.secondary} style={styles.bio}> <MaterialCommunityIcons name="information-outline" size={12} color={colors.text.hint} />
{bio} <Text style={styles.editHintText}></Text>
</Text>
) : (
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
~
</Text>
)}
</View>
{/* 个人信息标签 */}
<View style={styles.metaInfo}>
{location ? (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
{location}
</Text>
</View>
) : (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
</Text>
</View>
)}
{website ? (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
{typeof website === 'string' ? website.replace(/^https?:\/\//, '') : ''}
</Text>
</View>
) : (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
</Text>
</View>
)}
<View style={styles.metaTag}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
{`加入于 ${new Date(currentUser?.created_at || Date.now()).getFullYear()}`}
</Text>
</View>
</View>
{/* 编辑提示 */}
<View style={styles.editHint}>
<MaterialCommunityIcons name="information-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.editHintText}>
</Text>
</View>
</View> </View>
</View> </View>
{/* ===== 表单区域 ===== */} {/* ===== 表单区域 - Twitter 风格全宽分组 ===== */}
<View style={styles.formCard}> <View style={styles.formSection}>
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
</Text>
<FormField <FormField
icon="account-outline"
label="昵称" label="昵称"
value={nickname} value={nickname}
onChangeText={setNickname} onChangeText={setNickname}
@@ -670,14 +513,11 @@ export const EditProfileScreen: React.FC = () => {
colors={colors} colors={colors}
/> />
<View style={styles.divider} />
<FormField <FormField
icon="information-outline"
label="个人简介" label="个人简介"
value={bio} value={bio}
onChangeText={setBio} onChangeText={setBio}
placeholder="介绍一下自己,让大家更了解你" placeholder="介绍一下自己"
maxLength={100} maxLength={100}
multiline multiline
numberOfLines={3} numberOfLines={3}
@@ -685,10 +525,7 @@ export const EditProfileScreen: React.FC = () => {
colors={colors} colors={colors}
/> />
<View style={styles.divider} />
<FormField <FormField
icon="map-marker-outline"
label="地区" label="地区"
value={location} value={location}
onChangeText={setLocation} onChangeText={setLocation}
@@ -698,10 +535,7 @@ export const EditProfileScreen: React.FC = () => {
colors={colors} colors={colors}
/> />
<View style={styles.divider} />
<FormField <FormField
icon="link-variant"
label="个人网站" label="个人网站"
value={website} value={website}
onChangeText={setWebsite} onChangeText={setWebsite}
@@ -711,17 +545,13 @@ export const EditProfileScreen: React.FC = () => {
keyboardType="url" keyboardType="url"
sheet={styles} sheet={styles}
colors={colors} colors={colors}
isLast
/> />
</View> </View>
{/* 联系方式区域 */} {/* 联系方式区域 */}
<View style={styles.formCard}> <View style={[styles.formSection, { marginTop: spacing.md }]}>
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
</Text>
<FormField <FormField
icon="phone-outline"
label="手机号" label="手机号"
value={phone} value={phone}
onChangeText={setPhone} onChangeText={setPhone}
@@ -732,10 +562,7 @@ export const EditProfileScreen: React.FC = () => {
colors={colors} colors={colors}
/> />
<View style={styles.divider} />
<FormField <FormField
icon="email-outline"
label="邮箱" label="邮箱"
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
@@ -745,6 +572,7 @@ export const EditProfileScreen: React.FC = () => {
keyboardType="email-address" keyboardType="email-address"
sheet={styles} sheet={styles}
colors={colors} colors={colors}
isLast
/> />
</View> </View>
@@ -759,26 +587,9 @@ export const EditProfileScreen: React.FC = () => {
disabled={saving || uploadingAvatar || uploadingCover} disabled={saving || uploadingAvatar || uploadingCover}
activeOpacity={0.8} activeOpacity={0.8}
> >
<View style={styles.saveButtonContent}> <Text style={styles.saveButtonText}>
{saving ? ( {saving ? '保存中...' : '保存修改'}
<MaterialCommunityIcons </Text>
name="loading"
size={20}
color={colors.text.inverse}
style={styles.buttonIcon}
/>
) : (
<MaterialCommunityIcons
name="check"
size={20}
color={colors.text.inverse}
style={styles.buttonIcon}
/>
)}
<Text variant="body" style={[styles.saveButtonText, { fontWeight: '600' }]}>
{saving ? '保存中...' : '保存修改'}
</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</> </>
@@ -801,5 +612,4 @@ export const EditProfileScreen: React.FC = () => {
); );
}; };
export default EditProfileScreen; export default EditProfileScreen;

View File

@@ -1,7 +1,7 @@
/** /**
* 设置页 SettingsScreen(响应式适配) * 设置页 SettingsScreen - Twitter/X 风格
* 威友 - 应用设置 * 威友 - 应用设置
* 在宽屏下居中显示,最大宽度限制 * 采用扁平化设计,全宽布局,简洁分割线
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@@ -31,8 +31,7 @@ import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
// 设置卡片最大宽度 const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
const SETTINGS_CARD_MAX_WIDTH = 720;
interface SettingsItem { interface SettingsItem {
key: string; key: string;
@@ -44,12 +43,9 @@ interface SettingsItem {
subtitle?: string; subtitle?: string;
} }
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
const SETTINGS_GROUPS_BASE = [ const SETTINGS_GROUPS_BASE = [
{ {
title: '账号与安全', title: '账号与安全',
icon: 'shield-check-outline',
items: [ items: [
{ key: 'edit_profile', title: '编辑资料', icon: 'account-edit-outline', showArrow: true }, { key: 'edit_profile', title: '编辑资料', icon: 'account-edit-outline', showArrow: true },
{ key: 'verification', title: '身份认证', icon: 'check-decagram', showArrow: true, subtitle: '获得认证标识,解锁更多功能' }, { key: 'verification', title: '身份认证', icon: 'check-decagram', showArrow: true, subtitle: '获得认证标识,解锁更多功能' },
@@ -61,7 +57,6 @@ const SETTINGS_GROUPS_BASE = [
}, },
{ {
title: '通知与通用', title: '通知与通用',
icon: 'bell-outline',
items: [ items: [
{ key: 'notification_settings', title: '通知设置', icon: 'bell-cog-outline', showArrow: true, subtitle: '推送、震动、提示音' }, { key: 'notification_settings', title: '通知设置', icon: 'bell-cog-outline', showArrow: true, subtitle: '推送、震动、提示音' },
{ key: 'data_storage', title: '数据与存储', icon: 'database-outline', showArrow: true }, { key: 'data_storage', title: '数据与存储', icon: 'database-outline', showArrow: true },
@@ -69,7 +64,6 @@ const SETTINGS_GROUPS_BASE = [
}, },
{ {
title: '关于与帮助', title: '关于与帮助',
icon: 'information-outline',
items: [ items: [
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` }, { key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` },
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true }, { key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
@@ -81,39 +75,38 @@ function createSettingsStyles(colors: AppColors) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
}, },
scrollContent: { scrollContent: {
paddingVertical: spacing.lg, paddingVertical: spacing.sm,
}, },
groupContainer: { groupContainer: {
marginBottom: spacing['2xl'], marginBottom: spacing.xl,
maxWidth: SETTINGS_CARD_MAX_WIDTH, backgroundColor: colors.background.paper,
alignSelf: 'center', borderTopWidth: StyleSheet.hairlineWidth,
width: '100%', borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
}, },
groupHeader: { groupHeader: {
paddingHorizontal: spacing['2xl'], paddingHorizontal: spacing.lg,
marginBottom: spacing.sm, paddingVertical: spacing.sm,
marginTop: spacing.sm, backgroundColor: colors.background.default,
}, },
groupTitle: { groupTitle: {
fontWeight: '600', fontWeight: '600',
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: colors.text.secondary, color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
}, },
settingItem: { settingItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 16, paddingVertical: 14,
paddingHorizontal: spacing['2xl'], paddingHorizontal: spacing.lg,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
backgroundColor: colors.background.paper,
}, },
settingItemFirst: {},
settingItemLast: { settingItemLast: {
borderBottomWidth: 0, borderBottomWidth: 0,
}, },
@@ -123,13 +116,17 @@ function createSettingsStyles(colors: AppColors) {
flex: 1, flex: 1,
}, },
iconContainer: { iconContainer: {
width: 24, width: 32,
height: 24, height: 32,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: spacing.lg, marginRight: spacing.md,
},
dangerIconContainer: {
backgroundColor: colors.error.light + '20',
}, },
dangerIconContainer: {},
settingContent: { settingContent: {
flex: 1, flex: 1,
}, },
@@ -141,22 +138,21 @@ function createSettingsStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginTop: spacing.lg, marginTop: spacing.lg,
marginBottom: spacing['2xl'], marginBottom: spacing.xl,
paddingVertical: 16, paddingVertical: 14,
marginHorizontal: spacing['2xl'], marginHorizontal: spacing.lg,
borderRadius: 14, borderRadius: borderRadius.full,
gap: spacing.sm, gap: spacing.sm,
maxWidth: SETTINGS_CARD_MAX_WIDTH, backgroundColor: colors.background.paper,
alignSelf: 'center', borderWidth: 1,
width: '100%', borderColor: colors.divider,
backgroundColor: colors.error.light + '20',
}, },
logoutText: { logoutText: {
fontWeight: '600', fontWeight: '700',
}, },
footer: { footer: {
alignItems: 'center', alignItems: 'center',
marginTop: spacing.xl, marginTop: spacing.md,
paddingBottom: spacing.xl, paddingBottom: spacing.xl,
}, },
copyright: { copyright: {
@@ -170,7 +166,7 @@ export const SettingsScreen: React.FC = () => {
const { logout } = useAuthStore(); const { logout } = useAuthStore();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
const colors = useAppColors(); const colors = useAppColors();
const styles = useMemo(() => createSettingsStyles(colors), [colors]); const styles = useMemo(() => createSettingsStyles(colors), [colors]);
const themePreference = useThemePreference(); const themePreference = useThemePreference();
@@ -184,7 +180,6 @@ export const SettingsScreen: React.FC = () => {
return [ return [
{ {
title: '个性化', title: '个性化',
icon: 'palette-outline',
items: [ items: [
{ {
key: 'chat_settings', key: 'chat_settings',
@@ -307,16 +302,16 @@ export const SettingsScreen: React.FC = () => {
<View style={[styles.iconContainer, item.danger && styles.dangerIconContainer]}> <View style={[styles.iconContainer, item.danger && styles.dangerIconContainer]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name={item.icon as any} name={item.icon as any}
size={22} size={20}
color={item.danger ? colors.error.main : colors.text.secondary} color={item.danger ? colors.error.main : colors.text.secondary}
/> />
</View> </View>
<View style={styles.settingContent}> <View style={styles.settingContent}>
<Text variant="body" color={item.danger ? colors.error.main : colors.text.primary}> <Text variant="body" color={item.danger ? colors.error.main : colors.text.primary} style={{ fontWeight: '500' }}>
{item.title} {item.title}
</Text> </Text>
{item.subtitle && ( {item.subtitle && (
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}> <Text variant="caption" color={colors.text.hint} style={styles.subtitle}>
{item.subtitle} {item.subtitle}
</Text> </Text>
)} )}
@@ -329,13 +324,13 @@ export const SettingsScreen: React.FC = () => {
); );
const renderGroup = (group: (typeof settingsGroups)[0]) => ( const renderGroup = (group: (typeof settingsGroups)[0]) => (
<View key={group.title} style={styles.groupContainer}> <View key={group.title}>
<View style={styles.groupHeader}> <View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}> <Text variant="caption" style={styles.groupTitle}>
{group.title} {group.title}
</Text> </Text>
</View> </View>
<View> <View style={styles.groupContainer}>
{group.items.map((item, index) => renderSettingItem(item, index, group.items.length))} {group.items.map((item, index) => renderSettingItem(item, index, group.items.length))}
</View> </View>
</View> </View>
@@ -345,9 +340,9 @@ export const SettingsScreen: React.FC = () => {
<TouchableOpacity <TouchableOpacity
style={styles.logoutButton} style={styles.logoutButton}
onPress={() => handleItemPress('logout')} onPress={() => handleItemPress('logout')}
activeOpacity={0.7} activeOpacity={0.8}
> >
<MaterialCommunityIcons name="logout-variant" size={20} color={colors.error.main} /> <MaterialCommunityIcons name="logout-variant" size={18} color={colors.error.main} />
<Text variant="body" color={colors.error.main} style={styles.logoutText}> <Text variant="body" color={colors.error.main} style={styles.logoutText}>
退 退
</Text> </Text>
@@ -356,11 +351,8 @@ export const SettingsScreen: React.FC = () => {
const renderContent = () => ( const renderContent = () => (
<> <>
{/* 设置分组 */}
{settingsGroups.map((group) => renderGroup(group))} {settingsGroups.map((group) => renderGroup(group))}
{renderLogoutButton()} {renderLogoutButton()}
<View style={styles.footer}> <View style={styles.footer}>
<Text variant="caption" color={colors.text.hint}> <Text variant="caption" color={colors.text.hint}>
v{APP_VERSION} v{APP_VERSION}

View File

@@ -1,7 +1,7 @@
/** /**
* 统一的用户主页组件 * 统一的用户主页组件 - Twitter/X 风格
* 威友 - 支持两种模式self当前用户和 other其他用户 * 支持两种模式self当前用户和 other其他用户
* 采用现代卡片式设计,优化视觉层次和交互体验 * 采用 Twitter/X 扁平化设计,全宽布局,无边框卡片
* 支持桌面端双栏布局 * 支持桌面端双栏布局
*/ */
@@ -29,7 +29,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
const colors = useAppColors(); const colors = useAppColors();
const sharedStyles = useMemo(() => createSharedProfileStyles(colors), [colors]); const sharedStyles = useMemo(() => createSharedProfileStyles(colors), [colors]);
const { isDesktop, isTablet } = useResponsive(); const { isDesktop, isTablet } = useResponsive();
const { const {
user, user,
posts, posts,
@@ -45,14 +45,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
handleFollowingPress, handleFollowingPress,
handleFollowersPress, handleFollowersPress,
handleMessage, handleMessage,
handleMore, handleBlock,
isBlocked,
handleSettings, handleSettings,
handleEditProfile, handleEditProfile,
isCurrentUser, isCurrentUser,
currentUser, currentUser,
} = useUserProfile({ mode, userId, isDesktop, isTablet }); } = useUserProfile({ mode, userId, isDesktop, isTablet });
// 渲染帖子列表 // 渲染帖子列表 - Twitter 风格无边框
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => { const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => {
if (postList.length === 0) { if (postList.length === 0) {
return ( return (
@@ -64,7 +65,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
/> />
); );
} }
return ( return (
<View style={sharedStyles.postsContainer}> <View style={sharedStyles.postsContainer}>
{postList.map((post, index) => { {postList.map((post, index) => {
@@ -85,11 +86,11 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</View> </View>
); );
}, [currentUser?.id, handlePostAction, activeTab]); }, [currentUser?.id, handlePostAction, activeTab]);
// 渲染内容 // 渲染内容
const renderContent = useCallback(() => { const renderContent = useCallback(() => {
if (loading) return <Loading />; if (loading) return <Loading />;
if (activeTab === 0) { if (activeTab === 0) {
return renderPostList( return renderPostList(
posts, posts,
@@ -97,7 +98,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '' mode === 'self' ? '分享你的想法,发布第一条帖子吧' : ''
); );
} }
if (activeTab === 1) { if (activeTab === 1) {
return renderPostList( return renderPostList(
favorites, favorites,
@@ -105,29 +106,30 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : '' mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : ''
); );
} }
return null; return null;
}, [loading, activeTab, posts, favorites, mode, renderPostList]); }, [loading, activeTab, posts, favorites, mode, renderPostList]);
// 渲染用户信息头部 // 渲染用户信息头部
const renderUserHeader = useMemo(() => { const renderUserHeader = useMemo(() => {
if (!user) return null; if (!user) return null;
return ( return (
<UserProfileHeader <UserProfileHeader
user={user} user={user}
isCurrentUser={isCurrentUser} isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
onFollow={handleFollow} onFollow={handleFollow}
onSettings={handleSettings} onSettings={handleSettings}
onEditProfile={handleEditProfile} onEditProfile={handleEditProfile}
onMessage={handleMessage} onMessage={handleMessage}
onMore={handleMore} onBlock={handleBlock}
onFollowingPress={handleFollowingPress} onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress} onFollowersPress={handleFollowersPress}
/> />
); );
}, [user, isCurrentUser, handleFollow, handleSettings, handleEditProfile, handleMessage, handleMore, handleFollowingPress, handleFollowersPress]); }, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress]);
// 渲染 TabBar 和内容 // 渲染 TabBar 和内容
const renderTabBarAndContent = useMemo(() => ( const renderTabBarAndContent = useMemo(() => (
<> <>
@@ -145,7 +147,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</View> </View>
</> </>
), [activeTab, renderContent]); ), [activeTab, renderContent]);
// 未登录/用户不存在状态 // 未登录/用户不存在状态
if (mode === 'self' && !currentUser) { if (mode === 'self' && !currentUser) {
return ( return (
@@ -158,7 +160,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</SafeAreaView> </SafeAreaView>
); );
} }
if (mode === 'other' && !user && !loading) { if (mode === 'other' && !user && !loading) {
return ( return (
<SafeAreaView style={sharedStyles.container} edges={['bottom']}> <SafeAreaView style={sharedStyles.container} edges={['bottom']}>
@@ -170,14 +172,14 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</SafeAreaView> </SafeAreaView>
); );
} }
const safeAreaEdges = hasHeader ? ['bottom'] : (mode === 'self' ? ['top', 'bottom'] : ['bottom']); const safeAreaEdges = hasHeader ? ['bottom'] : (mode === 'self' ? ['top', 'bottom'] : ['bottom']);
// 桌面端使用双栏布局 // 桌面端使用双栏布局
if (isDesktop || isTablet) { if (isDesktop || isTablet) {
return ( return (
<SafeAreaView style={sharedStyles.container} edges={safeAreaEdges as any}> <SafeAreaView style={sharedStyles.container} edges={safeAreaEdges as any}>
<ResponsiveContainer maxWidth={1400}> <ResponsiveContainer maxWidth={1200}>
<View style={sharedStyles.desktopContainer}> <View style={sharedStyles.desktopContainer}>
{/* 左侧:用户信息 */} {/* 左侧:用户信息 */}
<View style={sharedStyles.desktopSidebar}> <View style={sharedStyles.desktopSidebar}>
@@ -196,7 +198,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
{renderUserHeader} {renderUserHeader}
</ScrollView> </ScrollView>
</View> </View>
{/* 右侧:帖子列表 */} {/* 右侧:帖子列表 */}
<View style={sharedStyles.desktopContent}> <View style={sharedStyles.desktopContent}>
<ScrollView <ScrollView
@@ -211,7 +213,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</SafeAreaView> </SafeAreaView>
); );
} }
// 移动端使用单栏布局 // 移动端使用单栏布局
return ( return (
<SafeAreaView style={sharedStyles.container} edges={safeAreaEdges as any}> <SafeAreaView style={sharedStyles.container} edges={safeAreaEdges as any}>
@@ -234,4 +236,4 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
); );
}; };
export default UserProfileScreen; export default UserProfileScreen;

View File

@@ -7,7 +7,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, type AppColors } from '../../theme'; import { spacing, borderRadius, type AppColors } from '../../theme';
import { Post, User } from '../../types'; import { Post, User } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/auth'; import { useCurrentUser } from '../../stores/auth';
@@ -54,7 +54,7 @@ export interface UseUserProfileReturn {
// 仅 other 模式 // 仅 other 模式
handleMessage?: () => Promise<void>; handleMessage?: () => Promise<void>;
handleMore?: () => void; handleBlock?: () => Promise<void>;
// 仅 self 模式 // 仅 self 模式
handleSettings?: () => void; handleSettings?: () => void;
@@ -356,53 +356,28 @@ case 'like':
} }
}, [user, router]); }, [user, router]);
// 更多操作(仅 other 模式) // 更多操作(仅 other 模式)- 由 UserProfileHeader 内部下拉菜单触发
const handleMore = useCallback(() => { const handleBlock = useCallback(async () => {
if (!user) return; if (!user) return;
const { Alert } = require('react-native'); const ok = isBlocked
Alert.alert( ? await authService.unblockUser(user.id)
'更多操作', : await authService.blockUser(user.id);
undefined, if (!ok) {
[ const { showDialog } = require('../../services/ui');
{ text: '取消', style: 'cancel' }, showDialog({
{ title: '失败',
text: isBlocked ? '取消拉黑' : '拉黑用户', message: isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试',
style: 'destructive', actions: [{ text: '确定' }],
onPress: () => { });
Alert.alert( return;
isBlocked ? '确认取消拉黑' : '确认拉黑', }
isBlocked setUser(prev =>
? '取消拉黑后,对方可以重新与你建立关系。' prev
: '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。', ? { ...prev, is_following: false, is_following_me: false }
[ : prev
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: async () => {
const ok = isBlocked
? await authService.unblockUser(user.id)
: await authService.blockUser(user.id);
if (!ok) {
Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试');
return;
}
setUser(prev =>
prev
? { ...prev, is_following: false, is_following_me: false }
: prev
);
setIsBlocked(!isBlocked);
Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户');
},
},
]
);
},
},
]
); );
setIsBlocked(!isBlocked);
}, [user, isBlocked]); }, [user, isBlocked]);
// 设置页跳转(仅 self 模式) // 设置页跳转(仅 self 模式)
@@ -444,7 +419,7 @@ case 'like':
handleFollowersPress, handleFollowersPress,
handlePostAction, handlePostAction,
handleMessage: mode === 'other' ? handleMessage : undefined, handleMessage: mode === 'other' ? handleMessage : undefined,
handleMore: mode === 'other' ? handleMore : undefined, handleBlock: mode === 'other' ? handleBlock : undefined,
handleSettings: mode === 'self' ? handleSettings : undefined, handleSettings: mode === 'self' ? handleSettings : undefined,
handleEditProfile: mode === 'self' ? handleEditProfile : undefined, handleEditProfile: mode === 'self' ? handleEditProfile : undefined,
isCurrentUser: mode === 'self', isCurrentUser: mode === 'self',
@@ -474,18 +449,21 @@ export function createSharedProfileStyles(colors: AppColors) {
desktopContent: { desktopContent: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
overflow: 'hidden',
}, },
desktopScrollContent: { desktopScrollContent: {
flexGrow: 1, flexGrow: 1,
}, },
tabBarContainer: { tabBarContainer: {
marginTop: spacing.xs, marginTop: 0,
marginBottom: 2, marginBottom: 0,
}, },
contentContainer: { contentContainer: {
flex: 1, flex: 1,
minHeight: 350, minHeight: 350,
paddingTop: spacing.xs, paddingTop: 0,
}, },
postsContainer: { postsContainer: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,

View File

@@ -1156,7 +1156,7 @@ function createScheduleStyles(colors: AppColors) {
height: 40, height: 40,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginLeft: spacing.sm, marginLeft: -8,
}, },
settingsButton: { settingsButton: {
width: 40, width: 40,

View File

@@ -51,14 +51,25 @@ TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
*/ */
async function syncMessages(): Promise<void> { async function syncMessages(): Promise<void> {
try { try {
// 同步未读数 // 同步会话未读数
const unreadData = await messageService.getUnreadCount(); const unreadData = await messageService.getUnreadCount();
const totalUnread = unreadData?.total_unread_count ?? 0; const totalUnread = unreadData?.total_unread_count ?? 0;
// 更新前台服务通知 // 同步系统消息未读数
await backgroundSyncManager.updateNotification(totalUnread); let systemUnread = 0;
try {
const sysUnreadData = await messageService.getSystemUnreadCount();
systemUnread = sysUnreadData?.unread_count ?? 0;
} catch {
// 忽略系统未读获取失败
}
console.log('[BackgroundService] 同步完成, 未读数:', totalUnread); const combinedTotal = totalUnread + systemUnread;
// 更新前台服务通知
await backgroundSyncManager.updateNotification(combinedTotal);
console.log('[BackgroundService] 同步完成, 会话未读:', totalUnread, '系统未读:', systemUnread);
} catch (error) { } catch (error) {
console.error('[BackgroundService] 同步失败:', error); console.error('[BackgroundService] 同步失败:', error);
} }

View File

@@ -172,6 +172,7 @@ export interface WSNotificationMessage {
category?: MessageCategory; category?: MessageCategory;
system_type?: SystemMessageType; system_type?: SystemMessageType;
extra_data?: SystemMessageExtraData; extra_data?: SystemMessageExtraData;
is_read?: boolean;
created_at: string; created_at: string;
} }
@@ -614,6 +615,12 @@ class WebSocketService {
type: 'notification', type: 'notification',
id: String(payload.id ?? ''), id: String(payload.id ?? ''),
content: payload.content || '', content: payload.content || '',
sender_id: payload.sender_id,
receiver_id: payload.receiver_id,
category: payload.category,
system_type: payload.system_type,
extra_data: payload.extra_data,
is_read: !!payload.is_read,
created_at: payload.created_at || new Date().toISOString(), created_at: payload.created_at || new Date().toISOString(),
}; };
this.emit('notification', m); this.emit('notification', m);

View File

@@ -54,6 +54,8 @@ class SystemNotificationService {
private isInitialized: boolean = false; private isInitialized: boolean = false;
private appStateSubscription: any = null; private appStateSubscription: any = null;
private currentAppState: AppStateStatus = 'active'; private currentAppState: AppStateStatus = 'active';
private shownNotificationIds: Set<string> = new Set();
private static readonly MAX_DEDUP_SIZE = 500;
async initialize(): Promise<boolean> { async initialize(): Promise<boolean> {
if (this.isInitialized) { if (this.isInitialized) {
@@ -174,6 +176,32 @@ class SystemNotificationService {
if (!getNotificationPreferencesSync().pushEnabled) { if (!getNotificationPreferencesSync().pushEnabled) {
return; return;
} }
const msgId = String(message.id || '');
if (msgId && this.shownNotificationIds.has(msgId)) {
return;
}
if ('is_read' in message && (message as any).is_read) {
return;
}
if (msgId) {
this.shownNotificationIds.add(msgId);
if (this.shownNotificationIds.size > SystemNotificationService.MAX_DEDUP_SIZE) {
const toRemove: string[] = [];
let count = 0;
for (const id of this.shownNotificationIds) {
if (count >= SystemNotificationService.MAX_DEDUP_SIZE / 2) break;
toRemove.push(id);
count++;
}
for (const id of toRemove) {
this.shownNotificationIds.delete(id);
}
}
}
// 仅在后台时显示通知,前台时不显示(用户正在使用应用,可以直接看到消息) // 仅在后台时显示通知,前台时不显示(用户正在使用应用,可以直接看到消息)
if (this.currentAppState !== 'active') { if (this.currentAppState !== 'active') {
// 判断是否是聊天消息(通过 segments 字段) // 判断是否是聊天消息(通过 segments 字段)

View File

@@ -376,6 +376,18 @@ export class MessageSyncService implements IMessageSyncService {
store.setUnreadCount(totalUnread, systemUnread); store.setUnreadCount(totalUnread, systemUnread);
// 获取最后一条系统通知的时间
if (systemUnread > 0) {
try {
const latestSystemMsg = await messageService.getSystemMessagesCursor({ cursor: '', page_size: 1 });
if (latestSystemMsg?.list?.[0]?.created_at) {
store.setLastSystemMessageAt(latestSystemMsg.list[0].created_at);
}
} catch {
// 获取最后系统通知时间失败,不影响主流程
}
}
// 服务端汇总未读为 0 时,清掉内存中残留的红点 // 服务端汇总未读为 0 时,清掉内存中残留的红点
if (totalUnread === 0) { if (totalUnread === 0) {
const currentConversations = store.getState().conversations; const currentConversations = store.getState().conversations;

View File

@@ -16,6 +16,7 @@ import type {
WSGroupRecallMessage, WSGroupRecallMessage,
WSGroupTypingMessage, WSGroupTypingMessage,
WSGroupNoticeMessage, WSGroupNoticeMessage,
WSNotificationMessage,
} from '@/services/core'; } from '@/services/core';
import { wsService, GroupNoticeType } from '@/services/core'; import { wsService, GroupNoticeType } from '@/services/core';
import { eventBus } from '@/core/events/EventBus'; import { eventBus } from '@/core/events/EventBus';
@@ -128,6 +129,20 @@ export class WSMessageHandler implements IWSMessageHandler {
this.handleGroupNotice(message); this.handleGroupNotice(message);
}); });
// 监听系统通知,实时更新系统未读数
wsService.on('notification', (message: WSNotificationMessage) => {
if (!message.is_read) {
this.incrementSystemUnread();
}
if (message.created_at) {
const store = useMessageStore.getState();
const currentLast = store.lastSystemMessageAt;
if (!currentLast || new Date(message.created_at) > new Date(currentLast)) {
store.setLastSystemMessageAt(message.created_at);
}
}
});
// 监听连接状态 // 监听连接状态
wsService.onConnect(() => { wsService.onConnect(() => {
useMessageStore.getState().setSSEConnected(true); useMessageStore.getState().setSSEConnected(true);
@@ -484,6 +499,12 @@ export class WSMessageHandler implements IWSMessageHandler {
} }
} }
private incrementSystemUnread(): void {
const store = useMessageStore.getState();
const currentUnread = store.getUnreadCount();
store.setUnreadCount(currentUnread.total + 1, currentUnread.system + 1);
}
private markMessageAsRecalled(conversationId: string, messageId: string): void { private markMessageAsRecalled(conversationId: string, messageId: string): void {
const store = useMessageStore.getState(); const store = useMessageStore.getState();
const messages = store.getMessages(conversationId); const messages = store.getMessages(conversationId);

View File

@@ -31,6 +31,9 @@ export interface MessageState {
totalUnreadCount: number; totalUnreadCount: number;
systemUnreadCount: number; systemUnreadCount: number;
// 最后一条系统通知时间
lastSystemMessageAt: string | null;
// 连接状态 // 连接状态
isWSConnected: boolean; isWSConnected: boolean;
@@ -75,6 +78,7 @@ export interface MessageActions {
addMessage: (conversationId: string, message: MessageResponse) => void; addMessage: (conversationId: string, message: MessageResponse) => void;
updateMessage: (conversationId: string, messageId: string, updates: Partial<MessageResponse>) => void; updateMessage: (conversationId: string, messageId: string, updates: Partial<MessageResponse>) => void;
setUnreadCount: (total: number, system: number) => void; setUnreadCount: (total: number, system: number) => void;
setLastSystemMessageAt: (time: string | null) => void;
setSSEConnected: (connected: boolean) => void; setSSEConnected: (connected: boolean) => void;
setCurrentConversation: (conversationId: string | null) => void; setCurrentConversation: (conversationId: string | null) => void;
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
@@ -95,6 +99,7 @@ const initialState: MessageState = {
messagesMap: new Map(), messagesMap: new Map(),
totalUnreadCount: 0, totalUnreadCount: 0,
systemUnreadCount: 0, systemUnreadCount: 0,
lastSystemMessageAt: null,
isWSConnected: false, isWSConnected: false,
currentConversationId: null, currentConversationId: null,
isLoadingConversations: false, isLoadingConversations: false,
@@ -160,6 +165,7 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
messagesMap: state.messagesMap, messagesMap: state.messagesMap,
totalUnreadCount: state.totalUnreadCount, totalUnreadCount: state.totalUnreadCount,
systemUnreadCount: state.systemUnreadCount, systemUnreadCount: state.systemUnreadCount,
lastSystemMessageAt: state.lastSystemMessageAt,
isWSConnected: state.isWSConnected, isWSConnected: state.isWSConnected,
currentConversationId: state.currentConversationId, currentConversationId: state.currentConversationId,
isLoadingConversations: state.isLoadingConversations, isLoadingConversations: state.isLoadingConversations,
@@ -314,6 +320,10 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
set({ totalUnreadCount: total, systemUnreadCount: system }); set({ totalUnreadCount: total, systemUnreadCount: system });
}, },
setLastSystemMessageAt: (time: string | null) => {
set({ lastSystemMessageAt: time });
},
setSSEConnected: (connected: boolean) => { setSSEConnected: (connected: boolean) => {
set({ isWSConnected: connected }); set({ isWSConnected: connected });
}, },