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 { 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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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('上传失败', '头像上传失败,请重试');
|
||||||
|
|||||||
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-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",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
182
services/cos.ts
182
services/cos.ts
@@ -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) => {
|
|
||||||
let cancelled = false;
|
|
||||||
let taskRef: any = null;
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
taskRef = await rnTransferManager.upload(
|
// 创建 FormData 用于文件上传
|
||||||
bucket,
|
const formData = new FormData();
|
||||||
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 }> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user