feat(ui): 实现原生标签页与玻璃效果按钮组件
引入 NativeTabs 替代默认 Tabs 以支持原生标签栏样式,并添加 GlassButton 组件实现毛玻璃效果按钮。 移除对 useBottomTabBarHeight 的依赖,统一使用固定底部间距 60。 重构头像上传逻辑,使用新的 uploadImage API 替代 COS 直传方案。 更新 expo-router 至 ~6.0.1 版本以支持不稳定特性。
This commit is contained in:
@@ -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