Files
digital-pilates/services/api.ts
richarjiang 5013464a2c feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息:
- 创建成分表分析页面,支持拍照/选择图片和结果展示
- 集成新的营养成分分析API,支持图片上传和流式分析
- 在营养雷达卡片中添加成分表分析入口
- 更新应用版本至1.0.19
2025-10-16 12:16:08 +08:00

235 lines
6.9 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',
} 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 };
}