新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息: - 创建成分表分析页面,支持拍照/选择图片和结果展示 - 集成新的营养成分分析API,支持图片上传和流式分析 - 在营养雷达卡片中添加成分表分析入口 - 更新应用版本至1.0.19
235 lines
6.9 KiB
TypeScript
235 lines
6.9 KiB
TypeScript
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 };
|
||
}
|
||
|
||
|