feat(ui): 实现原生标签页与玻璃效果按钮组件

引入 NativeTabs 替代默认 Tabs 以支持原生标签栏样式,并添加 GlassButton 组件实现毛玻璃效果按钮。
移除对 useBottomTabBarHeight 的依赖,统一使用固定底部间距 60。
重构头像上传逻辑,使用新的 uploadImage API 替代 COS 直传方案。
更新 expo-router 至 ~6.0.1 版本以支持不稳定特性。
This commit is contained in:
richarjiang
2025-09-12 15:48:58 +08:00
parent a84c026599
commit 1b76cc305a
9 changed files with 153 additions and 161 deletions

View File

@@ -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 }> {