feat(ui): 实现原生标签页与玻璃效果按钮组件
引入 NativeTabs 替代默认 Tabs 以支持原生标签栏样式,并添加 GlassButton 组件实现毛玻璃效果按钮。 移除对 useBottomTabBarHeight 的依赖,统一使用固定底部间距 60。 重构头像上传逻辑,使用新的 uploadImage API 替代 COS 直传方案。 更新 expo-router 至 ~6.0.1 版本以支持不稳定特性。
This commit is contained in:
@@ -2,6 +2,8 @@ import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
|
||||
import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||
|
||||
@@ -167,6 +169,23 @@ export default function TabLayout() {
|
||||
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 (
|
||||
<Tabs
|
||||
initialRouteName="statistics"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
|
||||
import { log } from '@/utils/logger';
|
||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
@@ -23,7 +23,7 @@ export default function PersonalScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
|
||||
|
||||
// 推送通知相关
|
||||
const {
|
||||
@@ -40,8 +40,8 @@ export default function PersonalScreen() {
|
||||
|
||||
// 计算底部间距
|
||||
const bottomPadding = useMemo(() => {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||
}, [insets?.bottom]);
|
||||
|
||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
@@ -22,7 +21,6 @@ import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -72,12 +70,8 @@ export default function ExploreScreen() {
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
// const tabBarHeight = useBottomTabBarHeight();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomPadding = useMemo(() => {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
|
||||
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
const days = getMonthDaysZh();
|
||||
@@ -466,7 +460,7 @@ export default function ExploreScreen() {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: bottomPadding,
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -40,7 +40,6 @@ interface UserProfile {
|
||||
weight?: number; // kg
|
||||
height?: number; // cm
|
||||
avatarUri?: string | null;
|
||||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
||||
activityLevel?: number; // 活动水平 1-4
|
||||
maxHeartRate?: number; // 最大心率
|
||||
}
|
||||
@@ -270,7 +269,13 @@ export default function EditProfileScreen() {
|
||||
{ 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) {
|
||||
console.warn('上传头像失败', e);
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
|
||||
66
components/glass/button.tsx
Normal file
66
components/glass/button.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -42,7 +42,7 @@
|
||||
"expo-linking": "~8.0.7",
|
||||
"expo-notifications": "~0.32.10",
|
||||
"expo-quick-actions": "^5.0.0",
|
||||
"expo-router": "~6.0.0",
|
||||
"expo-router": "~6.0.1",
|
||||
"expo-splash-screen": "~31.0.8",
|
||||
"expo-status-bar": "~3.0.7",
|
||||
"expo-symbols": "~1.0.6",
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ApiRequestOptions = {
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
unsetContentType?: boolean;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
@@ -27,10 +28,13 @@ export type ApiResponse<T> = {
|
||||
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = buildApiUrl(path);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (!options.unsetContentType) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
@@ -39,7 +43,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
const response = await fetch(url, {
|
||||
method: options.method ?? 'GET',
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
184
services/cos.ts
184
services/cos.ts
@@ -1,31 +1,5 @@
|
||||
import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos';
|
||||
import { api } from '@/services/api';
|
||||
import { uploadImage } from '@/services/users';
|
||||
|
||||
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 = {
|
||||
key: string;
|
||||
@@ -38,132 +12,56 @@ type UploadOptions = {
|
||||
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 }> {
|
||||
const { key, srcUri, contentType, onProgress, 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;
|
||||
}
|
||||
const { key, srcUri, contentType, signal } = options;
|
||||
|
||||
if (!srcUri || typeof srcUri !== 'string') {
|
||||
throw new Error('请提供本地文件路径 srcUri(形如 file:/// 或 content://)');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
if (signal) {
|
||||
if (signal.aborted) controller.abort();
|
||||
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let cancelled = false;
|
||||
let taskRef: any = null;
|
||||
(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);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
// 创建 FormData 用于文件上传
|
||||
const formData = new FormData();
|
||||
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
cancelled = true;
|
||||
try { taskRef?.cancel?.(); } catch { }
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
});
|
||||
// 从 srcUri 创建文件对象
|
||||
let fileBlob;
|
||||
if (srcUri.startsWith('file://') || srcUri.startsWith('content://')) {
|
||||
// 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 }> {
|
||||
|
||||
@@ -22,4 +22,10 @@ export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user