diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6778759..31732b3 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 + + + + + + + + + + + + + + } + return ( { - 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); diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 68f58f0..5dc1c51 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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} diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index 1d22c7f..615e376 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -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('上传失败', '头像上传失败,请重试'); diff --git a/components/glass/button.tsx b/components/glass/button.tsx new file mode 100644 index 0000000..4e4afde --- /dev/null +++ b/components/glass/button.tsx @@ -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 ( + + + {title} + + + ); + } else { + // fallback + return ( + + {title} + + ); + } +} + +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', + }, +}); diff --git a/package.json b/package.json index ce2f917..c577e33 100644 --- a/package.json +++ b/package.json @@ -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", @@ -81,4 +81,4 @@ "typescript": "~5.9.2" }, "private": true -} +} \ No newline at end of file diff --git a/services/api.ts b/services/api.ts index 023a346..72ca486 100644 --- a/services/api.ts +++ b/services/api.ts @@ -18,6 +18,7 @@ export type ApiRequestOptions = { headers?: Record; body?: any; signal?: AbortSignal; + unsetContentType?: boolean; }; export type ApiResponse = { @@ -27,10 +28,13 @@ export type ApiResponse = { async function doFetch(path: string, options: ApiRequestOptions = {}): Promise { const url = buildApiUrl(path); const headers: Record = { - '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(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, }); diff --git a/services/cos.ts b/services/cos.ts index d10c9dd..42e8221 100644 --- a/services/cos.ts +++ b/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 { - // 后端返回 { code, message, data },api.get 会提取 data - const data = await api.get('/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; 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 }> { diff --git a/services/users.ts b/services/users.ts index 1b1f9e5..e88f703 100644 --- a/services/users.ts +++ b/services/users.ts @@ -22,4 +22,10 @@ export async function updateUser(dto: UpdateUserDto): Promise { + return await api.post('/api/users/cos/upload-image', formData, { + unsetContentType: true + }); +} +