const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api"; const SESSION_KEY = "forgeflow.erp.session"; async function parseResponse(response) { const contentType = response.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); const data = isJson ? await response.json() : await response.text(); if (!response.ok) { const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`; throw new Error(detail); } return data; } function isSessionExpired(session) { if (!session?.access_token || !session?.expires_at) { return true; } const expiresAt = Date.parse(session.expires_at); if (Number.isNaN(expiresAt)) { return true; } return expiresAt <= Date.now(); } export async function requestJson(path, options = {}) { const { skipAuthRedirect = false, headers = {}, ...fetchOptions } = options; const session = readSession(); const authHeaders = session?.access_token && !isSessionExpired(session) ? { Authorization: `Bearer ${session.access_token}` } : {}; const response = await fetch(`${API_BASE_URL}${path}`, { headers: { "Content-Type": "application/json", ...authHeaders, ...headers }, ...fetchOptions }); if ((response.status === 401 || response.status === 403) && !skipAuthRedirect) { clearSession(); if (typeof window !== "undefined" && window.location.pathname !== "/login") { window.location.href = "/login"; } } return parseResponse(response); } export async function fetchResource(path, fallbackData) { try { return await requestJson(path, { method: "GET" }); } catch (error) { return fallbackData; } } export async function postResource(path, payload) { return requestJson(path, { method: "POST", body: JSON.stringify(payload) }); } export async function uploadResource(path, formData) { const session = readSession(); const authHeaders = session?.access_token && !isSessionExpired(session) ? { Authorization: `Bearer ${session.access_token}` } : {}; const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST", headers: authHeaders, body: formData }); if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { clearSession(); window.location.href = "/login"; } return parseResponse(response); } function getDownloadFilename(response, fallbackFilename) { const disposition = response.headers.get("content-disposition") || ""; const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); if (encodedMatch?.[1]) { try { return decodeURIComponent(encodedMatch[1]); } catch (error) { return fallbackFilename; } } const plainMatch = disposition.match(/filename="?([^";]+)"?/i); return plainMatch?.[1] || fallbackFilename; } export async function downloadResource(path, fallbackFilename = "download.xlsx") { const session = readSession(); const authHeaders = session?.access_token && !isSessionExpired(session) ? { Authorization: `Bearer ${session.access_token}` } : {}; const response = await fetch(`${API_BASE_URL}${path}`, { method: "GET", headers: authHeaders }); if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { clearSession(); window.location.href = "/login"; } if (!response.ok) { return parseResponse(response); } const blob = await response.blob(); const filename = getDownloadFilename(response, fallbackFilename); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); return { filename }; } export async function postDownloadResource(path, payload, fallbackFilename = "download.zip") { const session = readSession(); const authHeaders = session?.access_token && !isSessionExpired(session) ? { Authorization: `Bearer ${session.access_token}` } : {}; const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify(payload) }); if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { clearSession(); window.location.href = "/login"; } if (!response.ok) { return parseResponse(response); } const blob = await response.blob(); const filename = getDownloadFilename(response, fallbackFilename); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); return { filename }; } export async function openResource(path) { const session = readSession(); const authHeaders = session?.access_token && !isSessionExpired(session) ? { Authorization: `Bearer ${session.access_token}` } : {}; const response = await fetch(`${API_BASE_URL}${path}`, { method: "GET", headers: authHeaders }); if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { clearSession(); window.location.href = "/login"; } if (!response.ok) { return parseResponse(response); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000); return { opened: true }; } export async function putResource(path, payload) { return requestJson(path, { method: "PUT", body: JSON.stringify(payload) }); } export async function deleteResource(path) { return requestJson(path, { method: "DELETE" }); } export function saveSession(user) { localStorage.setItem(SESSION_KEY, JSON.stringify(user)); } export function readSession() { const raw = localStorage.getItem(SESSION_KEY); if (!raw) { return null; } try { const session = JSON.parse(raw); if (isSessionExpired(session)) { localStorage.removeItem(SESSION_KEY); return null; } return session; } catch (error) { console.warn("Failed to parse session", error); localStorage.removeItem(SESSION_KEY); return null; } } export function clearSession() { localStorage.removeItem(SESSION_KEY); } export function isAuthenticated() { return Boolean(readSession()); } function pemToArrayBuffer(pem) { const base64Text = pem .replace(/-----BEGIN PUBLIC KEY-----/g, "") .replace(/-----END PUBLIC KEY-----/g, "") .replace(/\s/g, ""); const binary = window.atob(base64Text); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes.buffer; } function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ""; for (let index = 0; index < bytes.byteLength; index += 1) { binary += String.fromCharCode(bytes[index]); } return window.btoa(binary); } async function encryptLoginPassword(password, publicKeyPem) { if (!window.crypto?.subtle) { throw new Error("当前访问方式不支持安全加密登录,请使用 HTTPS 或 localhost 地址访问系统"); } const publicKey = await window.crypto.subtle.importKey( "spki", pemToArrayBuffer(publicKeyPem), { name: "RSA-OAEP", hash: "SHA-256" }, false, ["encrypt"] ); const ciphertext = await window.crypto.subtle.encrypt( { name: "RSA-OAEP" }, publicKey, new TextEncoder().encode(password) ); return arrayBufferToBase64(ciphertext); } export async function login(username, password) { const loginKey = await requestJson("/auth/login-key", { method: "GET", skipAuthRedirect: true }); const passwordCiphertext = await encryptLoginPassword(password, loginKey.public_key_pem); return requestJson("/auth/login", { method: "POST", body: JSON.stringify({ username, password_ciphertext: passwordCiphertext, login_key_id: loginKey.key_id }), skipAuthRedirect: true }); } export function buildApiUrl(path) { return `${API_BASE_URL}${path}`; }