feat(ui): unify design system to Twitter/X style across screens and improve message handling
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:
@@ -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 handleMorePress = useCallback(() => {
|
||||||
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
|
moreButtonRef.current?.measure((_fx, _fy, _w, h, px, py) => {
|
||||||
|
const screenWidth = Dimensions.get('window').width;
|
||||||
|
const menuWidth = 180;
|
||||||
|
let x = px + _w / 2 - menuWidth / 2;
|
||||||
|
if (x + menuWidth > screenWidth - 12) {
|
||||||
|
x = screenWidth - menuWidth - 12;
|
||||||
|
}
|
||||||
|
if (x < 12) x = 12;
|
||||||
|
setMenuPosition({ x, y: py + h + 4 });
|
||||||
|
setMenuVisible(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderStatItem = ({
|
const handleBlockPress = useCallback(() => {
|
||||||
value,
|
setMenuVisible(false);
|
||||||
label,
|
onBlock?.();
|
||||||
onPress,
|
}, [onBlock]);
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
onPress?: () => void;
|
|
||||||
}) => {
|
|
||||||
const content = (
|
|
||||||
<View style={styles.statContent}>
|
|
||||||
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onPress) {
|
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 && (
|
<View style={styles.topBar}>
|
||||||
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
|
{isCurrentUser && onSettings && (
|
||||||
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
|
<TouchableOpacity style={styles.iconButton} onPress={onSettings}>
|
||||||
</TouchableOpacity>
|
<MaterialCommunityIcons name="cog-outline" size={20} color={colors.text.primary} />
|
||||||
)}
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
{/* 装饰性波浪 */}
|
|
||||||
<View style={styles.waveDecoration}>
|
|
||||||
<View style={styles.wave} />
|
|
||||||
</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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -690,6 +690,7 @@ function createGroupMembersStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
pageHeaderPlaceholder: {
|
pageHeaderPlaceholder: {
|
||||||
width: 40,
|
width: 40,
|
||||||
|
height: 40,
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 统一的用户主页组件
|
* 统一的用户主页组件 - Twitter/X 风格
|
||||||
* 威友 - 支持两种模式:self(当前用户)和 other(其他用户)
|
* 支持两种模式:self(当前用户)和 other(其他用户)
|
||||||
* 采用现代卡片式设计,优化视觉层次和交互体验
|
* 采用 Twitter/X 扁平化设计,全宽布局,无边框卡片
|
||||||
* 支持桌面端双栏布局
|
* 支持桌面端双栏布局
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -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 (
|
||||||
@@ -117,16 +118,17 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
<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(() => (
|
||||||
@@ -177,7 +179,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 字段)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user