Initial commit
This commit is contained in:
124
frontend/js/services/api.js
Normal file
124
frontend/js/services/api.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Filename: frontend/js/services/api.js
|
||||
|
||||
class APIClientError extends Error {
|
||||
constructor(message, status, code, rawMessageFromServer) {
|
||||
super(message);
|
||||
this.name = 'APIClientError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.rawMessageFromServer = rawMessageFromServer;
|
||||
}
|
||||
}
|
||||
|
||||
// Global Promise cache for raw responses
|
||||
const apiPromiseCache = new Map();
|
||||
|
||||
/**
|
||||
* [CORRECTED & CACHE-AWARE] A low-level fetch wrapper.
|
||||
* It handles caching, authentication, and centralized error handling.
|
||||
* On success (2xx), it returns the raw, unread Response object.
|
||||
* On failure (non-2xx), it consumes the body to throw a detailed APIClientError.
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
// For non-GET requests or noCache requests, we bypass the promise cache.
|
||||
const isGetRequest = !options.method || options.method.toUpperCase() === 'GET';
|
||||
const cacheKey = isGetRequest && !options.noCache ? url : null;
|
||||
|
||||
if (cacheKey && apiPromiseCache.has(cacheKey)) {
|
||||
return apiPromiseCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('bearerToken');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
// On auth error, always clear caches for this URL.
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
// ... (rest of the 401 logic is correct)
|
||||
localStorage.removeItem('bearerToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?error=会话已过期,请重新登录。';
|
||||
}
|
||||
throw new APIClientError('Unauthorized', 401, 'UNAUTHORIZED', 'Session expired or token is invalid.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData = null;
|
||||
let rawMessage = '';
|
||||
try {
|
||||
// This is the ONLY place the body is consumed in the failure path.
|
||||
rawMessage = await response.text();
|
||||
if(rawMessage) { // Avoid parsing empty string
|
||||
errorData = JSON.parse(rawMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
errorData = { error: { code: 'UNKNOWN_FORMAT', message: rawMessage || response.statusText } };
|
||||
}
|
||||
|
||||
const code = errorData?.error?.code || 'UNKNOWN_ERROR';
|
||||
const messageFromServer = errorData?.error?.message || rawMessage || 'No message provided by server.';
|
||||
|
||||
const error = new APIClientError(
|
||||
`API request failed: ${response.status}`,
|
||||
response.status,
|
||||
code,
|
||||
messageFromServer
|
||||
);
|
||||
|
||||
// Throwing the error will cause this promise to reject.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// On success, the promise resolves with the PRISTINE response object.
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
// If an error occurred (either thrown by us or a network error),
|
||||
// ensure the promise cache is cleared for this key before re-throwing.
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
throw error; // Re-throw to propagate the failure.
|
||||
}
|
||||
})();
|
||||
|
||||
// If we are caching, store the promise.
|
||||
if (cacheKey) {
|
||||
apiPromiseCache.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORRECTED & CACHE-AWARE] High-level wrapper that expects a JSON response.
|
||||
* It leverages apiFetch and is ONLY responsible for calling .json() on a successful response.
|
||||
*/
|
||||
export async function apiFetchJson(url, options = {}) {
|
||||
try {
|
||||
// 1. Get the raw response from apiFetch. It's either fresh or from the promise cache.
|
||||
// If it fails, apiFetch will throw, and this function will propagate the error.
|
||||
const response = await apiFetch(url, options);
|
||||
|
||||
// 2. We have a successful response. We need to clone it before reading the body.
|
||||
// This is CRITICAL because the original response in the promise cache MUST remain unread.
|
||||
const clonedResponse = response.clone();
|
||||
|
||||
// 3. Now we can safely consume the body of the CLONE.
|
||||
const jsonData = await clonedResponse.json();
|
||||
|
||||
return jsonData;
|
||||
|
||||
} catch (error) {
|
||||
// Just propagate the detailed error from apiFetch.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
39
frontend/js/services/errorHandler.js
Normal file
39
frontend/js/services/errorHandler.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Filename: frontend/js/services/errorHandler.js
|
||||
|
||||
/**
|
||||
* This module provides a centralized place for handling application-wide errors,
|
||||
* particularly those originating from API calls. It promotes a consistent user
|
||||
* experience for error notifications.
|
||||
*/
|
||||
|
||||
// Step 1: Define the single, authoritative map for all client-side error messages.
|
||||
// This is the "dictionary" that translates API error codes into user-friendly text.
|
||||
export const ERROR_MESSAGES = {
|
||||
'STATE_CONFLICT_MASTER_REVOKED': '操作失败:无法激活一个已被永久吊销(Revoked)的Key。',
|
||||
'NOT_FOUND': '操作失败:目标资源不存在或已从本组移除。列表将自动刷新。',
|
||||
'NO_KEYS_MATCH_FILTER': '没有找到任何符合当前过滤条件的Key可供操作。',
|
||||
// You can add many more specific codes here as your application grows.
|
||||
|
||||
'DEFAULT': '操作失败,请稍后重试或联系管理员。'
|
||||
};
|
||||
|
||||
/**
|
||||
* A universal API error handler function.
|
||||
* It inspects an error object, determines the best message to show,
|
||||
* and displays it using the provided toastManager.
|
||||
*
|
||||
* @param {Error|APIClientError} error - The error object caught in a try...catch block.
|
||||
* @param {object} toastManager - The toastManager instance to display notifications.
|
||||
* @param {object} [options={}] - Optional parameters for customization.
|
||||
* @param {string} [options.prefix=''] - A string to prepend to the error message (e.g., "任务启动失败: ").
|
||||
*/
|
||||
export function handleApiError(error, toastManager, options = {}) {
|
||||
const prefix = options.prefix || '';
|
||||
|
||||
// Use the exact same robust logic we developed before.
|
||||
const errorCode = error?.code || 'DEFAULT';
|
||||
const displayMessage = ERROR_MESSAGES[errorCode] || error.rawMessageFromServer || error.message || ERROR_MESSAGES['DEFAULT'];
|
||||
|
||||
toastManager.show(`${prefix}${displayMessage}`, 'error');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user