Files
digital-pilates/services/cos.ts
richarjiang e3e2f1b8c6 feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
2025-08-14 09:57:13 +08:00

167 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos';
import { api } from '@/services/api';
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;
body: any;
contentType?: string;
onProgress?: (progress: { percent: number }) => void;
signal?: AbortSignal;
};
let CosSdk: any | null = null;
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');
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, body, contentType, onProgress, signal } = options;
const COS = await ensureCosSdk();
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');
}
// 确保对象键以服务端授权的前缀开头
const finalKey = ((): string => {
const prefix = (cred.prefix || '').replace(/^\/+|\/+$/g, '');
if (!prefix) return key.replace(/^\//, '');
const normalizedKey = key.replace(/^\//, '');
return normalizedKey.startsWith(prefix + '/') ? normalizedKey : `${prefix}/${normalizedKey}`;
})();
const controller = new AbortController();
if (signal) {
if (signal.aborted) controller.abort();
signal.addEventListener('abort', () => controller.abort(), { once: true });
}
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 });
}
},
},
(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 });
}
);
controller.signal.addEventListener('abort', () => {
try { task && task.cancel && task.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++;
}
}
}