feat: 更新文章功能和相关依赖

- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容
- 添加文章卡片组件,展示推荐文章的标题、封面和阅读量
- 更新文章服务,支持获取文章列表和根据 ID 获取文章详情
- 集成腾讯云 COS SDK,支持文件上传功能
- 优化打卡功能,支持按日期加载和展示打卡记录
- 更新相关依赖,确保项目兼容性和功能完整性
- 调整样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-14 16:03:19 +08:00
parent 532cf251e2
commit 5d09cc05dc
24 changed files with 1953 additions and 513 deletions

View File

@@ -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'));
});
});