- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
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++;
|
||
}
|
||
}
|
||
}
|
||
|
||
|