feat: 更新文章功能和相关依赖
- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容 - 添加文章卡片组件,展示推荐文章的标题、封面和阅读量 - 更新文章服务,支持获取文章列表和根据 ID 获取文章详情 - 集成腾讯云 COS SDK,支持文件上传功能 - 优化打卡功能,支持按日期加载和展示打卡记录 - 更新相关依赖,确保项目兼容性和功能完整性 - 调整样式以适应新功能的展示和交互
This commit is contained in:
44
services/articles.ts
Normal file
44
services/articles.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from './api';
|
||||
|
||||
export type Article = {
|
||||
id: string;
|
||||
title: string;
|
||||
coverImage: string;
|
||||
htmlContent: string;
|
||||
publishedAt: string; // ISO string
|
||||
readCount: number;
|
||||
};
|
||||
|
||||
const demoArticles: Article[] = [
|
||||
{
|
||||
id: 'intro-pilates-posture',
|
||||
title: '新手入门:普拉提核心与体态的关系',
|
||||
coverImage: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||
publishedAt: dayjs().subtract(2, 'day').toISOString(),
|
||||
readCount: 1268,
|
||||
htmlContent: `
|
||||
<h2>为什么核心很重要?</h2>
|
||||
<p>核心是维持良好体态与动作稳定的关键。普拉提通过强调呼吸与深层肌群激活,帮助你在<em>日常站立、坐姿与训练</em>中保持更好的身体对齐。</p>
|
||||
<h3>入门建议</h3>
|
||||
<ol>
|
||||
<li>从呼吸开始:尝试<strong>胸廓外扩</strong>而非耸肩。</li>
|
||||
<li>慢而可控:注意动作过程中的连贯与专注。</li>
|
||||
<li>记录变化:每周拍照或在应用中记录体态变化。</li>
|
||||
</ol>
|
||||
<p>更多实操可在本应用的「AI体态评估」中获取个性化建议。</p>
|
||||
<img src="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/ImageCheck.jpeg" alt="pilates-illustration" />
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export function listRecommendedArticles(): Article[] {
|
||||
// 实际项目中可替换为 API 请求
|
||||
return demoArticles;
|
||||
}
|
||||
|
||||
export async function getArticleById(id: string): Promise<Article | undefined> {
|
||||
return api.get<Article>(`/articles/${id}`);
|
||||
}
|
||||
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
23
services/recommendations.ts
Normal file
23
services/recommendations.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export enum RecommendationType {
|
||||
Article = 'article',
|
||||
Checkin = 'checkin',
|
||||
}
|
||||
|
||||
export type RecommendationCard = {
|
||||
id: string;
|
||||
type: RecommendationType;
|
||||
title?: string;
|
||||
coverUrl: string;
|
||||
articleId?: string;
|
||||
subtitle?: string;
|
||||
extra?: Record<string, any>;
|
||||
};
|
||||
|
||||
export async function fetchRecommendations(): Promise<RecommendationCard[]> {
|
||||
// 后端返回 BaseResponseDto<data>,services/api 会自动解出 data 字段
|
||||
return api.get<RecommendationCard[]>(`/recommendations/list`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user