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
+ });
+}
+