Files
digital-pilates/services/api.ts
richarjiang a309123b35 feat(app): add version check system and enhance internationalization support
Add comprehensive app update checking functionality with:
- New VersionCheckContext for managing update detection and notifications
- VersionUpdateModal UI component for presenting update information
- Version service API integration with platform-specific update URLs
- Version check menu item in personal settings with manual/automatic checking

Enhance internationalization across workout features:
- Complete workout type translations for English and Chinese
- Localized workout detail modal with proper date/time formatting
- Locale-aware date formatting in fitness rings detail
- Workout notification improvements with deep linking to specific workout details

Improve UI/UX with better chart rendering, sizing fixes, and enhanced navigation flow. Update app version to 1.1.3 and include app version in API headers for better tracking.
2025-11-29 20:47:16 +08:00

364 lines
11 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 { buildApiUrl } from '@/constants/Api';
import AsyncStorage from '@/utils/kvStore';
import Constants from 'expo-constants';
import { Alert } from 'react-native';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
// 401处理的防抖机制
let is401Handling = false;
let last401Time = 0;
const DEBOUNCE_INTERVAL = 3000; // 3秒内只处理一次401
// 401处理回调函数由应用层注册
type UnauthorizedHandler = () => void | Promise<void>;
let unauthorizedHandler: UnauthorizedHandler | null = null;
/**
* 注册401未授权处理器
* 应该在应用启动时调用,传入退出登录的函数
*/
export function setUnauthorizedHandler(handler: UnauthorizedHandler) {
unauthorizedHandler = handler;
console.log('[API] 401处理器已注册');
}
/**
* 统一处理401未授权错误
* 包含防抖机制,避免多次提示
*/
async function handle401Unauthorized() {
const now = Date.now();
// 防抖如果正在处理或者距离上次处理不到3秒直接返回
if (is401Handling || (now - last401Time < DEBOUNCE_INTERVAL)) {
console.log('[API] 401处理防抖跳过重复处理');
return;
}
is401Handling = true;
last401Time = now;
try {
console.log('[API] 检测到401未授权开始处理登录过期');
// 清除本地token
await AsyncStorage.removeItem(STORAGE_KEYS.authToken);
await setAuthToken(null);
// 提示用户
Alert.alert(
'登录已过期',
'您的登录状态已过期,请重新登录',
[
{
text: '确定',
onPress: () => {
console.log('[API] 用户确认登录过期提示');
// 调用注册的处理器(如果存在)
if (unauthorizedHandler) {
try {
const result = unauthorizedHandler();
if (result instanceof Promise) {
result.catch(err => {
console.error('[API] 401处理器执行失败:', err);
});
}
} catch (err) {
console.error('[API] 401处理器执行失败:', err);
}
}
}
}
],
{ cancelable: false }
);
} catch (error) {
console.error('[API] 处理401错误时发生异常:', error);
} finally {
// 延迟重置处理标志,确保不会立即再次触发
setTimeout(() => {
is401Handling = false;
}, 1000);
}
}
// Token 缓存:内存中保存一份,避免每次都读取 AsyncStorage
let inMemoryToken: string | null = null;
/**
* 设置认证 token
* 同时更新内存缓存和持久化存储
*/
export async function setAuthToken(token: string | null): Promise<void> {
inMemoryToken = token;
// 同步更新 AsyncStorage
if (token) {
await AsyncStorage.setItem(STORAGE_KEYS.authToken, token);
} else {
await AsyncStorage.removeItem(STORAGE_KEYS.authToken);
}
}
/**
* 获取认证 token
* 优先使用内存缓存,若无则从 AsyncStorage 读取并缓存
*/
export async function getAuthToken(): Promise<string | null> {
// 如果内存中有,直接返回
if (inMemoryToken !== null) {
return inMemoryToken;
}
// 否则从 AsyncStorage 读取并缓存到内存
const token = await AsyncStorage.getItem(STORAGE_KEYS.authToken);
inMemoryToken = token;
return token;
}
export type ApiRequestOptions = {
method?: HttpMethod;
headers?: Record<string, string>;
body?: any;
signal?: AbortSignal;
unsetContentType?: boolean;
};
export type ApiResponse<T> = {
data: T;
};
function getAppVersion(): string | undefined {
return Constants.expoConfig?.version || Constants.nativeAppVersion || undefined;
}
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const url = buildApiUrl(path);
const headers: Record<string, string> = {
...(options.headers || {}),
};
if (!options.unsetContentType) {
headers['Content-Type'] = 'application/json';
}
const token = await getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const appVersion = getAppVersion();
if (appVersion) {
headers['X-App-Version'] = appVersion;
}
const response = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body != null ? options.unsetContentType ? options.body : JSON.stringify(options.body) : undefined,
signal: options.signal,
});
const json = await response.json()
if (!response.ok) {
// 检查是否为401未授权
if (response.status === 401) {
console.log('[API] 检测到401状态码触发登录过期处理');
await handle401Unauthorized();
}
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
const error = new Error(errorMessage);
// @ts-expect-error augment
error.status = response.status;
throw error;
}
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
const error = new Error(errorMessage);
// @ts-expect-error augment
error.status = response.status;
throw error;
}
// 支持后端返回 { data: ... } 或直接返回对象
return (json && (json.data ?? json)) as T;
}
export const api = {
get: <T>(path: string, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'POST', body }),
put: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'PUT', body }),
patch: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'PATCH', body }),
delete: <T>(path: string, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'DELETE' }),
};
export const STORAGE_KEYS = {
authToken: '@auth_token',
userProfile: '@user_profile',
privacyAgreed: '@privacy_agreed',
onboardingCompleted: '@onboarding_completed',
} as const;
// 流式文本 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 async function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) {
const url = buildApiUrl(path);
const token = await getAuthToken();
// 生成请求ID用于追踪和取消
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
...(options.headers || {}),
};
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`;
}
const appVersion = getAppVersion();
if (appVersion) {
requestHeaders['X-App-Version'] = appVersion;
}
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;
};
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 === 401) {
// 处理401未授权
console.log('[AI_CHAT][stream] 检测到401状态码触发登录过期处理');
handle401Unauthorized().catch(err => {
console.error('[AI_CHAT][stream] 处理401错误时发生异常:', err);
});
const error = new Error('登录已过期');
try { callbacks.onError?.(error); } catch { }
} else 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, requestId };
}