Files
frontend/src/screens/profile/SettingsScreen.tsx
lafay 2bad59afbb
All checks were successful
Frontend CI / ota (ios) (push) Successful in 2m46s
Frontend CI / ota (android) (push) Successful in 2m50s
Frontend CI / build-and-push-web (push) Successful in 3m54s
Frontend CI / build-android-apk (push) Successful in 40m29s
feat(profile): add help screen and fix upload field name
Add dedicated Help screen to profile tab with navigation integration.
Refactor SettingsScreen to navigate to HelpScreen instead of showing placeholder alert.
Fix API upload method to use correct field names ("file" for documents, "image" for media)
based on endpoint path, resolving upload failures for file uploads.
2026-06-22 07:56:06 +08:00

465 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 设置页 SettingsScreen - Twitter/X 风格
* 威友 - 应用设置
* 采用扁平化设计,全宽布局,简洁分割线
*/
import React, { useMemo, useState } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
Alert,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import {
useAppColors,
spacing,
fontSizes,
borderRadius,
useThemePreference,
useSetThemePreference,
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores';
import { Text, SimpleHeader } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as hrefs from '../../navigation/hrefs';
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
const THEME_OPTIONS: { value: 'system' | 'light' | 'dark'; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
];
interface SettingsItem {
key: string;
title: string;
icon: string;
onPress?: () => void;
showArrow?: boolean;
danger?: boolean;
subtitle?: string;
isThemePicker?: boolean;
}
const SETTINGS_GROUPS_BASE = [
{
title: '账号与安全',
items: [
{ key: 'edit_profile', title: '编辑资料', icon: 'account-edit-outline', showArrow: true },
{ key: 'verification', title: '身份认证', icon: 'check-decagram', showArrow: true, subtitle: '获得认证标识,解锁更多功能' },
{ key: 'privacy', title: '隐私设置', icon: 'shield-account-outline', showArrow: true },
{ key: 'security', title: '账号安全', icon: 'lock-outline', showArrow: true },
],
},
{
title: '通知与通用',
items: [
{ key: 'notification_settings', title: '通知设置', icon: 'bell-cog-outline', showArrow: true, subtitle: '推送、震动、提示音' },
{ key: 'data_storage', title: '数据与存储', icon: 'database-outline', showArrow: true },
],
},
{
title: '关于与帮助',
items: [
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` },
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
],
},
];
function createSettingsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.sm,
},
groupContainer: {
marginBottom: spacing.xl,
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
overflow: 'visible',
zIndex: 1,
},
groupHeader: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.background.default,
},
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.secondary,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: spacing.lg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
backgroundColor: colors.background.paper,
},
settingItemLast: {
borderBottomWidth: 0,
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 32,
height: 32,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
backgroundColor: colors.error.light + '20',
},
settingContent: {
flex: 1,
},
subtitle: {
marginTop: 2,
},
// 无背景色的选择框,紧贴右侧
selectBox: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs,
},
selectBoxText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
marginRight: spacing.xs,
},
// 原位展开的下拉菜单
dropdownMenu: {
position: 'absolute',
right: 0,
top: '100%',
marginTop: 4,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
zIndex: 100,
minWidth: 120,
overflow: 'hidden',
},
dropdownOption: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
dropdownOptionLast: {
borderBottomWidth: 0,
},
dropdownOptionText: {
fontSize: fontSizes.sm,
color: colors.text.primary,
},
dropdownOptionTextActive: {
color: colors.primary.main,
fontWeight: '600',
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.lg,
marginBottom: spacing.xl,
paddingVertical: 14,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.full,
gap: spacing.sm,
backgroundColor: colors.background.paper,
borderWidth: 1,
borderColor: colors.divider,
},
logoutText: {
fontWeight: '700',
},
footer: {
alignItems: 'center',
marginTop: spacing.md,
paddingBottom: spacing.xl,
},
copyright: {
marginTop: spacing.xs,
},
});
}
export const SettingsScreen: React.FC = () => {
const router = useRouter();
const logout = useAuthStore((s) => s.logout);
const insets = useSafeAreaInsets();
const { isMobile } = useResponsive();
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();
const setThemePreference = useSetThemePreference();
const [openPicker, setOpenPicker] = useState<string | null>(null);
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const settingsGroups = useMemo(() => {
return [
{
title: '个性化',
items: [
{
key: 'chat_settings',
title: '聊天设置',
icon: 'message-text-outline',
showArrow: true,
subtitle: '字号、主题、壁纸',
},
{
key: 'theme',
title: '主题模式',
icon: 'theme-light-dark',
isThemePicker: true,
},
],
},
...SETTINGS_GROUPS_BASE,
];
}, [themePreference]);
const handleItemPress = (key: string) => {
switch (key) {
case 'chat_settings':
router.push(hrefs.hrefProfileChatSettings());
break;
case 'theme': {
setOpenPicker(openPicker === 'theme' ? null : 'theme');
break;
}
case 'data_storage':
router.push(hrefs.hrefProfileDataStorage());
break;
case 'about':
router.push(hrefs.hrefProfileAbout());
break;
case 'help':
router.push(hrefs.hrefProfileHelp());
break;
case 'edit_profile':
router.push(hrefs.hrefProfileEdit());
break;
case 'verification':
router.push(hrefs.hrefProfileVerification());
break;
case 'notification_settings':
router.push(hrefs.hrefProfileNotifications());
break;
case 'blocked_users':
router.push(hrefs.hrefProfileBlocked());
break;
case 'security':
router.push(hrefs.hrefProfileSecurity());
break;
case 'privacy':
router.push(hrefs.hrefProfilePrivacySettings());
break;
case 'account_deletion':
router.push(hrefs.hrefProfileDeletion());
break;
case 'logout':
Alert.alert(
'退出登录',
'确定要退出登录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: async () => {
await logout();
router.replace(hrefs.hrefAuthLogin());
},
},
]
);
break;
default:
break;
}
};
const getThemeLabel = (value: string) =>
THEME_OPTIONS.find((o) => o.value === value)?.label ?? '跟随系统';
const renderSettingItem = (item: SettingsItem, index: number, total: number) => {
const isThemeOpen = item.isThemePicker && openPicker === 'theme';
const themeLabel = item.isThemePicker ? getThemeLabel(themePreference) : undefined;
return (
<TouchableOpacity
key={item.key}
style={[
styles.settingItem,
index === total - 1 && !isThemeOpen && styles.settingItemLast,
]}
onPress={() => (item.onPress ? item.onPress() : handleItemPress(item.key))}
activeOpacity={0.7}
>
<View style={styles.settingItemLeft}>
<View style={[styles.iconContainer, item.danger && styles.dangerIconContainer]}>
<MaterialCommunityIcons
name={item.icon as any}
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} style={{ fontWeight: '500' }}>
{item.title}
</Text>
{item.subtitle && (
<Text variant="caption" color={colors.text.hint} style={styles.subtitle}>
{item.subtitle}
</Text>
)}
</View>
</View>
{item.isThemePicker ? (
<View style={{ position: 'relative' }}>
<View style={styles.selectBox}>
<Text style={styles.selectBoxText}>{themeLabel}</Text>
<MaterialCommunityIcons
name={isThemeOpen ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.text.secondary}
/>
</View>
{isThemeOpen && (
<View style={styles.dropdownMenu}>
{THEME_OPTIONS.map((option, optIndex) => {
const isActive = themePreference === option.value;
return (
<TouchableOpacity
key={option.value}
style={[
styles.dropdownOption,
optIndex === THEME_OPTIONS.length - 1 && styles.dropdownOptionLast,
]}
onPress={() => {
void setThemePreference(option.value);
setOpenPicker(null);
}}
activeOpacity={0.7}
>
<Text
variant="body"
style={[
styles.dropdownOptionText,
isActive ? styles.dropdownOptionTextActive : undefined,
]}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
) : (
item.showArrow && (
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
)
)}
</TouchableOpacity>
);
};
const renderGroup = (group: (typeof settingsGroups)[0], groupIndex: number) => (
<View
key={group.title}
style={[
{ overflow: 'visible', zIndex: group.items.some((i) => 'isThemePicker' in i && i.isThemePicker && openPicker === 'theme') ? 10 : groupIndex },
]}
>
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
{group.title}
</Text>
</View>
<View style={styles.groupContainer}>
{group.items.map((item, index) => renderSettingItem(item, index, group.items.length))}
</View>
</View>
);
const renderLogoutButton = () => (
<TouchableOpacity
style={styles.logoutButton}
onPress={() => handleItemPress('logout')}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="logout-variant" size={18} color={colors.error.main} />
<Text variant="body" color={colors.error.main} style={styles.logoutText}>
退
</Text>
</TouchableOpacity>
);
const renderContent = () => (
<>
{settingsGroups.map((group, index) => renderGroup(group, index))}
{renderLogoutButton()}
<View style={styles.footer}>
<Text variant="caption" color={colors.text.hint}>
v{APP_VERSION}
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
© 2026
</Text>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="auto" />
<SimpleHeader title="设置" onBack={() => router.back()} />
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{renderContent()}
</ScrollView>
</SafeAreaView>
);
};
export default SettingsScreen;