feat: 更新文章功能和相关依赖
- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容 - 添加文章卡片组件,展示推荐文章的标题、封面和阅读量 - 更新文章服务,支持获取文章列表和根据 ID 获取文章详情 - 集成腾讯云 COS SDK,支持文件上传功能 - 优化打卡功能,支持按日期加载和展示打卡记录 - 更新相关依赖,确保项目兼容性和功能完整性 - 调整样式以适应新功能的展示和交互
This commit is contained in:
149
services/cos.ts
149
services/cos.ts
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -29,41 +30,24 @@ type CosCredential = {
|
||||
|
||||
type UploadOptions = {
|
||||
key: string;
|
||||
body: any;
|
||||
// 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 CosSdk: any | null = null;
|
||||
let rnTransferManager: any | null = null;
|
||||
let rnInitialized = false;
|
||||
|
||||
async function ensureCosSdk(): Promise<any> {
|
||||
if (CosSdk) return CosSdk;
|
||||
// RN 兼容:SDK 在初始化时会访问 navigator.userAgent
|
||||
const g: any = globalThis as any;
|
||||
if (!g.navigator) g.navigator = {};
|
||||
if (!g.navigator.userAgent) g.navigator.userAgent = 'react-native';
|
||||
// 动态导入避免影响首屏,并加入 require 回退,兼容打包差异
|
||||
let mod: any = null;
|
||||
try {
|
||||
mod = await import('cos-js-sdk-v5');
|
||||
} catch (_) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
mod = require('cos-js-sdk-v5');
|
||||
} catch { }
|
||||
}
|
||||
const Candidate = mod?.COS || mod?.default || mod;
|
||||
if (!Candidate) {
|
||||
throw new Error('cos-js-sdk-v5 加载失败');
|
||||
}
|
||||
CosSdk = Candidate;
|
||||
return CosSdk;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -80,8 +64,8 @@ async function fetchCredential(): Promise<CosCredential> {
|
||||
}
|
||||
|
||||
export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; publicUrl?: string }> {
|
||||
const { key, body, contentType, onProgress, signal } = options;
|
||||
const COS = await ensureCosSdk();
|
||||
const { key, srcUri, contentType, onProgress, signal } = options;
|
||||
|
||||
const cred = await fetchCredential();
|
||||
const bucket = COS_BUCKET || cred.bucket;
|
||||
const region = COS_REGION || cred.region;
|
||||
@@ -89,14 +73,48 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string
|
||||
throw new Error('未配置 COS_BUCKET / COS_REGION,且服务端未返回 bucket/region');
|
||||
}
|
||||
|
||||
// 确保对象键以服务端授权的前缀开头
|
||||
// 确保对象键以服务端授权的前缀开头(去除通配符,折叠斜杠,避免把 * 拼进 Key)
|
||||
const finalKey = ((): string => {
|
||||
const prefix = (cred.prefix || '').replace(/^\/+|\/+$/g, '');
|
||||
if (!prefix) return key.replace(/^\//, '');
|
||||
const rawPrefix = String(cred.prefix || '');
|
||||
// 1) 去掉 * 与多余斜杠,再去掉首尾斜杠
|
||||
const safePrefix = rawPrefix
|
||||
.replace(/\*/g, '')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
const normalizedKey = key.replace(/^\//, '');
|
||||
return normalizedKey.startsWith(prefix + '/') ? normalizedKey : `${prefix}/${normalizedKey}`;
|
||||
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();
|
||||
@@ -104,43 +122,46 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const cos = new COS({
|
||||
getAuthorization: (_opts: any, cb: any) => {
|
||||
cb({
|
||||
TmpSecretId: cred.credentials.tmpSecretId,
|
||||
TmpSecretKey: cred.credentials.tmpSecretKey,
|
||||
SecurityToken: cred.credentials.sessionToken,
|
||||
StartTime: cred.startTime,
|
||||
ExpiredTime: cred.expiredTime,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const task = cos.putObject(
|
||||
{
|
||||
Bucket: bucket,
|
||||
Region: region,
|
||||
Key: finalKey,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
onProgress: (progressData: any) => {
|
||||
if (onProgress) {
|
||||
const percent = progressData && progressData.percent ? progressData.percent : 0;
|
||||
onProgress({ percent });
|
||||
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,
|
||||
}
|
||||
},
|
||||
},
|
||||
(err: any, data: any) => {
|
||||
if (err) return reject(err);
|
||||
const publicUrl = cred.cdnDomain
|
||||
? `${String(cred.cdnDomain).replace(/\/$/, '')}/${finalKey.replace(/^\//, '')}`
|
||||
: buildPublicUrl(finalKey);
|
||||
resolve({ key: finalKey, etag: data && data.ETag, headers: data && data.headers, publicUrl });
|
||||
);
|
||||
} catch (e) {
|
||||
if (!cancelled) reject(e);
|
||||
}
|
||||
);
|
||||
})();
|
||||
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
try { task && task.cancel && task.cancel(); } catch { }
|
||||
cancelled = true;
|
||||
try { taskRef?.cancel?.(); } catch { }
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user