feat: 优化 AI 教练聊天和打卡功能

- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-14 09:57:13 +08:00
parent 7ad26590e5
commit e3e2f1b8c6
18 changed files with 918 additions and 117 deletions

View File

@@ -1,6 +1,18 @@
import { COS_BUCKET, COS_REGION } from '@/constants/Cos';
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;
@@ -9,6 +21,10 @@ type CosCredential = {
};
startTime?: number;
expiredTime?: number;
bucket?: string;
region?: string;
prefix?: string;
cdnDomain?: string;
};
type UploadOptions = {
@@ -23,23 +39,63 @@ let CosSdk: any | null = null;
async function ensureCosSdk(): Promise<any> {
if (CosSdk) return CosSdk;
// 动态导入避免影响首屏
const mod = await import('cos-js-sdk-v5');
CosSdk = mod.default ?? mod;
// 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> {
return await api.get<CosCredential>('/users/cos-token');
// 后端返回 { 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> }> {
export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; publicUrl?: string }> {
const { key, body, contentType, onProgress, signal } = options;
if (!COS_BUCKET || !COS_REGION) {
throw new Error('未配置 COS_BUCKET / COS_REGION');
}
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) {
@@ -62,9 +118,9 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string
const task = cos.putObject(
{
Bucket: COS_BUCKET,
Region: COS_REGION,
Key: key,
Bucket: bucket,
Region: region,
Key: finalKey,
Body: body,
ContentType: contentType,
onProgress: (progressData: any) => {
@@ -76,7 +132,10 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string
},
(err: any, data: any) => {
if (err) return reject(err);
resolve({ key, etag: data && data.ETag, headers: data && data.headers });
const publicUrl = cred.cdnDomain
? `${String(cred.cdnDomain).replace(/\/$/, '')}/${finalKey.replace(/^\//, '')}`
: buildPublicUrl(finalKey);
resolve({ key: finalKey, etag: data && data.ETag, headers: data && data.headers, publicUrl });
}
);