import type { ApiError, ApiErrorDetail } from '../types'; const TOKEN_KEY = 'capacity_report_token'; const DESKTOP_API_BASE = 'http://127.0.0.1:9081'; const API_BASE = resolveApiBase(); const API_FETCH_RETRIES = 8; const API_FETCH_RETRY_DELAY_MS = 500; const TAURI_HTTP_ERROR_PREFIX = 'CAPAREPORT_HTTP_ERROR:'; let onUnauthorized: (() => void) | null = null; let cachedTauriInvoke: TauriInvoke | null = null; type TauriInvoke = (command: string, args?: Record) => Promise; export interface DownloadResult { saved: boolean; path?: string; } interface TauriDownloadHeader { name: string; value: string; } interface TauriDownloadRequest { url: string; method: 'GET' | 'POST'; filename: string; headers: TauriDownloadHeader[]; body?: string; } interface TauriDownloadResult { saved: boolean; path?: string; } export class ApiRequestError extends Error { status: number; code?: string; detail?: ApiErrorDetail; constructor(message: string, status: number, detail?: ApiErrorDetail) { super(message); this.name = 'ApiRequestError'; this.status = status; this.code = detail?.code; this.detail = detail; } } export function getToken(): string { return localStorage.getItem(TOKEN_KEY) || ''; } export function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); document.cookie = `token=${token}; path=/; max-age=${60 * 60 * 24 * 30}`; } export function clearToken(): void { localStorage.removeItem(TOKEN_KEY); document.cookie = 'token=; path=/; max-age=0'; } export function setUnauthorizedHandler(handler: () => void): void { onUnauthorized = handler; } export async function apiGet(url: string): Promise { return request(url, { method: 'GET' }); } export async function apiPost(url: string, body?: unknown): Promise { return request(url, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }); } export async function request(url: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); const token = isLoginRequest(url) ? '' : getToken(); if (token) { headers.set('Authorization', `Bearer ${token}`); } const response = await fetchWithRetry(apiUrl(url), { ...init, headers }); if (response.status === 401) { if (isLoginRequest(url)) { const error = await readError(response); throw new ApiRequestError(error.message, response.status, error.detail); } clearToken(); onUnauthorized?.(); throw new ApiRequestError('登录已过期,请重新登录', response.status, { code: 'UNAUTHORIZED', message: '登录已过期,请重新登录' }); } if (!response.ok) { const error = await readError(response); throw new ApiRequestError(error.message, response.status, error.detail); } return response.json() as Promise; } export function upload( url: string, formData: FormData, onProgress?: (percent: number) => void ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', apiUrl(url)); const token = getToken(); if (token) { xhr.setRequestHeader('Authorization', `Bearer ${token}`); } xhr.upload.addEventListener('progress', event => { if (event.lengthComputable) { onProgress?.(Math.round((event.loaded / event.total) * 100)); } }); xhr.addEventListener('load', () => { if (xhr.status === 401) { clearToken(); onUnauthorized?.(); reject( new ApiRequestError('登录已过期,请重新登录', xhr.status, { code: 'UNAUTHORIZED', message: '登录已过期,请重新登录' }) ); return; } if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(parseXhrError(xhr.responseText))); return; } resolve(JSON.parse(xhr.responseText) as T); }); xhr.addEventListener('error', () => reject(new Error('网络错误'))); xhr.send(formData); }); } export async function download(url: string, body: unknown, filename: string): Promise { const headers = new Headers({ 'Content-Type': 'application/json' }); const token = getToken(); if (token) { headers.set('Authorization', `Bearer ${token}`); } if (isTauriRuntime()) { return await downloadWithTauri({ url: apiUrl(url), method: 'POST', filename, headers: headersToPairs(headers), body: JSON.stringify(body) }); } const response = await fetchWithRetry(apiUrl(url), { method: 'POST', headers, body: JSON.stringify(body) }); if (!response.ok) { const error = await readError(response); throw new ApiRequestError(error.message, response.status, error.detail); } return await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename); } export async function downloadGet(url: string, fallbackFilename: string): Promise { const headers = new Headers(); const token = getToken(); if (token) { headers.set('Authorization', `Bearer ${token}`); } if (isTauriRuntime()) { return await downloadWithTauri({ url: apiUrl(url), method: 'GET', filename: fallbackFilename, headers: headersToPairs(headers) }); } const response = await fetchWithRetry(apiUrl(url), { headers }); if (response.status === 401) { clearToken(); onUnauthorized?.(); throw new ApiRequestError('登录已过期,请重新登录', response.status, { code: 'UNAUTHORIZED', message: '登录已过期,请重新登录' }); } if (!response.ok) { const error = await readError(response); throw new ApiRequestError(error.message, response.status, error.detail); } const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename; return await saveBlobResponse(response, filename); } async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> { try { const data = (await response.json()) as ApiError; return parseApiError(data, response.statusText); } catch { return { message: response.statusText || '请求失败' }; } } function parseXhrError(text: string): string { try { const data = JSON.parse(text) as ApiError; return parseApiError(data, '请求失败').message; } catch { return text || '请求失败'; } } function parseApiError(data: ApiError, fallback: string): { message: string; detail?: ApiErrorDetail } { if (typeof data.detail === 'object' && data.detail !== null) { return { message: data.detail.message || data.message || data.error || fallback, detail: data.detail }; } return { message: data.detail || data.error || data.message || fallback, detail: data.code ? { code: data.code, message: data.message || data.error } : undefined }; } async function saveBlobResponse(response: Response, filename: string): Promise { const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = filename; link.click(); URL.revokeObjectURL(objectUrl); return { saved: true }; } async function downloadWithTauri(request: TauriDownloadRequest): Promise { try { const invoke = await getTauriInvoke(); const result = await invoke('download_to_file', { request }); return { saved: result.saved, path: result.path }; } catch (error) { throw parseTauriDownloadError(error); } } export async function openPathInFileManager(path: string): Promise { if (!isTauriRuntime()) return; const invoke = await getTauriInvoke(); await invoke('open_path_in_file_manager', { path }); } async function getTauriInvoke(): Promise { if (!cachedTauriInvoke) { const api = await import('@tauri-apps/api/core'); cachedTauriInvoke = api.invoke; } return cachedTauriInvoke; } function headersToPairs(headers: Headers): TauriDownloadHeader[] { const pairs: TauriDownloadHeader[] = []; headers.forEach((value, name) => { pairs.push({ name, value }); }); return pairs; } function parseTauriDownloadError(error: unknown): Error { const message = error instanceof Error ? error.message : String(error || '下载失败'); if (!message.startsWith(TAURI_HTTP_ERROR_PREFIX)) { return error instanceof Error ? error : new Error(message); } const payload = message.slice(TAURI_HTTP_ERROR_PREFIX.length); const separator = payload.indexOf(':'); const status = Number(payload.slice(0, separator)); const body = separator >= 0 ? payload.slice(separator + 1) : ''; const parsed = parseApiErrorText(body, `下载失败 (${status || 'HTTP'})`); if (status === 401) { clearToken(); onUnauthorized?.(); } return new ApiRequestError(parsed.message, status || 500, parsed.detail); } function parseApiErrorText(text: string, fallback: string): { message: string; detail?: ApiErrorDetail } { try { const data = JSON.parse(text) as ApiError; return parseApiError(data, fallback); } catch { return { message: text || fallback }; } } async function fetchWithRetry(url: string, init: RequestInit): Promise { let lastError: unknown; for (let attempt = 0; attempt <= API_FETCH_RETRIES; attempt += 1) { try { return await fetch(url, init); } catch (error) { lastError = error; if (attempt === API_FETCH_RETRIES) { break; } await sleep(API_FETCH_RETRY_DELAY_MS); } } throw lastError instanceof Error ? lastError : new Error('网络请求失败'); } function sleep(ms: number): Promise { return new Promise(resolve => window.setTimeout(resolve, ms)); } function parseFilename(disposition: string | null): string { if (!disposition) return ''; const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) { return decodeURIComponent(utf8Match[1]); } const plainMatch = disposition.match(/filename="?([^";]+)"?/i); return plainMatch?.[1] || ''; } export function apiUrl(url: string): string { if (!API_BASE || /^https?:\/\//i.test(url)) { return url; } return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`; } export function getApiBaseUrl(): string { return API_BASE || window.location.origin; } function resolveApiBase(): string { const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); if (configured) { return configured; } return isTauriRuntime() ? DESKTOP_API_BASE : ''; } function isTauriRuntime(): boolean { const win = window as Window & { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown; }; return Boolean(win.__TAURI__ || win.__TAURI_INTERNALS__ || navigator.userAgent.includes('Tauri')); } function isLoginRequest(url: string): boolean { try { const parsed = new URL(url, window.location.origin); return parsed.pathname === '/api/login'; } catch { return url.replace(/^https?:\/\/[^/]+/i, '').split('?')[0] === '/api/login'; } }