feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
This commit is contained in:
46
services/aiCoachSession.ts
Normal file
46
services/aiCoachSession.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type AiCoachChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AiCoachSessionCache = {
|
||||
conversationId?: string;
|
||||
messages: AiCoachChatMessage[];
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = '@ai_coach_session_v1';
|
||||
|
||||
export async function loadAiCoachSessionCache(): Promise<AiCoachSessionCache | null> {
|
||||
try {
|
||||
const s = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (!s) return null;
|
||||
const obj = JSON.parse(s) as AiCoachSessionCache;
|
||||
if (!obj || !Array.isArray(obj.messages)) return null;
|
||||
return obj;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAiCoachSessionCache(cache: AiCoachSessionCache): Promise<void> {
|
||||
try {
|
||||
const payload: AiCoachSessionCache = {
|
||||
conversationId: cache.conversationId,
|
||||
messages: cache.messages?.slice?.(-200) ?? [], // 限制最多缓存 200 条,避免无限增长
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
export async function clearAiCoachSessionCache(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
|
||||
144
services/api.ts
144
services/api.ts
@@ -85,4 +85,148 @@ export async function loadPersistedToken(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 流式文本 POST(基于 XMLHttpRequest),支持增量 onChunk 回调与取消
|
||||
export type TextStreamCallbacks = {
|
||||
onChunk: (chunkText: string) => void;
|
||||
onEnd?: (conversationId?: string) => void;
|
||||
onError?: (error: any) => void;
|
||||
};
|
||||
|
||||
export type TextStreamOptions = {
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) {
|
||||
const url = buildApiUrl(path);
|
||||
const token = getAuthToken();
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
if (token) {
|
||||
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
let lastReadIndex = 0;
|
||||
let resolved = false;
|
||||
let conversationIdFromHeader: string | undefined = undefined;
|
||||
|
||||
const abort = () => {
|
||||
try { xhr.abort(); } catch { }
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
resolved = true;
|
||||
};
|
||||
|
||||
// 日志:请求开始
|
||||
try {
|
||||
console.log('[AI_CHAT][stream] start', { url, hasToken: !!token, body });
|
||||
} catch { }
|
||||
|
||||
xhr.open('POST', url, true);
|
||||
// 设置超时(可选)
|
||||
if (typeof options.timeoutMs === 'number') {
|
||||
xhr.timeout = options.timeoutMs;
|
||||
}
|
||||
// 设置请求头
|
||||
Object.entries(requestHeaders).forEach(([k, v]) => {
|
||||
try { xhr.setRequestHeader(k, v); } catch { }
|
||||
});
|
||||
|
||||
// 进度事件:读取新增的响应文本
|
||||
xhr.onprogress = () => {
|
||||
try {
|
||||
const text = xhr.responseText ?? '';
|
||||
if (text.length > lastReadIndex) {
|
||||
const nextChunk = text.substring(lastReadIndex);
|
||||
lastReadIndex = text.length;
|
||||
// 首次拿到响应头时尝试解析会话ID
|
||||
if (!conversationIdFromHeader) {
|
||||
try {
|
||||
const rawHeaders = xhr.getAllResponseHeaders?.() || '';
|
||||
const matched = /^(.*)$/m.test(rawHeaders) ? rawHeaders : rawHeaders; // 保底,避免 TS 报错
|
||||
const headerLines = String(matched).split('\n');
|
||||
for (const line of headerLines) {
|
||||
const [hk, ...rest] = line.split(':');
|
||||
if (hk && hk.toLowerCase() === 'x-conversation-id') {
|
||||
conversationIdFromHeader = rest.join(':').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
try {
|
||||
callbacks.onChunk(nextChunk);
|
||||
} catch (err) {
|
||||
console.warn('[AI_CHAT][stream] onChunk error', err);
|
||||
}
|
||||
try {
|
||||
console.log('[AI_CHAT][stream] chunk', { length: nextChunk.length, preview: nextChunk.slice(0, 50) });
|
||||
} catch { }
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AI_CHAT][stream] onprogress error', err);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === xhr.DONE) {
|
||||
try { console.log('[AI_CHAT][stream] done', { status: xhr.status }); } catch { }
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try { callbacks.onEnd?.(conversationIdFromHeader); } catch { }
|
||||
} else {
|
||||
const error = new Error(`HTTP ${xhr.status}`);
|
||||
try { callbacks.onError?.(error); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = (e) => {
|
||||
try { console.warn('[AI_CHAT][stream] xhr error', e); } catch { }
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
try { callbacks.onError?.(e); } catch { }
|
||||
}
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
const err = new Error('Request timeout');
|
||||
try { console.warn('[AI_CHAT][stream] timeout'); } catch { }
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
try { callbacks.onError?.(err); } catch { }
|
||||
}
|
||||
};
|
||||
|
||||
// AbortSignal 支持
|
||||
if (options.signal) {
|
||||
const onAbort = () => {
|
||||
try { console.log('[AI_CHAT][stream] aborted'); } catch { }
|
||||
abort();
|
||||
};
|
||||
if (options.signal.aborted) onAbort();
|
||||
else options.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = body != null ? JSON.stringify(body) : undefined;
|
||||
xhr.send(payload);
|
||||
} catch (err) {
|
||||
try { console.warn('[AI_CHAT][stream] send error', err); } catch { }
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
try { callbacks.onError?.(err); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
return { abort };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,4 +55,19 @@ export async function fetchDailyCheckins(date?: string): Promise<any[]> {
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// 优先尝试按区间批量获取(若后端暂未实现将抛错,由调用方回退到逐日请求)
|
||||
export async function fetchCheckinsInRange(startDate: string, endDate: string): Promise<any[]> {
|
||||
const path = `/api/checkins/range?start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`;
|
||||
const data = await api.get<any[]>(path);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// 获取时间范围内每日是否已打卡(仅返回日期+布尔)
|
||||
export type DailyStatusItem = { date: string; checkedIn: boolean };
|
||||
export async function fetchDailyStatusRange(startDate: string, endDate: string): Promise<DailyStatusItem[]> {
|
||||
const path = `/api/checkins/range/daily-status?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`;
|
||||
const data = await api.get<DailyStatusItem[]>(path);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
23
services/users.ts
Normal file
23
services/users.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export type Gender = 'male' | 'female';
|
||||
|
||||
export type UpdateUserDto = {
|
||||
userId: string;
|
||||
name?: string;
|
||||
avatar?: string; // base64 字符串
|
||||
gender?: Gender;
|
||||
birthDate?: number; // 时间戳(秒)
|
||||
dailyStepsGoal?: number;
|
||||
dailyCaloriesGoal?: number;
|
||||
pilatesPurposes?: string[];
|
||||
weight?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any>> {
|
||||
// 固定使用后端文档接口:PUT /api/users/update
|
||||
return await api.put('/api/users/update', dto);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user