feat(ui): 实现原生标签页与玻璃效果按钮组件

引入 NativeTabs 替代默认 Tabs 以支持原生标签栏样式,并添加 GlassButton 组件实现毛玻璃效果按钮。
移除对 useBottomTabBarHeight 的依赖,统一使用固定底部间距 60。
重构头像上传逻辑,使用新的 uploadImage API 替代 COS 直传方案。
更新 expo-router 至 ~6.0.1 版本以支持不稳定特性。
This commit is contained in:
richarjiang
2025-09-12 15:48:58 +08:00
parent a84c026599
commit 1b76cc305a
9 changed files with 153 additions and 161 deletions

View File

@@ -2,6 +2,8 @@ import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Tabs, usePathname } from 'expo-router'; import { Tabs, usePathname } from 'expo-router';
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react'; import React from 'react';
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native'; import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
@@ -167,6 +169,23 @@ export default function TabLayout() {
tabBarShowLabel: false, tabBarShowLabel: false,
}); });
if (glassEffectAvailable) {
return <NativeTabs>
<NativeTabs.Trigger name="statistics">
<Label></Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="goals">
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
<Label></Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="personal">
<Icon sf="person.fill" drawable="custom_settings_drawable" />
<Label></Label>
</NativeTabs.Trigger>
</NativeTabs>
}
return ( return (
<Tabs <Tabs
initialRouteName="statistics" initialRouteName="statistics"

View File

@@ -9,7 +9,7 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
import { log } from '@/utils/logger'; import { log } from '@/utils/logger';
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences'; import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@@ -23,7 +23,7 @@ export default function PersonalScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard(); const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
// 推送通知相关 // 推送通知相关
const { const {
@@ -40,8 +40,8 @@ export default function PersonalScreen() {
// 计算底部间距 // 计算底部间距
const bottomPadding = useMemo(() => { const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]); }, [insets?.bottom]);
// 直接使用 Redux 中的用户信息,避免重复状态管理 // 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile); const userProfile = useAppSelector((state) => state.user.profile);

View File

@@ -10,7 +10,6 @@ import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard'; import WaterIntakeCard from '@/components/WaterIntakeCard';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard'; import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
@@ -22,7 +21,6 @@ import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health'; import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData'; import { getTestHealthData } from '@/utils/mockHealthData';
import { calculateNutritionGoals } from '@/utils/nutrition'; import { calculateNutritionGoals } from '@/utils/nutrition';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@@ -72,12 +70,8 @@ export default function ExploreScreen() {
// 使用 dayjs当月日期与默认选中"今天" // 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const tabBarHeight = useBottomTabBarHeight(); // const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算 // 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
const currentSelectedDate = useMemo(() => { const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh(); const days = getMonthDaysZh();
@@ -466,7 +460,7 @@ export default function ExploreScreen() {
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={{ contentContainerStyle={{
paddingTop: insets.top, paddingTop: insets.top,
paddingBottom: bottomPadding, paddingBottom: 60,
paddingHorizontal: 20 paddingHorizontal: 20
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}

View File

@@ -40,7 +40,6 @@ interface UserProfile {
weight?: number; // kg weight?: number; // kg
height?: number; // cm height?: number; // cm
avatarUri?: string | null; avatarUri?: string | null;
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
activityLevel?: number; // 活动水平 1-4 activityLevel?: number; // 活动水平 1-4
maxHeartRate?: number; // 最大心率 maxHeartRate?: number; // 最大心率
} }
@@ -270,7 +269,13 @@ export default function EditProfileScreen() {
{ prefix: 'avatars/', userId } { prefix: 'avatars/', userId }
); );
setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null })); console.log('url', url);
setProfile((p) => ({ ...p, avatarUri: url }));
// 保存更新后的 profile
await handleSaveWithProfile({ ...profile, avatarUri: url });
Alert.alert('成功', '头像更新成功');
} catch (e) { } catch (e) {
console.warn('上传头像失败', e); console.warn('上传头像失败', e);
Alert.alert('上传失败', '头像上传失败,请重试'); Alert.alert('上传失败', '头像上传失败,请重试');

View File

@@ -0,0 +1,66 @@
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import React from 'react';
import { GestureResponderEvent, StyleSheet, Text, TouchableOpacity } from 'react-native';
interface GlassButtonProps {
title: string;
onPress: (event: GestureResponderEvent) => void;
style?: object;
glassStyle?: 'regular' | 'clear';
tintColor?: string;
}
export default function GlassButton({
title,
onPress,
style = {},
glassStyle = 'regular',
tintColor = 'rgba(255, 255, 255, 0.3)',
}: GlassButtonProps) {
const available = isLiquidGlassAvailable();
if (available) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<GlassView
style={[styles.button, style]}
glassEffectStyle={glassStyle}
tintColor={tintColor}
isInteractive={true} // 如果需要点击反馈
>
<Text style={styles.buttonText}>{title}</Text>
</GlassView>
</TouchableOpacity>
);
} else {
// fallback
return (
<TouchableOpacity
onPress={onPress}
style={[styles.button, style, styles.fallbackBackground]}
activeOpacity={0.7}
>
<Text style={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
}
}
const styles = StyleSheet.create({
button: {
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // 保证玻璃边界圆角效果
},
fallbackBackground: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
buttonText: {
fontSize: 16,
color: '#000',
fontWeight: '600',
},
});

View File

@@ -42,7 +42,7 @@
"expo-linking": "~8.0.7", "expo-linking": "~8.0.7",
"expo-notifications": "~0.32.10", "expo-notifications": "~0.32.10",
"expo-quick-actions": "^5.0.0", "expo-quick-actions": "^5.0.0",
"expo-router": "~6.0.0", "expo-router": "~6.0.1",
"expo-splash-screen": "~31.0.8", "expo-splash-screen": "~31.0.8",
"expo-status-bar": "~3.0.7", "expo-status-bar": "~3.0.7",
"expo-symbols": "~1.0.6", "expo-symbols": "~1.0.6",
@@ -81,4 +81,4 @@
"typescript": "~5.9.2" "typescript": "~5.9.2"
}, },
"private": true "private": true
} }

View File

@@ -18,6 +18,7 @@ export type ApiRequestOptions = {
headers?: Record<string, string>; headers?: Record<string, string>;
body?: any; body?: any;
signal?: AbortSignal; signal?: AbortSignal;
unsetContentType?: boolean;
}; };
export type ApiResponse<T> = { export type ApiResponse<T> = {
@@ -27,10 +28,13 @@ export type ApiResponse<T> = {
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> { async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const url = buildApiUrl(path); const url = buildApiUrl(path);
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers || {}), ...(options.headers || {}),
}; };
if (!options.unsetContentType) {
headers['Content-Type'] = 'application/json';
}
const token = await getAuthToken(); const token = await getAuthToken();
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
@@ -39,7 +43,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
const response = await fetch(url, { const response = await fetch(url, {
method: options.method ?? 'GET', method: options.method ?? 'GET',
headers, headers,
body: options.body != null ? JSON.stringify(options.body) : undefined, body: options.body != null ? options.unsetContentType ? options.body : JSON.stringify(options.body) : undefined,
signal: options.signal, signal: options.signal,
}); });

View File

@@ -1,31 +1,5 @@
import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos'; import { uploadImage } from '@/services/users';
import { api } from '@/services/api';
type ServerCosToken = {
tmpSecretId: string;
tmpSecretKey: string;
sessionToken: string;
startTime?: number;
expiredTime?: number;
bucket?: string;
region?: string;
prefix?: string;
cdnDomain?: string;
};
type CosCredential = {
credentials: {
tmpSecretId: string;
tmpSecretKey: string;
sessionToken: string;
};
startTime?: number;
expiredTime?: number;
bucket?: string;
region?: string;
prefix?: string;
cdnDomain?: string;
};
type UploadOptions = { type UploadOptions = {
key: string; key: string;
@@ -38,132 +12,56 @@ type UploadOptions = {
signal?: AbortSignal; signal?: AbortSignal;
}; };
let rnTransferManager: any | null = null;
let rnInitialized = false;
async function fetchCredential(): Promise<CosCredential> {
// 后端返回 { code, message, data }api.get 会提取 data
const data = await api.get<ServerCosToken>('/api/users/cos/upload-token');
console.log('fetchCredential', data);
return {
credentials: {
tmpSecretId: data.tmpSecretId,
tmpSecretKey: data.tmpSecretKey,
sessionToken: data.sessionToken,
},
startTime: data.startTime,
expiredTime: data.expiredTime,
bucket: data.bucket,
region: data.region,
prefix: data.prefix,
cdnDomain: data.cdnDomain,
};
}
export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; publicUrl?: string }> { export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; publicUrl?: string }> {
const { key, srcUri, contentType, onProgress, signal } = options; const { key, srcUri, contentType, signal } = options;
const cred = await fetchCredential();
const bucket = COS_BUCKET || cred.bucket;
const region = COS_REGION || cred.region;
if (!bucket || !region) {
throw new Error('未配置 COS_BUCKET / COS_REGION且服务端未返回 bucket/region');
}
// 确保对象键以服务端授权的前缀开头(去除通配符,折叠斜杠,避免把 * 拼进 Key
const finalKey = ((): string => {
const rawPrefix = String(cred.prefix || '');
// 1) 去掉 * 与多余斜杠,再去掉首尾斜杠
const safePrefix = rawPrefix
.replace(/\*/g, '')
.replace(/\/{2,}/g, '/')
.replace(/^\/+|\/+$/g, '');
const normalizedKey = key.replace(/^\//, '');
if (!safePrefix) return normalizedKey;
if (normalizedKey.startsWith(safePrefix + '/')) return normalizedKey;
return `${safePrefix}/${normalizedKey}`.replace(/\/{2,}/g, '/');
})();
// 初始化 react-native-cos-sdk一次
if (!rnInitialized) {
// await Cos.initWithSessionCredentialCallback(async () => {
// // SDK 会在需要时调用该回调,我们返回当前的临时密钥
// return {
// tmpSecretId: cred.credentials.tmpSecretId,
// tmpSecretKey: cred.credentials.tmpSecretKey,
// sessionToken: cred.credentials.sessionToken,
// startTime: cred.startTime,
// expiredTime: cred.expiredTime,
// } as any;
// });
// const serviceConfig = { region, isDebuggable: true, isHttps: true } as any;
// await Cos.registerDefaultService(serviceConfig);
// const transferConfig = {
// forceSimpleUpload: false,
// enableVerification: true,
// divisionForUpload: 2 * 1024 * 1024,
// sliceSizeForUpload: 1 * 1024 * 1024,
// } as any;
// rnTransferManager = await Cos.registerDefaultTransferManger(serviceConfig, transferConfig);
rnInitialized = true;
}
if (!srcUri || typeof srcUri !== 'string') { if (!srcUri || typeof srcUri !== 'string') {
throw new Error('请提供本地文件路径 srcUri形如 file:/// 或 content://'); throw new Error('请提供本地文件路径 srcUri形如 file:/// 或 content://');
} }
const controller = new AbortController(); if (signal?.aborted) {
if (signal) { throw new DOMException('Aborted', 'AbortError');
if (signal.aborted) controller.abort();
signal.addEventListener('abort', () => controller.abort(), { once: true });
} }
return await new Promise((resolve, reject) => { try {
let cancelled = false; // 创建 FormData 用于文件上传
let taskRef: any = null; const formData = new FormData();
(async () => {
try {
taskRef = await rnTransferManager.upload(
bucket,
finalKey,
srcUri,
{
resultListener: {
successCallBack: (header: any) => {
if (cancelled) return;
const publicUrl = buildPublicUrl(finalKey);
const etag = header?.ETag || header?.headers?.ETag || header?.headers?.etag;
resolve({ key: finalKey, etag, headers: header, publicUrl });
},
failCallBack: (clientError: any, serviceError: any) => {
if (cancelled) return;
console.log('uploadToCos', { clientError, serviceError });
const err = clientError || serviceError || new Error('COS 上传失败');
reject(err);
},
},
progressCallback: (complete: number, target: number) => {
if (onProgress) {
const percent = target > 0 ? complete / target : 0;
onProgress({ percent });
}
},
contentType,
}
);
} catch (e) {
if (!cancelled) reject(e);
}
})();
controller.signal.addEventListener('abort', () => { // 从 srcUri 创建文件对象
cancelled = true; let fileBlob;
try { taskRef?.cancel?.(); } catch { } if (srcUri.startsWith('file://') || srcUri.startsWith('content://')) {
reject(new DOMException('Aborted', 'AbortError')); // React Native 环境,使用 fetch 读取本地文件
}); const response = await fetch(srcUri);
}); fileBlob = await response.blob();
} else {
throw new Error('不支持的文件路径格式,请使用 file:// 或 content:// 格式');
}
formData.append('file', {
uri: srcUri,
type: "image/jpeg",
name: "upload.jpg",
} as any);
console.log('formData', formData)
// 使用新的上传接口
const result = await uploadImage(formData);
console.log('result', result);
return {
key: result.fileKey,
publicUrl: result.fileUrl,
};
} catch (error: any) {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
console.log('uploadToCos error:', error);
throw error;
}
} }
export async function uploadWithRetry(options: UploadOptions & { maxRetries?: number; backoffMs?: number }): Promise<{ key: string; etag?: string }> { export async function uploadWithRetry(options: UploadOptions & { maxRetries?: number; backoffMs?: number }): Promise<{ key: string; etag?: string }> {

View File

@@ -22,4 +22,10 @@ export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any
return await api.put('/api/users/update', dto); return await api.put('/api/users/update', dto);
} }
export async function uploadImage(formData: FormData): Promise<{ fileKey: string; fileUrl: string }> {
return await api.post('/api/users/cos/upload-image', formData, {
unsetContentType: true
});
}