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

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

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

View File

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

View File

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

View File

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

View File

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