ZHIWEI666 commited on
Commit
f33a009
·
verified ·
1 Parent(s): 696afc9

Upload 104 files

Browse files
前端页面/core/网络请求_业务API.js CHANGED
@@ -11,6 +11,11 @@ import { request } from "./网络请求_基础设施.js";
11
  import { API } from "./全局配置.js";
12
  import { t } from "../components/用户体验_国际化.js";
13
  import { removeCache } from "./网络请求_缓存管理.js";
 
 
 
 
 
14
 
15
  function publicationHeaders(options) {
16
  if (options?.publicationId === undefined) return undefined;
@@ -62,10 +67,10 @@ const api = {
62
  async updateUserSettings(account, data) { return request(`/api/users/${account}/settings`, { method: "PUT", body: data }); },
63
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
64
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
65
- async getCreators(sort, limit, options = {}) { return request(`/api/creators?sort=${sort}&limit=${limit}`, options); },
66
  // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
67
  // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
68
- async searchCreators(keyword, sort, limit = 50, options = {}) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID, ...options }); },
69
  async getCreatorDetails(account, options = {}) { return request(`/api/creators/${account}/details`, options); },
70
  async uploadFile(file, fileType, timeout = 120000) {
71
  const formData = new FormData();
@@ -77,7 +82,7 @@ const api = {
77
  // NOTE: author 未 encodeURIComponent,与 assignee/account 等编码不一致;
78
  // 但 account 通常为纯字母数字,不影响功能,暂不改动以避免破坏线上缓存匹配
79
  async updateItem(itemId, author, data) { return request(`/api/items/${itemId}?author=${author}`, { method: "PUT", body: data }); },
80
- async getItems(type, sort, limit, options = {}) { return request(`/api/items?type=${type}&sort=${sort}&limit=${limit}`, options); },
81
  async getItemById(itemId, options = {}) { return request(`/api/items/${itemId}`, options); },
82
  async deleteItem(itemId) { return request(`/api/items/${itemId}`, { method: "DELETE" }); },
83
  async recordItemUse(itemId) { return request(`/api/items/${itemId}/use`, { method: "POST" }); },
@@ -168,7 +173,7 @@ const api = {
168
  // 💬 讨论区 API
169
  // ==========================================
170
  async getPosts(page = 1, limit = 20, sort = "latest", options = {}) {
171
- return request(`/api/posts?page=${page}&limit=${limit}&sort=${sort}`, options);
172
  },
173
  async getPostDetail(postId, options = {}) {
174
  return request(`/api/posts/${postId}`, options);
@@ -208,7 +213,7 @@ const api = {
208
  let url = `/api/prompts?page=${page}&limit=${limit}&sort=${sort}`;
209
  if (promptType) url += `&prompt_type=${promptType}`;
210
  if (category) url += `&category=${encodeURIComponent(category)}`;
211
- return request(url, options);
212
  },
213
  async getPromptDetail(promptId, options = {}) {
214
  return request(`/api/prompts/${promptId}`, options);
@@ -268,7 +273,7 @@ const api = {
268
  async getTasks(page = 1, limit = 20, status = null, sort = "latest", options = {}) {
269
  let url = `/api/tasks?page=${page}&limit=${limit}&sort=${sort}`;
270
  if (status) url += `&status=${status}`;
271
- return request(url, options);
272
  },
273
  async getTaskDetail(taskId, options = {}) {
274
  removeCache(`api_/api/tasks/${taskId}`);
 
11
  import { API } from "./全局配置.js";
12
  import { t } from "../components/用户体验_国际化.js";
13
  import { removeCache } from "./网络请求_缓存管理.js";
14
+ import { requireListResponseData } from "../components/性能优化工具.js";
15
+
16
+ function requestPublicList(endpoint, options = {}) {
17
+ return request(endpoint, { ...options, validateResponse: requireListResponseData });
18
+ }
19
 
20
  function publicationHeaders(options) {
21
  if (options?.publicationId === undefined) return undefined;
 
67
  async updateUserSettings(account, data) { return request(`/api/users/${account}/settings`, { method: "PUT", body: data }); },
68
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
69
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
70
+ async getCreators(sort, limit, options = {}) { return requestPublicList(`/api/creators?sort=${sort}&limit=${limit}`, options); },
71
  // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
72
  // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
73
+ async searchCreators(keyword, sort, limit = 50, options = {}) { return requestPublicList(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID, ...options }); },
74
  async getCreatorDetails(account, options = {}) { return request(`/api/creators/${account}/details`, options); },
75
  async uploadFile(file, fileType, timeout = 120000) {
76
  const formData = new FormData();
 
82
  // NOTE: author 未 encodeURIComponent,与 assignee/account 等编码不一致;
83
  // 但 account 通常为纯字母数字,不影响功能,暂不改动以避免破坏线上缓存匹配
84
  async updateItem(itemId, author, data) { return request(`/api/items/${itemId}?author=${author}`, { method: "PUT", body: data }); },
85
+ async getItems(type, sort, limit, options = {}) { return requestPublicList(`/api/items?type=${type}&sort=${sort}&limit=${limit}`, options); },
86
  async getItemById(itemId, options = {}) { return request(`/api/items/${itemId}`, options); },
87
  async deleteItem(itemId) { return request(`/api/items/${itemId}`, { method: "DELETE" }); },
88
  async recordItemUse(itemId) { return request(`/api/items/${itemId}/use`, { method: "POST" }); },
 
173
  // 💬 讨论区 API
174
  // ==========================================
175
  async getPosts(page = 1, limit = 20, sort = "latest", options = {}) {
176
+ return requestPublicList(`/api/posts?page=${page}&limit=${limit}&sort=${sort}`, options);
177
  },
178
  async getPostDetail(postId, options = {}) {
179
  return request(`/api/posts/${postId}`, options);
 
213
  let url = `/api/prompts?page=${page}&limit=${limit}&sort=${sort}`;
214
  if (promptType) url += `&prompt_type=${promptType}`;
215
  if (category) url += `&category=${encodeURIComponent(category)}`;
216
+ return requestPublicList(url, options);
217
  },
218
  async getPromptDetail(promptId, options = {}) {
219
  return request(`/api/prompts/${promptId}`, options);
 
273
  async getTasks(page = 1, limit = 20, status = null, sort = "latest", options = {}) {
274
  let url = `/api/tasks?page=${page}&limit=${limit}&sort=${sort}`;
275
  if (status) url += `&status=${status}`;
276
+ return requestPublicList(url, options);
277
  },
278
  async getTaskDetail(taskId, options = {}) {
279
  removeCache(`api_/api/tasks/${taskId}`);
前端页面/core/网络请求_基础设施.js CHANGED
@@ -1,946 +1,971 @@
1
- // 前端页面/core/网络请求_基础设施.js
2
- // ==========================================
3
- // 🌐 网络请求基础设施模块
4
- // ==========================================
5
- // 作用:提供底层网络请求能力,包括缓存、重试、错误处理、请求取消等
6
- // 关联文件:
7
- // - 全局配置.js (配置中心)
8
- // - 网络请求_缓存管理.js (缓存管理)
9
- // - 网络请求_图片代理.js (图片代理)
10
- // ==========================================
11
-
12
- import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
- import { API, CACHE } from "./全局配置.js";
14
- import { beginLocalPerformanceSample } from "./本机性能统计.js";
15
- import { eventBus, EVENTS, logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
16
- import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache, isPrivateApiGet, captureCacheWrite, captureCacheSource } from "./网络请求_缓存管理.js";
17
- import { unproxyImages } from "./网络请求_图片代理.js";
18
- // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
19
- // 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
20
- import { t } from "../components/用户体验_国际化.js";
21
-
22
- // 🚀 动态时序导入:proxyImages 通过动态 import() 获取,避免模块初始化时序问题
23
- let _proxyImages = null;
24
- async function getProxyImages() {
25
- if (!_proxyImages) {
26
- const mod = await import("./网络请求_图片代理.js");
27
- _proxyImages = mod.proxyImages;
28
- }
29
- return _proxyImages;
30
- }
31
-
32
- // 🏗️ P2架构优化:使用配置中心的 API 地址
33
- const BASE_URL = API.BASE_URL;
34
-
35
- // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
36
- const pendingRequests = new Map();
37
- const cacheReadOwners = new Map();
38
- const pendingRequestGuards = new WeakMap();
39
- const responseCacheSources = new WeakMap();
40
-
41
- function _cachedResponse(value, source) {
42
- if (value && typeof value === 'object') responseCacheSources.set(value, { source });
43
- return value;
44
- }
45
-
46
- export function getResponseCacheSource(value) {
47
- return responseCacheSources.get(value) || null;
48
- }
49
-
50
-
51
- function withRequestDiagnostic(error, response, data = null) {
52
- let headerId;
53
- try { headerId = response.headers?.get?.(API.REQUEST_ID_HEADER); } catch {}
54
- for (const value of [headerId, data?.error_id]) {
55
- if (typeof value === 'string' && API.REQUEST_ID_PATTERN.test(value)) {
56
- error.requestId = value;
57
- break;
58
- }
59
- }
60
- return error;
61
- }
62
-
63
- function httpErrorMessage(data, status) {
64
- if (typeof data.detail === 'string' && Object.hasOwn(API.REQUEST_BODY_ERRORS, data.detail)) {
65
- const rule = API.REQUEST_BODY_ERRORS[data.detail];
66
- if (rule.status === status) return t(rule.msgKey);
67
- }
68
- if (Array.isArray(data.detail)) return t('feedback.invalid_request_data');
69
- for (const value of [data.detail, data.message, data.error]) {
70
- if (typeof value === 'string' && value.trim()) return value;
71
- }
72
- return t('feedback.http_request_failed', { status });
73
- }
74
-
75
- // 🚀 P3优化:网络状态由 状态管理.js 的 isOnline() 统一提供(见下方离线模式支持),
76
- // 本模块不再自行注册 online/offline 监听。
77
-
78
- // 🔧 修复:401(Token 过期/签名无效)统一处理——清除本地凭证并提示重新登录。
79
- // 背景:服务端更换 JWT_SECRET 或 Token 过期后,旧 Token 被云端拒签,若不清除
80
- // 本地凭证会导致所有登录态操作反复报「Token 签名无效」。去重窗口:并发请求
81
- // 同时收到 401 时只提示一次。
82
- let _authExpiredAt = 0;
83
- function _safeStorageGet(storageName, key) {
84
- try {
85
- const storage = window[storageName];
86
- return storage ? storage.getItem(key) : null;
87
- } catch (_) {
88
- return null;
89
- }
90
- }
91
-
92
- function _safeGetAuthToken() {
93
- return _safeStorageGet('localStorage', 'ComfyCommunity_Token')
94
- || _safeStorageGet('sessionStorage', 'ComfyCommunity_Token')
95
- || '';
96
- }
97
-
98
- /** 与实际 Authorization 使用同一存储读取;仅返回比较函数,不暴露凭证。 */
99
- export function captureRequestSession() {
100
- const originalToken = _safeGetAuthToken();
101
- return () => originalToken === _safeGetAuthToken();
102
- }
103
-
104
- // 仅跟踪私有 GET;会话撤销不取消或重放已提交的业务写操作。
105
- const activePrivateReads = new Set();
106
- function revokePrivateReads(force = false) {
107
- for (const entry of activePrivateReads) {
108
- if (force || !entry.matches()) {
109
- entry.revoked = true;
110
- entry.controller?.abort();
111
- }
112
- }
113
- }
114
- for (const event of ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired']) {
115
- window.addEventListener(event, () => revokePrivateReads(true));
116
- }
117
- window.addEventListener('storage', event => {
118
- if (event.key === null || event.key === CACHE.LEGACY_KEYS.USER) revokePrivateReads(true);
119
- else if (event.key === CACHE.LEGACY_KEYS.TOKEN) revokePrivateReads();
120
- });
121
- eventBus.on(EVENTS.USER_LOGIN, () => revokePrivateReads(true));
122
- eventBus.on(EVENTS.USER_LOGOUT, () => revokePrivateReads(true));
123
-
124
- function _safeClearAuthStorage() {
125
- for (const storageName of ['localStorage', 'sessionStorage']) {
126
- try {
127
- const storage = window[storageName];
128
- storage?.removeItem('ComfyCommunity_Token');
129
- storage?.removeItem('ComfyCommunity_User');
130
- } catch (_) {
131
- // 浏览器禁用 Storage 时仍继续完成内存登出和事件通知。
132
- }
133
- }
134
- }
135
-
136
- function notifyTokenExpired() {
137
- const now = Date.now();
138
- if (now - _authExpiredAt < 5000) return;
139
- _authExpiredAt = now;
140
- console.warn("⚠️ 登录凭证已失效,本地凭证已清除,请重新登录");
141
- // 动态 import UI 组件,与 proxyImages 同样避免模块初始化时序问题
142
- import("../components/UI交互提示组件.js").then(async ({ showToast }) => {
143
- const { t } = await import("../components/用户体验_国际化.js");
144
- showToast(t('auth.token_expired'), "warning", 4000);
145
- }).catch(() => {});
146
- }
147
-
148
- function handleAuthExpired(failedToken) {
149
- // 竞态防护:若用户在 401 返回前已重新登录(存储中的 Token 已更换),
150
- // 不清理新凭证;失败请求未携带 Token 时 401 与登录态无关,同样不打扰
151
- if (!failedToken) return false;
152
- const currentToken = _safeGetAuthToken();
153
- if (currentToken !== failedToken) return false;
154
- // 清除前取出账号,用于清理账号级 Profile 缓存
155
- let account = null;
156
- try {
157
- const userStr = _safeStorageGet('localStorage', "ComfyCommunity_User")
158
- || _safeStorageGet('sessionStorage', "ComfyCommunity_User");
159
- if (userStr) account = JSON.parse(userStr)?.user?.account || null;
160
- } catch (e) {}
161
- // 与手动登出同深度的完整清理:Token/User、私有数据缓存、内存缓存与安装版本戳
162
- logoutAndClearUserData(account);
163
- notifyTokenExpired();
164
- return true;
165
- }
166
-
167
- // 🚀 P4优化:请求取消管理器
168
- const requestCancelManager = {
169
- _controllers: new Map(), // {componentId: Set<AbortController>}
170
-
171
- /**
172
- * 创建并注册 AbortController
173
- * @param {string} componentId - 组件 ID
174
- * @returns {AbortController}
175
- */
176
- create(componentId = '_global') {
177
- if (!this._controllers.has(componentId)) {
178
- this._controllers.set(componentId, new Set());
179
- }
180
- const controller = new AbortController();
181
- this._controllers.get(componentId).add(controller);
182
- return controller;
183
- },
184
-
185
- /**
186
- * 移除已完成的 controller
187
- */
188
- remove(componentId, controller) {
189
- const controllers = this._controllers.get(componentId);
190
- if (controllers) {
191
- controllers.delete(controller);
192
- }
193
- },
194
-
195
- /**
196
- * 取消指定组件的所有请求
197
- * @param {string} componentId - 组件 ID
198
- */
199
- cancelAll(componentId) {
200
- const controllers = this._controllers.get(componentId);
201
- if (controllers) {
202
- const count = controllers.size;
203
- for (const controller of controllers) {
204
- controller.abort();
205
- }
206
- controllers.clear();
207
- // 🔧 仅在实际撤销了请求时打日志:本方法会被搜索框的每一次输入触发,
208
- // 绝大多数时候并没有在途请求,无条件打印会刷出大量无意义日志
209
- if (count > 0) {
210
- console.log(`🚫 已取消组件 [${componentId}] 的 ${count} 个在途请求`);
211
- }
212
- }
213
- },
214
-
215
- /**
216
- * 取消所有请求
217
- */
218
- cancelAllGlobal() {
219
- for (const [componentId, controllers] of this._controllers) {
220
- for (const controller of controllers) {
221
- controller.abort();
222
- }
223
- controllers.clear();
224
- }
225
- console.log('🚫 已取消所有进行中的请求');
226
- },
227
-
228
- /**
229
- * 获取统计信息
230
- */
231
- getStats() {
232
- let total = 0;
233
- for (const controllers of this._controllers.values()) {
234
- total += controllers.size;
235
- }
236
- return { components: this._controllers.size, activeRequests: total };
237
- }
238
- };
239
-
240
- // 🚀 P3优化:请求队列管理(限制并发数)
241
- const requestQueue = {
242
- maxConcurrent: 6, // 浏览器同域名默认限制 6 个并发
243
- running: 0,
244
- pending: [],
245
-
246
- async add(fn, signal) {
247
- signal.throwIfAborted();
248
- // 如果还有槽位,直接执行
249
- if (this.running < this.maxConcurrent) {
250
- this.running++;
251
- try {
252
- return await fn();
253
- } finally {
254
- this.running--;
255
- this._processNext();
256
- }
257
- }
258
-
259
- // 否则加入队列等待
260
- return new Promise((resolve, reject) => {
261
- const onAbort = () => {
262
- const index = this.pending.indexOf(run);
263
- if (index !== -1) this.pending.splice(index, 1);
264
- reject(signal.reason);
265
- };
266
- const run = async () => {
267
- signal.removeEventListener('abort', onAbort);
268
- try {
269
- signal.throwIfAborted();
270
- resolve(await fn());
271
- } catch (e) {
272
- reject(e);
273
- }
274
- };
275
- this.pending.push(run);
276
- signal.addEventListener('abort', onAbort, { once: true });
277
- });
278
- },
279
-
280
- _processNext() {
281
- if (this.pending.length > 0 && this.running < this.maxConcurrent) {
282
- const next = this.pending.shift();
283
- this.running++;
284
- next().finally(() => {
285
- this.running--;
286
- this._processNext();
287
- });
288
- }
289
- },
290
-
291
- getStats() {
292
- return { running: this.running, pending: this.pending.length };
293
- }
294
- };
295
-
296
- async function request(endpoint, options = {}) {
297
- const sample = beginLocalPerformanceSample(endpoint, options.method || 'GET');
298
- try { return await performRequest(endpoint, options, sample); }
299
- finally { sample?.finish(); }
300
- }
301
-
302
- async function performRequest(endpoint, options = {}, sample = null) {
303
- const privateRead = (options.method || "GET").toUpperCase() === "GET" && isPrivateApiGet(endpoint);
304
- if (privateRead) {
305
- removeCache(`api_${endpoint}`);
306
- options = { ...options, noStore: true };
307
- }
308
- // 私有详情显式不留存:同时绕过响应命中/回退/去重、插件写缓存和浏览器缓存。
309
- if (options.noStore) options = { ...options, noCache: true, cache: "no-store" };
310
- const url = `${BASE_URL}${endpoint}`;
311
- const method = (options.method || "GET").toUpperCase();
312
- const headers = { ...options.headers };
313
- if (!(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; }
314
- const token = _safeGetAuthToken();
315
- const privateEntry = privateRead ? { revoked: false, controller: null, matches: () => token === _safeGetAuthToken() } : null;
316
- const assertReadSession = () => {
317
- if (privateRead && (privateEntry.revoked || !privateEntry.matches())) {
318
- const error = new Error(t('auth.session_changed'));
319
- error.name = 'SessionChangedError';
320
- error.isCancelled = true;
321
- throw error;
322
- }
323
- };
324
- if (token) {
325
- const tokenParts = token.split(".");
326
- if (tokenParts.length === 3) {
327
- // 标准 JWT 格式
328
- headers["Authorization"] = `Bearer ${token}`;
329
- } else {
330
- // 🔒 P0同步:旧版 mock_token 已被后端明确拒绝(伪造后门已封堵),
331
- // 以及其他无法识别的格式,统一清除本地旧凭证并提示重新登录。
332
- // 🔧 修复:凭证既已清除,同步广播登出事件重置导航登录态/停止轮询,
333
- // 并给用户明确提示(原实现仅 console.warn 静默清理)
334
- console.warn("⚠️ 检测到无效/已过期的旧版 Token,已自动清除,请重新登录");
335
- _safeClearAuthStorage();
336
- window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
337
- notifyTokenExpired();
338
- }
339
- }
340
-
341
- // 🚀 P4优化:组件级请求取消支持
342
- const componentId = options.componentId || '_global';
343
-
344
- // ⚡ P1性能优化:GET 请求缓存检查
345
- // 🔑 P0修复:cacheKey 必须包含完整 endpoint(含查询参数),不能截断 '?' 之后的部分
346
- // 例:'/api/items?type=tool&sort=time' 和 '/api/items?type=tool&sort=views' 必须产生不同的缓存 Key
347
- const cacheKey = `api_${endpoint}`;
348
- const isRequestCacheCurrent = method === "GET" && !options.noStore ? captureCacheWrite(cacheKey) : null;
349
- let isCacheWriteCurrent = isRequestCacheCurrent;
350
- const trackPendingRequest = method === "GET" && !options.noCache;
351
- const trackCacheOwner = method === "GET" && !options.noStore;
352
- if (method === "GET" && !options.noCache) {
353
- const cached = getCache(cacheKey);
354
- if (cached) {
355
- const source = captureCacheSource(cacheKey);
356
- const proxyFn = await getProxyImages();
357
- sample?.cacheHit();
358
- return _cachedResponse(proxyFn(cached), source);
359
- }
360
- }
361
-
362
- // 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
363
- // ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
364
- // ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
365
- const fallbackMeta = (method === "GET" && !options.noCache)
366
- ? getCacheWithMeta(cacheKey, true) // 忽略过期
367
- : { value: null, expired: false, found: false };
368
- const conditionalRead = method === "GET" && !options.noStore && options.credentials !== "include"
369
- && !Object.keys(headers).some(name => name.toLowerCase() === "authorization")
370
- && API.PUBLIC_CONDITIONAL_GET_PATHS.includes(endpoint.split(/[?#]/)[0]);
371
- const conditionalMeta = conditionalRead ? getCacheWithMeta(cacheKey, true) : null;
372
- if (conditionalRead) {
373
- for (const name of Object.keys(headers)) if (name.toLowerCase() === "if-none-match") delete headers[name];
374
- if (conditionalMeta?.found && conditionalMeta.etag) headers["If-None-Match"] = conditionalMeta.etag;
375
- }
376
-
377
- // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
378
- // ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
379
- // 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
380
- // 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
381
- // 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
382
- // 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
383
- // noCache 只代表绕过该 endpoint 的响应缓存,不得绕过共享网络熔断;榜单强制刷新
384
- // 仍由列表层先显示自己的缓存,冷却期内直接失败返回,避免切换榜单逐个重试 HF。
385
- if (method === "GET" && (!isOnline() || !isCloudReachable())) {
386
- if (fallbackMeta.found) {
387
- console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存`);
388
- const source = captureCacheSource(cacheKey);
389
- const proxyFn2 = await getProxyImages();
390
- sample?.cacheHit();
391
- return _cachedResponse(proxyFn2(fallbackMeta.value), source);
392
- }
393
- // 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
394
- // 导致英文界面下仍弹中文提示;现改走词典。云端不可达与本机断网分开用词,
395
- // 否则本机明明有网的用户会被「网络已断开」误导去查自己的路由器
396
- throw new Error(isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache'));
397
- }
398
-
399
- // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
400
- if (trackPendingRequest) {
401
- const pending = pendingRequests.get(cacheKey);
402
- if (pending && pendingRequestGuards.get(pending)?.() && cacheReadOwners.get(cacheKey) === pending) {
403
- sample?.discard();
404
- return pending;
405
- }
406
- }
407
-
408
- const fetchOptions = { ...options, method, headers };
409
-
410
- // 🚀 核心修改:在将 body 转为 JSON 字符串发送前,执行无情剥离!
411
- if (options.body && !(options.body instanceof FormData) && typeof options.body !== "string") {
412
- fetchOptions.body = JSON.stringify(unproxyImages(options.body));
413
- } else if (options.body instanceof FormData) {
414
- fetchOptions.body = options.body;
415
- }
416
-
417
- // 🚀 P1优化:重试与超时预算
418
- // 🔧 修复:重试次数/间隔此前硬编码在本文件(2 / 1000),使 全局配置.js 的
419
- // API.MAX_RETRIES、API.RETRY_DELAY 成为死配置,现统一读取配置中心
420
- // 🐢 弱网预算分级:列表/搜索类 GET 有本地缓存兜底,失败上限适当收紧,
421
- // 避免弱网下用户长时间盯骨架屏;按去掉查询串的路径全匹配,
422
- // 不会误伤 /api/items/{id}、/api/creators/{account}/details 等详情请求
423
- const endpointPath = endpoint.split("?")[0];
424
- let defaultTimeout = API.TIMEOUT;
425
- let defaultRetries = method === "GET" ? API.MAX_RETRIES : 0; // 非 GET 不重试
426
- if (method === "GET") {
427
- if (endpointPath === "/api/creators/search") {
428
- defaultTimeout = API.SEARCH_TIMEOUT;
429
- defaultRetries = API.SEARCH_RETRIES;
430
- } else if (endpointPath === "/api/items" || endpointPath === "/api/creators") {
431
- defaultRetries = API.LIST_RETRIES;
432
- }
433
- }
434
- // 🐢 后台补新短预算:本地已有该请求的缓存,说明列表视图已经把内容渲染上屏了
435
- // (七大列表都是「本地数据先上屏 → 再发请求补新」),这次请求纯属后台行为,
436
- // 失败要快——它每多挂 1 秒就多占 1 个并发额度(全局仅 6 个)。用 Math.min 只收不放,
437
- // 不会把搜索类已经收紧过的 10s 预算又放宽回去。
438
- // noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
439
- // 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
440
- const useCacheBudget = method === "GET" && !options.noCache && fallbackMeta.found;
441
- if (useCacheBudget) {
442
- defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
443
- defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
444
- }
445
- const requestTimeout = options.timeout || defaultTimeout;
446
- const maxRetries = options.retries ?? defaultRetries;
447
- const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
448
-
449
- // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
450
- if (privateEntry) activePrivateReads.add(privateEntry);
451
- const requestPromise = (async () => {
452
- let lastError = null;
453
- // ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
454
- // 5xx 不置位:服务端已经应答说明云端可达,若也打冷却会让一个坏接口把全站 GET 拖下水
455
- let networkLevelFailure = false;
456
-
457
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
458
- assertReadSession();
459
- // 🚀 P1优化:指数退避延迟
460
- if (attempt > 0) {
461
- // ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
462
- // 继续重试只是白烧一轮超时预算、白占一个并发额度,直接跳去下方缓存兜底
463
- if (method === "GET" && !isCloudReachable()) break;
464
- const delay = retryDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s...
465
- console.log(`🔄 请求重试 (${attempt}/${maxRetries})...`);
466
- await new Promise(r => setTimeout(r, delay));
467
- }
468
-
469
- // 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
470
- const controller = requestCancelManager.create(componentId);
471
- if (privateEntry) privateEntry.controller = controller;
472
- const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
473
- let timeoutId = null;
474
- let timedOut = false;
475
- let fetchStarted = false;
476
- let transportFailed = false;
477
- const onTimeout = () => { timedOut = true; controller.abort(); };
478
- // 已有缓存时排队也计入预算,避免被无缓存的慢请求无限拖住。
479
- if (useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
480
-
481
- try {
482
- const result = await requestQueue.add(async () => {
483
- if (!useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
484
- // 请求可能在云端故障被另一个并发请求确认前进入队列;出队发出 fetch
485
- // 前复查共享状态,避免队列继续逐个访问已知不可达的 HF endpoint。
486
- if (method === "GET" && (!isOnline() || !isCloudReachable())) {
487
- const unavailableError = new Error(
488
- isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache')
489
- );
490
- unavailableError.name = 'CloudUnavailableError';
491
- throw unavailableError;
492
- }
493
- assertReadSession();
494
- fetchStarted = true;
495
- try {
496
- if (isRequestCacheCurrent) isCacheWriteCurrent = captureCacheWrite(cacheKey);
497
- let response = await fetch(url, currentFetchOptions);
498
- let validatedData;
499
- if (response.status === 304 && conditionalRead) {
500
- const currentMeta = getCacheWithMeta(cacheKey, true);
501
- if (conditionalMeta?.found && conditionalMeta.etag
502
- && response.headers?.get?.('ETag') === conditionalMeta.etag
503
- && currentMeta.found && currentMeta.etag === conditionalMeta.etag
504
- && currentMeta.value === conditionalMeta.value) {
505
- sample?.cacheHit();
506
- validatedData = currentMeta.value;
507
- } else {
508
- // 缓存可能在等待期间被清理/写操作失效;同一预算内补取完整响应。
509
- const fullHeaders = { ...currentFetchOptions.headers };
510
- for (const name of Object.keys(fullHeaders)) if (name.toLowerCase() === "if-none-match") delete fullHeaders[name];
511
- if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
512
- isCacheWriteCurrent = captureCacheWrite(cacheKey);
513
- response = await fetch(url, { ...currentFetchOptions, headers: fullHeaders, cache: "no-store" });
514
- if (response.status === 304) throw new SyntaxError('Unexpected not-modified response');
515
- }
516
- }
517
- let data;
518
- let bodyConfirmed = true;
519
- try {
520
- data = validatedData !== undefined ? validatedData
521
- : response.status === 204 ? {} : await response.json();
522
- } catch (error) {
523
- // 错误响应的正文不可读时仍按 HTTP 状态处理,不能用缓存掩盖 401/403。
524
- if (!response.ok && (error instanceof SyntaxError || error instanceof TypeError || (timedOut && error.name === 'AbortError'))) {
525
- data = {};
526
- bodyConfirmed = false;
527
- } else {
528
- throw error;
529
- }
530
- }
531
- return { response, data, bodyConfirmed, validated: validatedData !== undefined };
532
- } catch (error) {
533
- transportFailed = error instanceof TypeError;
534
- throw error;
535
- }
536
- }, controller.signal);
537
- clearTimeout(timeoutId);
538
- requestCancelManager.remove(componentId, controller);
539
- const { response } = result;
540
- let responseData = result.data;
541
- assertReadSession();
542
-
543
- if (!response.ok && !result.validated) {
544
- if (!responseData || typeof responseData !== "object" || Array.isArray(responseData)) responseData = {};
545
- const errorMsg = httpErrorMessage(responseData, response.status);
546
-
547
- // 🚀 P1优化:4xx 错误不重试,5xx 错误可重试
548
- if (response.status >= 400 && response.status < 500) {
549
- if (response.status === 401) {
550
- // 🔧 修复:清除失效凭证并提示重新登录(原实现仅广播事件,无人清理);
551
- // 传入本请求实际使用的 Token 做竞态比对,避免误清重新登录后签发的新凭证。
552
- // 仅当确认凭证确实失效后才广播登出事件(导航停止轮询并重置登录态),
553
- // 迟到的旧 401 或匿名请求的 401 不再误触发登录态重置。
554
- // 登录接口的 401 语义是「密码错误」而非凭证失效,不走全局清理,
555
- // 避免与登录表单自身的错误提示重复叠加
556
- if (endpoint !== "/api/users/login" && handleAuthExpired(token)) {
557
- window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
558
- }
559
- }
560
- const error = withRequestDiagnostic(new Error(errorMsg), response, responseData);
561
- error.status = response.status;
562
- if (typeof responseData.detail === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(responseData.detail)) error.code = responseData.detail;
563
- if (result.bodyConfirmed && Object.hasOwn(API.REQUEST_BODY_ERRORS, error.code)
564
- && API.REQUEST_BODY_ERRORS[error.code].status === error.status) {
565
- error.requestBodyRejected = true;
566
- }
567
- throw error;
568
- }
569
-
570
- // 应用明确标记且正文可读的503只影响当前接口,仍允许其它榜单联网。
571
- if (response.status === 503 && result.bodyConfirmed &&
572
- response.headers?.get?.(API.ENDPOINT_ERROR_SCOPE_HEADER) === API.ENDPOINT_ERROR_SCOPE_VALUE) {
573
- lastError = withRequestDiagnostic(new Error(errorMsg), response, responseData);
574
- lastError.status = response.status;
575
- networkLevelFailure = false;
576
- break;
577
- }
578
-
579
- // Hugging Face Space / 网关不可用通常以 502、503 或 504 响应,
580
- // 这类响应证明请求到达了代理层,但云端当前无法提供服务。
581
- // 与超时/连接失败一样共享冷却,避免切换榜单时每个 endpoint
582
- // 都重复访问一次;普通 500 仍只视为单接口服务端错误。
583
- if ([502, 503, 504].includes(response.status)) {
584
- lastError = withRequestDiagnostic(new Error(errorMsg), response, responseData);
585
- lastError.status = response.status;
586
- networkLevelFailure = true;
587
- markCloudDown(endpoint);
588
- break;
589
- }
590
-
591
- lastError = withRequestDiagnostic(new Error(errorMsg), response, responseData);
592
- lastError.status = response.status;
593
- continue; // 5xx 错误重试
594
- }
595
-
596
- // ☁️ 拿到了正常应答即证明云端可达,立即清零冷却(不必等它自然过期)。
597
- // 非 GET 的成功同样算数——写操作是冷却期内唯一还在联网的请求,天然是探针
598
- markCloudUp();
599
-
600
- // 🚀 P1优化:精确清除相关缓存(替代暴力清空)
601
- if (["POST", "PUT", "DELETE"].includes(method)) {
602
- invalidateRelatedCache(endpoint, method);
603
- }
604
-
605
- // ⚡ P1性能优化:GET 请求结果缓存
606
- if (method === "GET" && !options.noStore && isCacheWriteCurrent() && cacheReadOwners.get(cacheKey) === requestPromise) {
607
- const ttl = _getCacheTTL(endpoint);
608
- if (ttl > 0) {
609
- // 创作者列表不持久化到 localStorage,避免空间不足
610
- const persist = !endpoint.includes('/api/creators');
611
- setCache(cacheKey, responseData, ttl, persist,
612
- conditionalRead ? { etag: response.headers?.get?.('ETag') } : {});
613
- }
614
- }
615
-
616
- // 入口数据挂载代理
617
- const proxyFn3 = await getProxyImages();
618
- assertReadSession();
619
- responseData = proxyFn3(responseData);
620
- if (responseData && typeof responseData === "object") {
621
- responseCacheSources.delete(responseData);
622
- if (trackCacheOwner && (!isCacheWriteCurrent() || cacheReadOwners.get(cacheKey) !== requestPromise)) responseCacheSources.set(responseData, { source: null });
623
- }
624
- return responseData;
625
- } catch (error) {
626
- clearTimeout(timeoutId); // 清除超时计时器
627
- // 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
628
- // 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
629
- requestCancelManager.remove(componentId, controller);
630
- assertReadSession();
631
-
632
- // 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
633
- // 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
634
- // 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
635
- // 直接抛出而不走循环后的「过期缓存兜底」,避免把作废请求的结果上屏
636
- if (error.name === 'AbortError' && !timedOut) {
637
- const cancelErr = new Error(t('feedback.request_cancelled'));
638
- cancelErr.name = 'RequestCancelledError';
639
- cancelErr.isCancelled = true;
640
- throw cancelErr;
641
- }
642
-
643
- // 队列等待期间已有并发请求确证网络不可达:该请求尚未发出 fetch,
644
- // 直接交给统一缓存兜底,不重试、不延长冷却,也不把队列错误当作网络探测。
645
- if (error.name === 'CloudUnavailableError') {
646
- lastError = error;
647
- break;
648
- }
649
-
650
- // 🔧 P1优化:可重试的错误类型
651
- const isRetryable = (
652
- error.name === 'AbortError' ||
653
- transportFailed
654
- );
655
-
656
- if (isRetryable) {
657
- // 🔧 修复:重试耗尽时不再直接 throw(否则循环后的「过期缓存兜底」
658
- // 分支永远不可达,断网时无法无缝切换本地数据)。记录友好错误后跳出循环,
659
- // 让 GET 请求走过期缓存回退;无缓存时仍以该友好错误抛出。
660
- const isTimeout = error.name === 'AbortError';
661
- lastError = isTimeout
662
- ? new Error(t('feedback.network_timeout'))
663
- : new Error(t('feedback.network_unavailable'));
664
- lastError.code = isTimeout ? 'network_timeout' : 'network_unavailable';
665
- if (fetchStarted) networkLevelFailure = true;
666
- // ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
667
- // 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
668
- // 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫���级的,
669
- // 多等一轮只多花约 1 秒,却能挡住偶发抖动误伤 60 秒);
670
- // 未配重试时(后台补新短预算,界面已用缓存上屏)误判零代价,立即定论。
671
- // 超时则可能是 HF Space 冷启动(慢但能成功),不在此定论,耗尽后由下方统一处理
672
- if (!isTimeout && (attempt >= 1 || maxRetries === 0)) markCloudDown(endpoint);
673
- if (attempt < maxRetries) continue; // 重试
674
- break; // 重试耗尽 → 跳到过期缓存兜底
675
- }
676
-
677
- // 🔧 P3优化:错误分类处理,提供更清晰的错误信息(不可重试错误直接抛出)
678
- if (error instanceof SyntaxError) {
679
- throw new Error(t('feedback.response_format_error'));
680
- }
681
- throw error;
682
- }
683
- }
684
-
685
- // ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
686
- // 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
687
- if (networkLevelFailure) markCloudDown(endpoint);
688
-
689
- // 所有重试都失败,尝试回退到过期缓存
690
- if (method === "GET" && !options.noCache) {
691
- const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期
692
- if (fallback.found) {
693
- console.warn('📴 网络请求失败,回退到本地缓存');
694
- const source = captureCacheSource(cacheKey);
695
- const proxyFn4 = await getProxyImages();
696
- sample?.cacheHit();
697
- return _cachedResponse(proxyFn4(fallback.value), source); // 返回过期缓存数据
698
- }
699
- }
700
- // 仍无缓存,才抛异常
701
- throw lastError || new Error(t('feedback.error'));
702
- })();
703
-
704
- if (privateEntry) requestPromise.finally(() => activePrivateReads.delete(privateEntry)).catch(() => {});
705
-
706
- if (trackCacheOwner) {
707
- cacheReadOwners.set(cacheKey, requestPromise);
708
- requestPromise.finally(() => {
709
- if (cacheReadOwners.get(cacheKey) === requestPromise) cacheReadOwners.delete(cacheKey);
710
- }).catch(() => {});
711
- }
712
-
713
- // 记录进行中的请求
714
- if (trackPendingRequest) {
715
- pendingRequests.set(cacheKey, requestPromise);
716
- pendingRequestGuards.set(requestPromise, isRequestCacheCurrent);
717
- }
718
-
719
- // 确保请求完成后清除去重记录
720
- // 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
721
- // 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
722
- // 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
723
- if (trackPendingRequest) {
724
- requestPromise.finally(() => {
725
- if (pendingRequests.get(cacheKey) === requestPromise) pendingRequests.delete(cacheKey);
726
- }).catch(() => {});
727
- }
728
-
729
- return requestPromise;
730
- }
731
-
732
- // ==========================================
733
- // 📡 SSE 流式请求(用于安装进度等长时间操作)
734
- // ==========================================
735
-
736
- /**
737
- * 发起 SSE 流式请求(用于安装进度等长时间操作)
738
- * @param {string} endpoint - API端点路径(本地ComfyUI路径,如 /community_hub/install_tool_stream)
739
- * @param {Object} body - POST请求体
740
- * @param {Function} onProgress - 进度回调 ({stage, progress, message, status, data}) => void
741
- * @param {Object} [options] - 可选配置 { timeout: 300000 }
742
- * @returns {Promise<Object>} 最终结果事件
743
- */
744
- export async function requestSSE(endpoint, body, onProgress, options = {}) {
745
- const timeout = options.timeout || 1200000; // 20分钟全局超时(大型工具克隆需要较长时间)
746
- const controller = new AbortController();
747
- const globalTimer = setTimeout(() => controller.abort(), timeout);
748
- const externalSignal = options.signal;
749
- const abortFromCaller = () => controller.abort();
750
- if (externalSignal?.aborted) controller.abort();
751
- else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
752
-
753
- // 📡 空闲超时检测:长时间无数据视为连接已断
754
- const IDLE_TIMEOUT_DEFAULT = 30000; // 30 秒无数据视为空闲
755
- const IDLE_TIMEOUT_GRACE = 5000; // 收到 finalResult 后的宽限期 5 秒
756
- let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
757
- let idleTimer = null;
758
- let finalResult = null;
759
- let streamReader = null;
760
-
761
- const clearIdleTimer = () => {
762
- if (idleTimer !== null) {
763
- clearTimeout(idleTimer);
764
- idleTimer = null;
765
- }
766
- };
767
-
768
- try {
769
- const response = await fetch(endpoint, {
770
- method: "POST",
771
- headers: { "Content-Type": "application/json" },
772
- body: JSON.stringify(body),
773
- signal: controller.signal
774
- });
775
-
776
- if (!response.ok) {
777
- const error = withRequestDiagnostic(new Error(t('feedback.http_request_failed', { status: response.status })), response);
778
- error.status = response.status;
779
- // 仅当流式路由明确不存在/不接受该方法时,调用方可安全回退到旧端点。
780
- // 网络错误、超时和服务端错误都可能发生在操作已开始后,不可自动重发。
781
- error.fallbackSafe = response.status === 404 || response.status === 405;
782
- throw error;
783
- }
784
-
785
- const reader = streamReader = response.body.getReader();
786
- const decoder = new TextDecoder('utf-8', { fatal: true });
787
- const decodeSSEChunk = (value, options) => {
788
- try {
789
- return decoder.decode(value, options);
790
- } catch (_) {
791
- const error = new Error(t('feedback.stream_invalid_encoding'));
792
- error.code = 'invalid_stream_encoding';
793
- error.outcomeUnknown = true;
794
- throw error;
795
- }
796
- };
797
- let totalBytes = 0, lineBytes = 0, lineCount = 0;
798
- let workBytes = 0, workLines = 0, workChunks = 0;
799
- const lineParts = [];
800
- const limitError = () => {
801
- const error = new Error(t('feedback.stream_limit_exceeded'));
802
- error.code = 'stream_limit_exceeded';
803
- error.outcomeUnknown = true;
804
- return error;
805
- };
806
- const appendLinePart = (part) => {
807
- if (!part) return;
808
- let level = 0;
809
- while (lineParts[level] !== undefined) {
810
- part = lineParts[level] + part;
811
- lineParts[level++] = undefined;
812
- }
813
- lineParts[level] = part;
814
- };
815
- const takeLine = () => {
816
- const line = lineParts.slice().reverse().filter(part => part !== undefined).join('');
817
- lineParts.length = 0;
818
- return line;
819
- };
820
- const yieldIfNeeded = async () => {
821
- if (workBytes >= API.SSE_YIELD_BYTES || workLines >= API.SSE_YIELD_LINES || workChunks >= API.SSE_YIELD_CHUNKS) {
822
- workBytes = workLines = workChunks = 0;
823
- await new Promise(resolve => setTimeout(resolve, 0));
824
- }
825
- if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
826
- };
827
- const checkChunkBudget = (value) => {
828
- totalBytes += value.byteLength;
829
- if (totalBytes > API.SSE_MAX_RESPONSE_BYTES) throw limitError();
830
- let start = 0, end;
831
- while ((end = value.indexOf(10, start)) !== -1) {
832
- lineBytes += end - start;
833
- if (lineBytes > API.SSE_MAX_LINE_BYTES) throw limitError();
834
- lineBytes = 0;
835
- start = end + 1;
836
- }
837
- lineBytes += value.byteLength - start;
838
- if (lineBytes > API.SSE_MAX_LINE_BYTES) throw limitError();
839
- };
840
- const processSSELine = (line) => {
841
- if (!line.startsWith("data: ")) return;
842
- let data;
843
- try {
844
- data = JSON.parse(line.slice(6));
845
- } catch (_) {
846
- console.warn("[SSE] 解析事件失败");
847
- return;
848
- }
849
- if (!data || typeof data !== "object" || Array.isArray(data)) {
850
- console.warn("[SSE] 忽略非对象事件");
851
- return;
852
- }
853
- if (data.status === "success" || data.status === "error") {
854
- // 先保存最终结果,UI 更新回调即使异常也不能丢失服务端结果。
855
- finalResult = data;
856
- currentIdleTimeout = IDLE_TIMEOUT_GRACE;
857
- }
858
- try {
859
- onProgress?.(data);
860
- } catch (callbackError) {
861
- console.warn("[SSE] 进度回调失败");
862
- }
863
- };
864
- const processSSEText = async (text, flush = false) => {
865
- let start = 0, end;
866
- while ((end = text.indexOf("\n", start)) !== -1) {
867
- if (++lineCount > API.SSE_MAX_LINES) throw limitError();
868
- appendLinePart(text.slice(start, end));
869
- processSSELine(takeLine());
870
- start = end + 1;
871
- workLines++;
872
- await yieldIfNeeded();
873
- }
874
- appendLinePart(text.slice(start));
875
- if (flush && lineParts.length) {
876
- if (++lineCount > API.SSE_MAX_LINES) throw limitError();
877
- processSSELine(takeLine());
878
- }
879
- };
880
- while (true) {
881
- // 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
882
- const idlePromise = new Promise((_, reject) => {
883
- idleTimer = setTimeout(() => reject(new Error("SSE_IDLE_TIMEOUT")), currentIdleTimeout);
884
- });
885
-
886
- let readResult;
887
- try {
888
- readResult = await Promise.race([
889
- reader.read(),
890
- idlePromise
891
- ]);
892
- } catch (e) {
893
- clearIdleTimer();
894
- if (e.message === "SSE_IDLE_TIMEOUT") {
895
- controller.abort();
896
- // 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
897
- if (finalResult) return finalResult;
898
- // 否则返回空闲超时错误
899
- return { status: "error", outcomeUnknown: true, message: t('feedback.stream_idle_timeout') };
900
- }
901
- throw e;
902
- }
903
- clearIdleTimer();
904
-
905
- const { done, value } = readResult;
906
- if (done) {
907
- await processSSEText(decodeSSEChunk(), true);
908
- break;
909
- }
910
-
911
- checkChunkBudget(value);
912
- workBytes += value.byteLength;
913
- workChunks++;
914
- await processSSEText(decodeSSEChunk(value, { stream: true }));
915
- await yieldIfNeeded();
916
- }
917
-
918
- return finalResult || { status: "error", outcomeUnknown: true, message: t('feedback.stream_missing_final') };
919
- } catch (err) {
920
- // 已收到最终事件时,后续连接关闭错误不应覆盖操作结果或触发重复提交。
921
- if (finalResult) return finalResult;
922
- if (err.name === "AbortError") {
923
- return {
924
- status: "error",
925
- outcomeUnknown: true,
926
- cancelRequested: !!externalSignal?.aborted,
927
- message: externalSignal?.aborted ? t('feedback.request_cancelled') : t('feedback.stream_timeout_unconfirmed')
928
- };
929
- }
930
- throw err;
931
- } finally {
932
- clearTimeout(globalTimer);
933
- clearIdleTimer();
934
- externalSignal?.removeEventListener("abort", abortFromCaller);
935
- try { controller.abort(); } catch (_) {}
936
- if (streamReader) {
937
- try { Promise.resolve(streamReader.cancel()).catch(() => {}); } catch (_) {}
938
- try { streamReader.releaseLock(); } catch (_) {}
939
- streamReader = null;
940
- }
941
- }
942
- }
943
-
944
- // 导出给外部使用
945
- export { requestCancelManager, invalidateRelatedCache, request };
946
- export default request;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 前端页面/core/网络请求_基础设施.js
2
+ // ==========================================
3
+ // 🌐 网络请求基础设施模块
4
+ // ==========================================
5
+ // 作用:提供底层网络请求能力,包括缓存、重试、错误处理、请求取消等
6
+ // 关联文件:
7
+ // - 全局配置.js (配置中心)
8
+ // - 网络请求_缓存管理.js (缓存管理)
9
+ // - 网络请求_图片代理.js (图片代理)
10
+ // ==========================================
11
+
12
+ import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
+ import { API, CACHE } from "./全局配置.js";
14
+ import { beginLocalPerformanceSample } from "./本机性能统计.js";
15
+ import { eventBus, EVENTS, logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
16
+ import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache, isPrivateApiGet, captureCacheWrite, captureCacheSource } from "./网络请求_缓存管理.js";
17
+ import { unproxyImages } from "./网络请求_图片代理.js";
18
+ // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
19
+ // 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
20
+ import { t } from "../components/用户体验_国际化.js";
21
+
22
+ // 🚀 动态时序导入:proxyImages 通过动态 import() 获取,避免模块初始化时序问题
23
+ let _proxyImages = null;
24
+ async function getProxyImages() {
25
+ if (!_proxyImages) {
26
+ const mod = await import("./网络请求_图片代理.js");
27
+ _proxyImages = mod.proxyImages;
28
+ }
29
+ return _proxyImages;
30
+ }
31
+
32
+ // 🏗️ P2架构优化:使用配置中心的 API 地址
33
+ const BASE_URL = API.BASE_URL;
34
+
35
+ // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
36
+ const pendingRequests = new Map();
37
+ const cacheReadOwners = new Map();
38
+ const pendingRequestGuards = new WeakMap();
39
+ const responseCacheSources = new WeakMap();
40
+
41
+ function _cachedResponse(value, source) {
42
+ if (value && typeof value === 'object') responseCacheSources.set(value, { source });
43
+ return value;
44
+ }
45
+
46
+ export function getResponseCacheSource(value) {
47
+ return responseCacheSources.get(value) || null;
48
+ }
49
+
50
+
51
+ function withRequestDiagnostic(error, response, data = null) {
52
+ let headerId;
53
+ try { headerId = response.headers?.get?.(API.REQUEST_ID_HEADER); } catch {}
54
+ for (const value of [headerId, data?.error_id]) {
55
+ if (typeof value === 'string' && API.REQUEST_ID_PATTERN.test(value)) {
56
+ error.requestId = value;
57
+ break;
58
+ }
59
+ }
60
+ return error;
61
+ }
62
+
63
+ function httpErrorMessage(data, status) {
64
+ if (typeof data.detail === 'string' && Object.hasOwn(API.REQUEST_BODY_ERRORS, data.detail)) {
65
+ const rule = API.REQUEST_BODY_ERRORS[data.detail];
66
+ if (rule.status === status) return t(rule.msgKey);
67
+ }
68
+ if (Array.isArray(data.detail)) return t('feedback.invalid_request_data');
69
+ for (const value of [data.detail, data.message, data.error]) {
70
+ if (typeof value === 'string' && value.trim()) return value;
71
+ }
72
+ return t('feedback.http_request_failed', { status });
73
+ }
74
+
75
+ // 🚀 P3优化:网络状态由 状态管理.js 的 isOnline() 统一提供(见下方离线模式支持),
76
+ // 本模块不再自行注册 online/offline 监听。
77
+
78
+ // 🔧 修复:401(Token 过期/签名无效)统一处理——清除本地凭证并提示重新登录。
79
+ // 背景:服务端更换 JWT_SECRET 或 Token 过期后,旧 Token 被云端拒签,若不清除
80
+ // 本地凭证会导致所有登录态操作反复报「Token 签名无效」。去重窗口:并发请求
81
+ // 同时收到 401 时只提示一次。
82
+ let _authExpiredAt = 0;
83
+ function _safeStorageGet(storageName, key) {
84
+ try {
85
+ const storage = window[storageName];
86
+ return storage ? storage.getItem(key) : null;
87
+ } catch (_) {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function _safeGetAuthToken() {
93
+ return _safeStorageGet('localStorage', 'ComfyCommunity_Token')
94
+ || _safeStorageGet('sessionStorage', 'ComfyCommunity_Token')
95
+ || '';
96
+ }
97
+
98
+ /** 与实际 Authorization 使用同一存储读取;仅返回比较函数,不暴露凭证。 */
99
+ export function captureRequestSession() {
100
+ const originalToken = _safeGetAuthToken();
101
+ return () => originalToken === _safeGetAuthToken();
102
+ }
103
+
104
+ // 仅跟踪私有 GET;会话撤销不取消或重放已提交的业务写操作。
105
+ const activePrivateReads = new Set();
106
+ function revokePrivateReads(force = false) {
107
+ for (const entry of activePrivateReads) {
108
+ if (force || !entry.matches()) {
109
+ entry.revoked = true;
110
+ entry.controller?.abort();
111
+ }
112
+ }
113
+ }
114
+ for (const event of ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired']) {
115
+ window.addEventListener(event, () => revokePrivateReads(true));
116
+ }
117
+ window.addEventListener('storage', event => {
118
+ if (event.key === null || event.key === CACHE.LEGACY_KEYS.USER) revokePrivateReads(true);
119
+ else if (event.key === CACHE.LEGACY_KEYS.TOKEN) revokePrivateReads();
120
+ });
121
+ eventBus.on(EVENTS.USER_LOGIN, () => revokePrivateReads(true));
122
+ eventBus.on(EVENTS.USER_LOGOUT, () => revokePrivateReads(true));
123
+
124
+ function _safeClearAuthStorage() {
125
+ for (const storageName of ['localStorage', 'sessionStorage']) {
126
+ try {
127
+ const storage = window[storageName];
128
+ storage?.removeItem('ComfyCommunity_Token');
129
+ storage?.removeItem('ComfyCommunity_User');
130
+ } catch (_) {
131
+ // 浏览器禁用 Storage 时仍继续完成内存登出和事件通知。
132
+ }
133
+ }
134
+ }
135
+
136
+ function notifyTokenExpired() {
137
+ const now = Date.now();
138
+ if (now - _authExpiredAt < 5000) return;
139
+ _authExpiredAt = now;
140
+ console.warn("⚠️ 登录凭证已失效,本地凭证已清除,请重新登录");
141
+ // 动态 import UI 组件,与 proxyImages 同样避免模块初始化时序问题
142
+ import("../components/UI交互提示组件.js").then(async ({ showToast }) => {
143
+ const { t } = await import("../components/用户体验_国际化.js");
144
+ showToast(t('auth.token_expired'), "warning", 4000);
145
+ }).catch(() => {});
146
+ }
147
+
148
+ function handleAuthExpired(failedToken) {
149
+ // 竞态防护:若用户在 401 返回前已重新登录(存储中的 Token 已更换),
150
+ // 不清理新凭证;失败请求未携带 Token 时 401 与登录态无关,同样不打扰
151
+ if (!failedToken) return false;
152
+ const currentToken = _safeGetAuthToken();
153
+ if (currentToken !== failedToken) return false;
154
+ // 清除前取出账号,用于清理账号级 Profile 缓存
155
+ let account = null;
156
+ try {
157
+ const userStr = _safeStorageGet('localStorage', "ComfyCommunity_User")
158
+ || _safeStorageGet('sessionStorage', "ComfyCommunity_User");
159
+ if (userStr) account = JSON.parse(userStr)?.user?.account || null;
160
+ } catch (e) {}
161
+ // 与手动登出同深度的完整清理:Token/User、私有数据缓存、内存缓存与安装版本戳
162
+ logoutAndClearUserData(account);
163
+ notifyTokenExpired();
164
+ return true;
165
+ }
166
+
167
+ // 🚀 P4优化:请求取消管理器
168
+ const requestCancelManager = {
169
+ _controllers: new Map(), // {componentId: Set<AbortController>}
170
+
171
+ /**
172
+ * 创建并注册 AbortController
173
+ * @param {string} componentId - 组件 ID
174
+ * @returns {AbortController}
175
+ */
176
+ create(componentId = '_global') {
177
+ if (!this._controllers.has(componentId)) {
178
+ this._controllers.set(componentId, new Set());
179
+ }
180
+ const controller = new AbortController();
181
+ this._controllers.get(componentId).add(controller);
182
+ return controller;
183
+ },
184
+
185
+ /**
186
+ * 移除已完成的 controller
187
+ */
188
+ remove(componentId, controller) {
189
+ const controllers = this._controllers.get(componentId);
190
+ if (controllers) {
191
+ controllers.delete(controller);
192
+ }
193
+ },
194
+
195
+ /**
196
+ * 取消指定组件的所有请求
197
+ * @param {string} componentId - 组件 ID
198
+ */
199
+ cancelAll(componentId) {
200
+ const controllers = this._controllers.get(componentId);
201
+ if (controllers) {
202
+ const count = controllers.size;
203
+ for (const controller of controllers) {
204
+ controller.abort();
205
+ }
206
+ controllers.clear();
207
+ // 🔧 仅在实际撤销了请求时打日志:本方法会被搜索框的每一次输入触发,
208
+ // 绝大多数时候并没有在途请求,无条件打印会刷出大量无意义日志
209
+ if (count > 0) {
210
+ console.log(`🚫 已取消组件 [${componentId}] 的 ${count} 个在途请求`);
211
+ }
212
+ }
213
+ },
214
+
215
+ /**
216
+ * 取消所有请求
217
+ */
218
+ cancelAllGlobal() {
219
+ for (const [componentId, controllers] of this._controllers) {
220
+ for (const controller of controllers) {
221
+ controller.abort();
222
+ }
223
+ controllers.clear();
224
+ }
225
+ console.log('🚫 已取消所有进行中的请求');
226
+ },
227
+
228
+ /**
229
+ * 获取统计信息
230
+ */
231
+ getStats() {
232
+ let total = 0;
233
+ for (const controllers of this._controllers.values()) {
234
+ total += controllers.size;
235
+ }
236
+ return { components: this._controllers.size, activeRequests: total };
237
+ }
238
+ };
239
+
240
+ // 🚀 P3优化:请求队列管理(限制并发数)
241
+ const requestQueue = {
242
+ maxConcurrent: 6, // 浏览器同域名默认限制 6 个并发
243
+ running: 0,
244
+ pending: [],
245
+
246
+ async add(fn, signal) {
247
+ signal.throwIfAborted();
248
+ // 如果还有槽位,直接执行
249
+ if (this.running < this.maxConcurrent) {
250
+ this.running++;
251
+ try {
252
+ return await fn();
253
+ } finally {
254
+ this.running--;
255
+ this._processNext();
256
+ }
257
+ }
258
+
259
+ // 否则加入队列等待
260
+ return new Promise((resolve, reject) => {
261
+ const onAbort = () => {
262
+ const index = this.pending.indexOf(run);
263
+ if (index !== -1) this.pending.splice(index, 1);
264
+ reject(signal.reason);
265
+ };
266
+ const run = async () => {
267
+ signal.removeEventListener('abort', onAbort);
268
+ try {
269
+ signal.throwIfAborted();
270
+ resolve(await fn());
271
+ } catch (e) {
272
+ reject(e);
273
+ }
274
+ };
275
+ this.pending.push(run);
276
+ signal.addEventListener('abort', onAbort, { once: true });
277
+ });
278
+ },
279
+
280
+ _processNext() {
281
+ if (this.pending.length > 0 && this.running < this.maxConcurrent) {
282
+ const next = this.pending.shift();
283
+ this.running++;
284
+ next().finally(() => {
285
+ this.running--;
286
+ this._processNext();
287
+ });
288
+ }
289
+ },
290
+
291
+ getStats() {
292
+ return { running: this.running, pending: this.pending.length };
293
+ }
294
+ };
295
+
296
+ async function request(endpoint, options = {}) {
297
+ const sample = beginLocalPerformanceSample(endpoint, options.method || 'GET');
298
+ try { return await performRequest(endpoint, options, sample); }
299
+ finally { sample?.finish(); }
300
+ }
301
+
302
+ async function performRequest(endpoint, options = {}, sample = null) {
303
+ const privateRead = (options.method || "GET").toUpperCase() === "GET" && isPrivateApiGet(endpoint);
304
+ if (privateRead) {
305
+ removeCache(`api_${endpoint}`);
306
+ options = { ...options, noStore: true };
307
+ }
308
+ // 私有详情显式不留存:同时绕过响应命中/回退/去重、插件写缓存和浏览器缓存。
309
+ if (options.noStore) options = { ...options, noCache: true, cache: "no-store" };
310
+ const url = `${BASE_URL}${endpoint}`;
311
+ const method = (options.method || "GET").toUpperCase();
312
+ const validateResponse = method === "GET" ? options.validateResponse ?? null : null;
313
+ if (validateResponse !== null && typeof validateResponse !== "function") throw new TypeError("Invalid response validator");
314
+ const headers = { ...options.headers };
315
+ if (!(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; }
316
+ const token = _safeGetAuthToken();
317
+ const privateEntry = privateRead ? { revoked: false, controller: null, matches: () => token === _safeGetAuthToken() } : null;
318
+ const assertReadSession = () => {
319
+ if (privateRead && (privateEntry.revoked || !privateEntry.matches())) {
320
+ const error = new Error(t('auth.session_changed'));
321
+ error.name = 'SessionChangedError';
322
+ error.isCancelled = true;
323
+ throw error;
324
+ }
325
+ };
326
+ if (token) {
327
+ const tokenParts = token.split(".");
328
+ if (tokenParts.length === 3) {
329
+ // 标准 JWT 格式
330
+ headers["Authorization"] = `Bearer ${token}`;
331
+ } else {
332
+ // 🔒 P0同步:旧版 mock_token 已被后端明确拒绝(伪造后门已封堵),
333
+ // 以及其他无法识别的格式,统一清除本地旧凭证并提示重新登录。
334
+ // 🔧 修复:凭证既已清除,同步广播登出事件重置导航登录态/停止轮询,
335
+ // 并给用户明确提示(原实现仅 console.warn 静默清理)
336
+ console.warn("⚠️ 检测到无效/已过期的旧版 Token,已自动清除,请重新登录");
337
+ _safeClearAuthStorage();
338
+ window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
339
+ notifyTokenExpired();
340
+ }
341
+ }
342
+
343
+ // 🚀 P4优化:组件级请求取消支持
344
+ const componentId = options.componentId || '_global';
345
+
346
+ // ⚡ P1性能优化:GET 请求缓存检查
347
+ // 🔑 P0修复:cacheKey 必须包含完整 endpoint(含查询参数),不能截断 '?' 之后的部分
348
+ // 例:'/api/items?type=tool&sort=time' 和 '/api/items?type=tool&sort=views' 必须产生不同的缓存 Key
349
+ const cacheKey = `api_${endpoint}`;
350
+ const readValidatedCacheMeta = () => {
351
+ const metadata = getCacheWithMeta(cacheKey, true);
352
+ if (metadata.found && validateResponse) {
353
+ const source = captureCacheSource(cacheKey);
354
+ try { validateResponse(metadata.value); }
355
+ catch {
356
+ source?.discard();
357
+ return { value: null, expired: false, found: false };
358
+ }
359
+ }
360
+ return metadata;
361
+ };
362
+ const isRequestCacheCurrent = method === "GET" && !options.noStore ? captureCacheWrite(cacheKey) : null;
363
+ let isCacheWriteCurrent = isRequestCacheCurrent;
364
+ const trackPendingRequest = method === "GET" && !options.noCache;
365
+ const trackCacheOwner = method === "GET" && !options.noStore;
366
+ if (method === "GET" && !options.noCache) {
367
+ let cached = getCache(cacheKey);
368
+ const source = cached ? captureCacheSource(cacheKey) : null;
369
+ if (cached && validateResponse) {
370
+ try { validateResponse(cached); }
371
+ catch { source?.discard(); cached = null; }
372
+ }
373
+ if (cached) {
374
+ const proxyFn = await getProxyImages();
375
+ sample?.cacheHit();
376
+ return _cachedResponse(proxyFn(cached), source);
377
+ }
378
+ }
379
+
380
+ // 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
381
+ // ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
382
+ // ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
383
+ const fallbackMeta = (method === "GET" && !options.noCache)
384
+ ? readValidatedCacheMeta() // 忽略过期
385
+ : { value: null, expired: false, found: false };
386
+ const conditionalRead = method === "GET" && !options.noStore && options.credentials !== "include"
387
+ && !Object.keys(headers).some(name => name.toLowerCase() === "authorization")
388
+ && API.PUBLIC_CONDITIONAL_GET_PATHS.includes(endpoint.split(/[?#]/)[0]);
389
+ const conditionalMeta = conditionalRead ? readValidatedCacheMeta() : null;
390
+ if (conditionalRead) {
391
+ for (const name of Object.keys(headers)) if (name.toLowerCase() === "if-none-match") delete headers[name];
392
+ if (conditionalMeta?.found && conditionalMeta.etag) headers["If-None-Match"] = conditionalMeta.etag;
393
+ }
394
+
395
+ // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
396
+ // ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
397
+ // 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
398
+ // 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
399
+ // 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
400
+ // 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
401
+ // noCache 只代表绕过该 endpoint 的响应缓存,不得绕过共享网络熔断;榜单强制刷新
402
+ // 仍由列表层先显示自己的缓存,冷却期内直接失败返回,避免切换榜单逐个重试 HF。
403
+ if (method === "GET" && (!isOnline() || !isCloudReachable())) {
404
+ if (fallbackMeta.found) {
405
+ console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存`);
406
+ const source = captureCacheSource(cacheKey);
407
+ const proxyFn2 = await getProxyImages();
408
+ sample?.cacheHit();
409
+ return _cachedResponse(proxyFn2(fallbackMeta.value), source);
410
+ }
411
+ // 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
412
+ // 导致英文界面下仍弹中文提示;现改走词典。云端不可达与本机断网分开用词,
413
+ // 否则本机明明有网的用户会被「网络已断开」误导去查自己的路由器
414
+ throw new Error(isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache'));
415
+ }
416
+
417
+ // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
418
+ if (trackPendingRequest) {
419
+ const pending = pendingRequests.get(cacheKey);
420
+ const guard = pendingRequestGuards.get(pending);
421
+ if (pending && guard?.isCurrent() && guard.validateResponse === validateResponse && cacheReadOwners.get(cacheKey) === pending) {
422
+ sample?.discard();
423
+ return pending;
424
+ }
425
+ }
426
+
427
+ const fetchOptions = { ...options, method, headers };
428
+ delete fetchOptions.validateResponse;
429
+
430
+ // 🚀 核心修改:在将 body 转为 JSON 字符串发送前,执行无情剥离!
431
+ if (options.body && !(options.body instanceof FormData) && typeof options.body !== "string") {
432
+ fetchOptions.body = JSON.stringify(unproxyImages(options.body));
433
+ } else if (options.body instanceof FormData) {
434
+ fetchOptions.body = options.body;
435
+ }
436
+
437
+ // 🚀 P1优化:重试与超时预算
438
+ // 🔧 修复:重试次数/间隔此前硬编码在本文件(2 / 1000),使 全局配置.js 的
439
+ // API.MAX_RETRIES、API.RETRY_DELAY 成为死配置,现统一读取配置中心
440
+ // 🐢 弱网预算分级:列表/搜索类 GET 有本地缓存兜底,失败上限适当收紧,
441
+ // 避免弱网下用户长时间盯骨架屏;按去掉查询串的路径全匹配,
442
+ // 不会误伤 /api/items/{id}、/api/creators/{account}/details 等详情请求
443
+ const endpointPath = endpoint.split("?")[0];
444
+ let defaultTimeout = API.TIMEOUT;
445
+ let defaultRetries = method === "GET" ? API.MAX_RETRIES : 0; // 非 GET 不重试
446
+ if (method === "GET") {
447
+ if (endpointPath === "/api/creators/search") {
448
+ defaultTimeout = API.SEARCH_TIMEOUT;
449
+ defaultRetries = API.SEARCH_RETRIES;
450
+ } else if (endpointPath === "/api/items" || endpointPath === "/api/creators") {
451
+ defaultRetries = API.LIST_RETRIES;
452
+ }
453
+ }
454
+ // 🐢 后台补新短预算:本地已有该请求的缓存,说明列表视图已经把内容渲染上屏了
455
+ // (七大列表都是「本地数据先上屏 → 再发请求补新」),这次请求纯属后台行为,
456
+ // 失败要快——它每多挂 1 秒就多占 1 个并发额度(全局仅 6 个)。用 Math.min 只收不放,
457
+ // 不会把搜索类已经收紧过的 10s 预算又放宽回去。
458
+ // noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
459
+ // 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
460
+ const useCacheBudget = method === "GET" && !options.noCache && fallbackMeta.found;
461
+ if (useCacheBudget) {
462
+ defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
463
+ defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
464
+ }
465
+ const requestTimeout = options.timeout || defaultTimeout;
466
+ const maxRetries = options.retries ?? defaultRetries;
467
+ const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
468
+
469
+ // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
470
+ if (privateEntry) activePrivateReads.add(privateEntry);
471
+ const requestPromise = (async () => {
472
+ let lastError = null;
473
+ // ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
474
+ // 5xx 不置位:服务端已经应答说明云端可达,若也打冷却会让一个坏接口把全站 GET 拖下水
475
+ let networkLevelFailure = false;
476
+
477
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
478
+ assertReadSession();
479
+ // 🚀 P1优化:指数退避延迟
480
+ if (attempt > 0) {
481
+ // ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
482
+ // 继续重试只是白烧一轮超时预算、白占一个并发额度,直接跳去下方缓存兜底
483
+ if (method === "GET" && !isCloudReachable()) break;
484
+ const delay = retryDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s...
485
+ console.log(`🔄 请求重试 (${attempt}/${maxRetries})...`);
486
+ await new Promise(r => setTimeout(r, delay));
487
+ }
488
+
489
+ // 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
490
+ const controller = requestCancelManager.create(componentId);
491
+ if (privateEntry) privateEntry.controller = controller;
492
+ const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
493
+ let timeoutId = null;
494
+ let timedOut = false;
495
+ let fetchStarted = false;
496
+ let transportFailed = false;
497
+ const onTimeout = () => { timedOut = true; controller.abort(); };
498
+ // 已有缓存时排队也计入预算,避免被无缓存的慢请求无限拖住。
499
+ if (useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
500
+
501
+ try {
502
+ const result = await requestQueue.add(async () => {
503
+ if (!useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
504
+ // 请求可能在云端故障被另一个并发请求确认前进入队列;出队发出 fetch
505
+ // 前复查共享状态,避免队列继续逐个访问已知不可达的 HF endpoint。
506
+ if (method === "GET" && (!isOnline() || !isCloudReachable())) {
507
+ const unavailableError = new Error(
508
+ isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache')
509
+ );
510
+ unavailableError.name = 'CloudUnavailableError';
511
+ throw unavailableError;
512
+ }
513
+ assertReadSession();
514
+ fetchStarted = true;
515
+ try {
516
+ if (isRequestCacheCurrent) isCacheWriteCurrent = captureCacheWrite(cacheKey);
517
+ let response = await fetch(url, currentFetchOptions);
518
+ let validatedData;
519
+ if (response.status === 304 && conditionalRead) {
520
+ const currentMeta = getCacheWithMeta(cacheKey, true);
521
+ if (conditionalMeta?.found && conditionalMeta.etag
522
+ && response.headers?.get?.('ETag') === conditionalMeta.etag
523
+ && currentMeta.found && currentMeta.etag === conditionalMeta.etag
524
+ && currentMeta.value === conditionalMeta.value) {
525
+ sample?.cacheHit();
526
+ validatedData = currentMeta.value;
527
+ } else {
528
+ // 缓存可能在等待期间被清理/写操作失效;同一预算内补取完整响应。
529
+ const fullHeaders = { ...currentFetchOptions.headers };
530
+ for (const name of Object.keys(fullHeaders)) if (name.toLowerCase() === "if-none-match") delete fullHeaders[name];
531
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
532
+ isCacheWriteCurrent = captureCacheWrite(cacheKey);
533
+ response = await fetch(url, { ...currentFetchOptions, headers: fullHeaders, cache: "no-store" });
534
+ if (response.status === 304) throw new SyntaxError('Unexpected not-modified response');
535
+ }
536
+ }
537
+ let data;
538
+ let bodyConfirmed = true;
539
+ try {
540
+ data = validatedData !== undefined ? validatedData
541
+ : response.status === 204 ? {} : await response.json();
542
+ } catch (error) {
543
+ // 错误响应的正文不可读时仍按 HTTP 状态处理,不能用缓存掩盖 401/403。
544
+ if (!response.ok && (error instanceof SyntaxError || error instanceof TypeError || (timedOut && error.name === 'AbortError'))) {
545
+ data = {};
546
+ bodyConfirmed = false;
547
+ } else {
548
+ throw error;
549
+ }
550
+ }
551
+ return { response, data, bodyConfirmed, validated: validatedData !== undefined };
552
+ } catch (error) {
553
+ transportFailed = error instanceof TypeError;
554
+ throw error;
555
+ }
556
+ }, controller.signal);
557
+ clearTimeout(timeoutId);
558
+ requestCancelManager.remove(componentId, controller);
559
+ const { response } = result;
560
+ let responseData = result.data;
561
+ assertReadSession();
562
+
563
+ if (!response.ok && !result.validated) {
564
+ if (!responseData || typeof responseData !== "object" || Array.isArray(responseData)) responseData = {};
565
+ const errorMsg = httpErrorMessage(responseData, response.status);
566
+
567
+ // 🚀 P1优化:4xx 错误不重试,5xx 错误可重试
568
+ if (response.status >= 400 && response.status < 500) {
569
+ if (response.status === 401) {
570
+ // 🔧 修复:清除失效凭证并提示重新登录(原实现仅广播事件,无人清理);
571
+ // 传入本请求实际使用的 Token 做竞态比对,避免误清重新登录后签发的新凭证。
572
+ // 仅当确认凭证确实失效后才广播登出事件(导航停止轮询并重置登录态),
573
+ // 迟到的旧 401 或匿名请求的 401 不再误触发登录态重置。
574
+ // 登录接口的 401 语义是「密码错误」而非凭证失效,不走全局清理,
575
+ // 避免与登录表单自身的错误提示重复叠加
576
+ if (endpoint !== "/api/users/login" && handleAuthExpired(token)) {
577
+ window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
578
+ }
579
+ }
580
+ const error = withRequestDiagnostic(new Error(errorMsg), response, responseData);
581
+ error.status = response.status;
582
+ if (typeof responseData.detail === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(responseData.detail)) error.code = responseData.detail;
583
+ if (result.bodyConfirmed && Object.hasOwn(API.REQUEST_BODY_ERRORS, error.code)
584
+ && API.REQUEST_BODY_ERRORS[error.code].status === error.status) {
585
+ error.requestBodyRejected = true;
586
+ }
587
+ throw error;
588
+ }
589
+
590
+ // 应用明确标记且正文可读的503只影响当前接口,仍允许其它榜单联网。
591
+ if (response.status === 503 && result.bodyConfirmed &&
592
+ response.headers?.get?.(API.ENDPOINT_ERROR_SCOPE_HEADER) === API.ENDPOINT_ERROR_SCOPE_VALUE) {
593
+ lastError = withRequestDiagnostic(new Error(errorMsg), response, responseData);
594
+ lastError.status = response.status;
595
+ networkLevelFailure = false;
596
+ break;
597
+ }
598
+
599
+ // Hugging Face Space / 网关不可用通常以 502、503 或 504 响应,
600
+ // 这类响应证明请求到达了代理层,但云端当前无法提供服务。
601
+ // 与超时/连接失败一样共享冷却,避免切换榜单时每个 endpoint
602
+ // 都重复访问一次;普通 500 仍只视为单接口服务端错误。
603
+ if ([502, 503, 504].includes(response.status)) {
604
+ lastError = withRequestDiagnostic(new Error(errorMsg), response, responseData);
605
+ lastError.status = response.status;
606
+ networkLevelFailure = true;
607
+ markCloudDown(endpoint);
608
+ break;
609
+ }
610
+
611
+ lastError = withRequestDiagnostic(new Error(errorMsg), response, responseData);
612
+ lastError.status = response.status;
613
+ continue; // 5xx 错误重试
614
+ }
615
+
616
+ // ☁️ 拿到了正常应答即证明云端可达,立即清零冷却(不必等它自然过期)。
617
+ // 非 GET 的成功同样算数——写操作是冷却期内唯一还在联网的请求,天然是探针
618
+ markCloudUp();
619
+
620
+ if (validateResponse) {
621
+ try { validateResponse(responseData); }
622
+ catch (error) { lastError = error; break; }
623
+ }
624
+
625
+ // 🚀 P1优化:精确清除相关缓存(替代暴力清空)
626
+ if (["POST", "PUT", "DELETE"].includes(method)) {
627
+ invalidateRelatedCache(endpoint, method);
628
+ }
629
+
630
+ // ⚡ P1性能优化:GET 请求结果缓存
631
+ if (method === "GET" && !options.noStore && isCacheWriteCurrent() && cacheReadOwners.get(cacheKey) === requestPromise) {
632
+ const ttl = _getCacheTTL(endpoint);
633
+ if (ttl > 0) {
634
+ // 创作者列表不持久化到 localStorage,避免空间不足
635
+ const persist = !endpoint.includes('/api/creators');
636
+ setCache(cacheKey, responseData, ttl, persist,
637
+ conditionalRead ? { etag: response.headers?.get?.('ETag') } : {});
638
+ }
639
+ }
640
+
641
+ // 入口数据挂载代理
642
+ const proxyFn3 = await getProxyImages();
643
+ assertReadSession();
644
+ responseData = proxyFn3(responseData);
645
+ if (responseData && typeof responseData === "object") {
646
+ responseCacheSources.delete(responseData);
647
+ if (trackCacheOwner && (!isCacheWriteCurrent() || cacheReadOwners.get(cacheKey) !== requestPromise)) responseCacheSources.set(responseData, { source: null });
648
+ }
649
+ return responseData;
650
+ } catch (error) {
651
+ clearTimeout(timeoutId); // 清除超时计时器
652
+ // 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
653
+ // 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
654
+ requestCancelManager.remove(componentId, controller);
655
+ assertReadSession();
656
+
657
+ // 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
658
+ // 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
659
+ // 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
660
+ // 直接抛出而不走循环后的「过期缓存兜底」,避免把作废请求的结果上屏
661
+ if (error.name === 'AbortError' && !timedOut) {
662
+ const cancelErr = new Error(t('feedback.request_cancelled'));
663
+ cancelErr.name = 'RequestCancelledError';
664
+ cancelErr.isCancelled = true;
665
+ throw cancelErr;
666
+ }
667
+
668
+ // 队列等待期间已有并发请求确证网络不可达:该请求尚未发出 fetch,
669
+ // 直接交给统一缓存兜底,不重试、不延长冷却,也不把队列错误当作网络探测。
670
+ if (error.name === 'CloudUnavailableError') {
671
+ lastError = error;
672
+ break;
673
+ }
674
+
675
+ // 🔧 P1优化:可重试的错误类型
676
+ const isRetryable = (
677
+ error.name === 'AbortError' ||
678
+ transportFailed
679
+ );
680
+
681
+ if (isRetryable) {
682
+ // 🔧 修复:重试耗尽时不再直接 throw(否则循环后的「过期缓存兜底」
683
+ // 分支永远不可达,断网时无法无缝切换本地数据)。记录友好错误后跳出循环,
684
+ // 让 GET 请求走过期缓存回退;无缓存时仍以该友好错误抛出。
685
+ const isTimeout = error.name === 'AbortError';
686
+ lastError = isTimeout
687
+ ? new Error(t('feedback.network_timeout'))
688
+ : new Error(t('feedback.network_unavailable'));
689
+ lastError.code = isTimeout ? 'network_timeout' : 'network_unavailable';
690
+ if (fetchStarted) networkLevelFailure = true;
691
+ // ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
692
+ // 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
693
+ // 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫秒级的,
694
+ // 多等一轮只多花约 1 秒,却能挡住偶发抖动误伤 60 秒);
695
+ // 未配重试时(后台补新短预算,界面已用缓存上屏)误判零代价,立即定论。
696
+ // 超时则可能是 HF Space 冷启动(慢但能成功),不在此定论,耗尽后由下方统一处理
697
+ if (!isTimeout && (attempt >= 1 || maxRetries === 0)) markCloudDown(endpoint);
698
+ if (attempt < maxRetries) continue; // 重试
699
+ break; // 重试耗尽 → 跳到过期缓存兜底
700
+ }
701
+
702
+ // 🔧 P3优化:错误分类处理,提供更清晰的错误信息(不可重试错误直接抛出)
703
+ if (error instanceof SyntaxError) {
704
+ throw new Error(t('feedback.response_format_error'));
705
+ }
706
+ throw error;
707
+ }
708
+ }
709
+
710
+ // ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
711
+ // 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
712
+ if (networkLevelFailure) markCloudDown(endpoint);
713
+
714
+ // 所有重试都失败,尝试回退到过期缓存
715
+ if (method === "GET" && !options.noCache) {
716
+ const fallback = readValidatedCacheMeta(); // true = 允许过期
717
+ if (fallback.found) {
718
+ console.warn('📴 网络请求失败,回退到本地缓存');
719
+ const source = captureCacheSource(cacheKey);
720
+ const proxyFn4 = await getProxyImages();
721
+ sample?.cacheHit();
722
+ return _cachedResponse(proxyFn4(fallback.value), source); // 返回过期缓存数据
723
+ }
724
+ }
725
+ // 仍无缓存,才抛异常
726
+ throw lastError || new Error(t('feedback.error'));
727
+ })();
728
+
729
+ if (privateEntry) requestPromise.finally(() => activePrivateReads.delete(privateEntry)).catch(() => {});
730
+
731
+ if (trackCacheOwner) {
732
+ cacheReadOwners.set(cacheKey, requestPromise);
733
+ requestPromise.finally(() => {
734
+ if (cacheReadOwners.get(cacheKey) === requestPromise) cacheReadOwners.delete(cacheKey);
735
+ }).catch(() => {});
736
+ }
737
+
738
+ // 记录进行中的请求
739
+ if (trackPendingRequest) {
740
+ pendingRequests.set(cacheKey, requestPromise);
741
+ pendingRequestGuards.set(requestPromise, { isCurrent: isRequestCacheCurrent, validateResponse });
742
+ }
743
+
744
+ // 确保请求完成后清除去重记录
745
+ // 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
746
+ // 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
747
+ // 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
748
+ if (trackPendingRequest) {
749
+ requestPromise.finally(() => {
750
+ if (pendingRequests.get(cacheKey) === requestPromise) pendingRequests.delete(cacheKey);
751
+ }).catch(() => {});
752
+ }
753
+
754
+ return requestPromise;
755
+ }
756
+
757
+ // ==========================================
758
+ // 📡 SSE 流式请求(用于安装进度等长时间操作)
759
+ // ==========================================
760
+
761
+ /**
762
+ * 发起 SSE 流式请求(用于安装进度等长时间操作)
763
+ * @param {string} endpoint - API端点路径(本地ComfyUI路径,如 /community_hub/install_tool_stream)
764
+ * @param {Object} body - POST请求体
765
+ * @param {Function} onProgress - 进度回调 ({stage, progress, message, status, data}) => void
766
+ * @param {Object} [options] - 可选配置 { timeout: 300000 }
767
+ * @returns {Promise<Object>} 最终结果事件
768
+ */
769
+ export async function requestSSE(endpoint, body, onProgress, options = {}) {
770
+ const timeout = options.timeout || 1200000; // 20分钟全局超时(大型工具克隆需要较长时间)
771
+ const controller = new AbortController();
772
+ const globalTimer = setTimeout(() => controller.abort(), timeout);
773
+ const externalSignal = options.signal;
774
+ const abortFromCaller = () => controller.abort();
775
+ if (externalSignal?.aborted) controller.abort();
776
+ else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
777
+
778
+ // 📡 空闲超时检测:长时间无数据视为连接已断
779
+ const IDLE_TIMEOUT_DEFAULT = 30000; // 30 秒无数据视为空闲
780
+ const IDLE_TIMEOUT_GRACE = 5000; // 收到 finalResult 后的宽限期 5 秒
781
+ let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
782
+ let idleTimer = null;
783
+ let finalResult = null;
784
+ let streamReader = null;
785
+
786
+ const clearIdleTimer = () => {
787
+ if (idleTimer !== null) {
788
+ clearTimeout(idleTimer);
789
+ idleTimer = null;
790
+ }
791
+ };
792
+
793
+ try {
794
+ const response = await fetch(endpoint, {
795
+ method: "POST",
796
+ headers: { "Content-Type": "application/json" },
797
+ body: JSON.stringify(body),
798
+ signal: controller.signal
799
+ });
800
+
801
+ if (!response.ok) {
802
+ const error = withRequestDiagnostic(new Error(t('feedback.http_request_failed', { status: response.status })), response);
803
+ error.status = response.status;
804
+ // 仅当流式路由明确不存在/不接受该方法时,调用方可安全回退到旧端点。
805
+ // 网络错误、超时和服务端错误都可能发生在操作已开始后,不可自动重发。
806
+ error.fallbackSafe = response.status === 404 || response.status === 405;
807
+ throw error;
808
+ }
809
+
810
+ const reader = streamReader = response.body.getReader();
811
+ const decoder = new TextDecoder('utf-8', { fatal: true });
812
+ const decodeSSEChunk = (value, options) => {
813
+ try {
814
+ return decoder.decode(value, options);
815
+ } catch (_) {
816
+ const error = new Error(t('feedback.stream_invalid_encoding'));
817
+ error.code = 'invalid_stream_encoding';
818
+ error.outcomeUnknown = true;
819
+ throw error;
820
+ }
821
+ };
822
+ let totalBytes = 0, lineBytes = 0, lineCount = 0;
823
+ let workBytes = 0, workLines = 0, workChunks = 0;
824
+ const lineParts = [];
825
+ const limitError = () => {
826
+ const error = new Error(t('feedback.stream_limit_exceeded'));
827
+ error.code = 'stream_limit_exceeded';
828
+ error.outcomeUnknown = true;
829
+ return error;
830
+ };
831
+ const appendLinePart = (part) => {
832
+ if (!part) return;
833
+ let level = 0;
834
+ while (lineParts[level] !== undefined) {
835
+ part = lineParts[level] + part;
836
+ lineParts[level++] = undefined;
837
+ }
838
+ lineParts[level] = part;
839
+ };
840
+ const takeLine = () => {
841
+ const line = lineParts.slice().reverse().filter(part => part !== undefined).join('');
842
+ lineParts.length = 0;
843
+ return line;
844
+ };
845
+ const yieldIfNeeded = async () => {
846
+ if (workBytes >= API.SSE_YIELD_BYTES || workLines >= API.SSE_YIELD_LINES || workChunks >= API.SSE_YIELD_CHUNKS) {
847
+ workBytes = workLines = workChunks = 0;
848
+ await new Promise(resolve => setTimeout(resolve, 0));
849
+ }
850
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
851
+ };
852
+ const checkChunkBudget = (value) => {
853
+ totalBytes += value.byteLength;
854
+ if (totalBytes > API.SSE_MAX_RESPONSE_BYTES) throw limitError();
855
+ let start = 0, end;
856
+ while ((end = value.indexOf(10, start)) !== -1) {
857
+ lineBytes += end - start;
858
+ if (lineBytes > API.SSE_MAX_LINE_BYTES) throw limitError();
859
+ lineBytes = 0;
860
+ start = end + 1;
861
+ }
862
+ lineBytes += value.byteLength - start;
863
+ if (lineBytes > API.SSE_MAX_LINE_BYTES) throw limitError();
864
+ };
865
+ const processSSELine = (line) => {
866
+ if (!line.startsWith("data: ")) return;
867
+ let data;
868
+ try {
869
+ data = JSON.parse(line.slice(6));
870
+ } catch (_) {
871
+ console.warn("[SSE] 解析事件失败");
872
+ return;
873
+ }
874
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
875
+ console.warn("[SSE] 忽略非对象事件");
876
+ return;
877
+ }
878
+ if (data.status === "success" || data.status === "error") {
879
+ // 先保存最终结果,UI 更新回调即使异常也不能丢失服务端结果。
880
+ finalResult = data;
881
+ currentIdleTimeout = IDLE_TIMEOUT_GRACE;
882
+ }
883
+ try {
884
+ onProgress?.(data);
885
+ } catch (callbackError) {
886
+ console.warn("[SSE] 进度回调失败");
887
+ }
888
+ };
889
+ const processSSEText = async (text, flush = false) => {
890
+ let start = 0, end;
891
+ while ((end = text.indexOf("\n", start)) !== -1) {
892
+ if (++lineCount > API.SSE_MAX_LINES) throw limitError();
893
+ appendLinePart(text.slice(start, end));
894
+ processSSELine(takeLine());
895
+ start = end + 1;
896
+ workLines++;
897
+ await yieldIfNeeded();
898
+ }
899
+ appendLinePart(text.slice(start));
900
+ if (flush && lineParts.length) {
901
+ if (++lineCount > API.SSE_MAX_LINES) throw limitError();
902
+ processSSELine(takeLine());
903
+ }
904
+ };
905
+ while (true) {
906
+ // 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
907
+ const idlePromise = new Promise((_, reject) => {
908
+ idleTimer = setTimeout(() => reject(new Error("SSE_IDLE_TIMEOUT")), currentIdleTimeout);
909
+ });
910
+
911
+ let readResult;
912
+ try {
913
+ readResult = await Promise.race([
914
+ reader.read(),
915
+ idlePromise
916
+ ]);
917
+ } catch (e) {
918
+ clearIdleTimer();
919
+ if (e.message === "SSE_IDLE_TIMEOUT") {
920
+ controller.abort();
921
+ // 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
922
+ if (finalResult) return finalResult;
923
+ // 否则返回空闲超时错误
924
+ return { status: "error", outcomeUnknown: true, message: t('feedback.stream_idle_timeout') };
925
+ }
926
+ throw e;
927
+ }
928
+ clearIdleTimer();
929
+
930
+ const { done, value } = readResult;
931
+ if (done) {
932
+ await processSSEText(decodeSSEChunk(), true);
933
+ break;
934
+ }
935
+
936
+ checkChunkBudget(value);
937
+ workBytes += value.byteLength;
938
+ workChunks++;
939
+ await processSSEText(decodeSSEChunk(value, { stream: true }));
940
+ await yieldIfNeeded();
941
+ }
942
+
943
+ return finalResult || { status: "error", outcomeUnknown: true, message: t('feedback.stream_missing_final') };
944
+ } catch (err) {
945
+ // 已收到最终事件时,后续连接关闭错误不应覆盖操作结果或触发重复提交。
946
+ if (finalResult) return finalResult;
947
+ if (err.name === "AbortError") {
948
+ return {
949
+ status: "error",
950
+ outcomeUnknown: true,
951
+ cancelRequested: !!externalSignal?.aborted,
952
+ message: externalSignal?.aborted ? t('feedback.request_cancelled') : t('feedback.stream_timeout_unconfirmed')
953
+ };
954
+ }
955
+ throw err;
956
+ } finally {
957
+ clearTimeout(globalTimer);
958
+ clearIdleTimer();
959
+ externalSignal?.removeEventListener("abort", abortFromCaller);
960
+ try { controller.abort(); } catch (_) {}
961
+ if (streamReader) {
962
+ try { Promise.resolve(streamReader.cancel()).catch(() => {}); } catch (_) {}
963
+ try { streamReader.releaseLock(); } catch (_) {}
964
+ streamReader = null;
965
+ }
966
+ }
967
+ }
968
+
969
+ // 导出给外部使用
970
+ export { requestCancelManager, invalidateRelatedCache, request };
971
+ export default request;
前端页面/core/网络请求_缓存管理.js CHANGED
@@ -69,6 +69,19 @@ export function captureCacheSource(key) {
69
  return {
70
  isAvailable,
71
  isFresh,
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  copyTo(targetKey, value) {
73
  const normalized = targetKey;
74
  if (!isFresh() || typeof normalized !== 'string' ||
 
69
  return {
70
  isAvailable,
71
  isFresh,
72
+ discard() {
73
+ if (!isAvailable()) return false;
74
+ _deleteMemoryEntry(fk);
75
+ try {
76
+ const stored = JSON.parse(localStorage.getItem(fk));
77
+ if (_isValidCacheEntry(stored) && stored.expireAt === cached.expireAt
78
+ && stored.etag === cached.etag && JSON.stringify(stored.value) === JSON.stringify(cached.value)) {
79
+ localStorage.removeItem(fk);
80
+ }
81
+ } catch {}
82
+ storageUsageBytes = null;
83
+ return true;
84
+ },
85
  copyTo(targetKey, value) {
86
  const normalized = targetKey;
87
  if (!isFresh() || typeof normalized !== 'string' ||