Files
digital-pilates/services/api.ts
richarjiang a228280ca4 feat(onboarding): 添加新用户引导流程
实现了完整的应用引导功能,包括:
- 新增引导页面UI,包含健康数据追踪、轻断食计划和健康挑战三个介绍页面
- 添加引导状态持久化存储,使用AsyncStorage管理用户完成状态
- 修改应用启动逻辑,根据引导状态决定跳转到主页或引导页
- 在开发者选项中添加重置引导状态功能,方便测试
- 更新路由配置和存储键常量,统一管理引导相关配置
2025-11-06 15:22:31 +08:00

235 lines
7.0 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';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
let inMemoryToken: string | null = null;
export async function setAuthToken(token: string | null): Promise<void> {
inMemoryToken = token;
}
export function getAuthToken(): Promise<string | null> {
return AsyncStorage.getItem(STORAGE_KEYS.authToken);
}
export type ApiRequestOptions = {
method?: HttpMethod;
headers?: Record<string, string>;
body?: any;
signal?: AbortSignal;
unsetContentType?: boolean;
};
export type ApiResponse<T> = {
data: T;
};
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 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()
console.log('json', json);
if (!response.ok) {
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) {
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 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 >= 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 };
}