- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容 - 添加文章卡片组件,展示推荐文章的标题、封面和阅读量 - 更新文章服务,支持获取文章列表和根据 ID 获取文章详情 - 集成腾讯云 COS SDK,支持文件上传功能 - 优化打卡功能,支持按日期加载和展示打卡记录 - 更新相关依赖,确保项目兼容性和功能完整性 - 调整样式以适应新功能的展示和交互
188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos';
|
||
import { api } from '@/services/api';
|
||
import Cos from 'react-native-cos-sdk';
|
||
|
||
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;
|
||
// React Native COS SDK 推荐使用本地文件路径(file:// 或 content://)
|
||
srcUri?: string;
|
||
// 为兼容旧实现(Web/Blob)。在 RN SDK 下会忽略 body
|
||
body?: any;
|
||
contentType?: string;
|
||
onProgress?: (progress: { percent: number }) => void;
|
||
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;
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
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);
|
||
}
|
||
})();
|
||
|
||
controller.signal.addEventListener('abort', () => {
|
||
cancelled = true;
|
||
try { taskRef?.cancel?.(); } catch { }
|
||
reject(new DOMException('Aborted', 'AbortError'));
|
||
});
|
||
});
|
||
}
|
||
|
||
export async function uploadWithRetry(options: UploadOptions & { maxRetries?: number; backoffMs?: number }): Promise<{ key: string; etag?: string }> {
|
||
const { maxRetries = 2, backoffMs = 800, ...rest } = options;
|
||
let attempt = 0;
|
||
// 简单指数退避
|
||
while (true) {
|
||
try {
|
||
return await uploadToCos(rest);
|
||
} catch (e: any) {
|
||
if (rest.signal?.aborted) throw e;
|
||
if (attempt >= maxRetries) throw e;
|
||
const wait = backoffMs * Math.pow(2, attempt);
|
||
await new Promise(r => setTimeout(r, wait));
|
||
attempt++;
|
||
}
|
||
}
|
||
}
|
||
|
||
|