ZHIWEI666 commited on
Commit
ae840a9
·
verified ·
1 Parent(s): 23fc4ef

Upload 62 files

Browse files
前端页面/components/用户体验_国际化.js CHANGED
@@ -615,6 +615,9 @@ const translations = {
615
  'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
616
  // 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
617
  'feedback.offline_no_cache': '网络已断开,且无本地缓存',
 
 
 
618
  'feedback.search_local_result': '⚠️ 网络不可用,显示本地搜索结果',
619
  // 🔍 搜索请求被新关键词取代时抛出的错误消息(详见 网络请求_基础设施.js 的主动取消分支)
620
  'feedback.request_cancelled': '请求已取消',
@@ -2026,6 +2029,7 @@ const translations = {
2026
  'feedback.retry_suffix': ', please try again',
2027
  'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
2028
  'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
 
2029
  'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
2030
  'feedback.request_cancelled': 'Request cancelled',
2031
  'common.processing': 'Processing',
 
615
  'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
616
  // 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
617
  'feedback.offline_no_cache': '网络已断开,且无本地缓存',
618
+ // ☁️ 云端不可达冷却期内、且本地无缓存可兜底时抛出(详见 网络请求_基础设施.js)。
619
+ // 与上条刻意分开:本机明明有网时说「网络已断开」会把用户误导去查自己的路由器
620
+ 'feedback.cloud_unreachable_no_cache': '云端服务暂时连不上,本地也没有可显示的内容',
621
  'feedback.search_local_result': '⚠️ 网络不可用,显示本地搜索结果',
622
  // 🔍 搜索请求被新关键词取代时抛出的错误消息(详见 网络请求_基础设施.js 的主动取消分支)
623
  'feedback.request_cancelled': '请求已取消',
 
2029
  'feedback.retry_suffix': ', please try again',
2030
  'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
2031
  'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
2032
+ 'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
2033
  'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
2034
  'feedback.request_cancelled': 'Request cancelled',
2035
  'common.processing': 'Processing',
前端页面/core/侧边栏数据引擎.js CHANGED
@@ -537,6 +537,15 @@ export async function loadSidebarContent({
537
  // 防竞态检查2:网络请求完成后再次验证
538
  if (savedToken !== getRenderToken()) return;
539
 
 
 
 
 
 
 
 
 
 
540
  // 只有token匹配时才同时更新state.allData和setCache
541
  if (_shouldUpdateData(state.allData, newData)) {
542
  console.log(`✅ ${savedTab}_${savedSort} 检测到新数据,执行静默更新`);
@@ -679,19 +688,29 @@ export async function loadSidebarContent({
679
  }
680
  }
681
 
682
- // 存入缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线可展示;搜索结果不存入缓存)
683
  const isCreatorSearch = tab === "creators" && keyword;
684
- if (!isCreatorSearch) {
 
 
 
 
 
685
  setCache(cacheKey, realData, getCacheExpireTime(), true);
686
  }
687
 
688
  // 创作者非搜索数据存入 sessionStorage(用于离线降级)
689
- if (tab === "creators" && !isCreatorSearch) {
690
  saveCreatorsToSessionStorage(realData);
691
  }
692
 
693
  state.isFullyLoaded = true;
694
 
 
 
 
 
 
695
  // 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
696
  // 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
697
  // 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
@@ -942,6 +961,26 @@ function _showEndIndicator(contentArea) {
942
  }
943
 
944
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
945
  /** 判断是否需要更新数据 —— 对比ID顺序和关键字段 */
946
  function _shouldUpdateData(oldData, newData) {
947
  if (!oldData || !newData) return true;
 
537
  // 防竞态检查2:网络请求完成后再次验证
538
  if (savedToken !== getRenderToken()) return;
539
 
540
+ // 🛡️ 空响应防护:云端返回空列表时不回写缓存、也不清屏重渲染。
541
+ // 本分支由「缓存过期」触发,是切 Tab 时最常走到的路径,若不拦,
542
+ // 下方 state.allData = newData + contentArea.innerHTML = "" 会把
543
+ // 已上屏的本地数据连画面带缓存一起清空(见 _isEmptyOverwrite 说明)
544
+ if (_isEmptyOverwrite(newData, state.allData, cachedData)) {
545
+ console.warn(`🛡️ ${savedTab}_${savedSort} 云端返回空列表,保留本地数据,跳过缓存回写`);
546
+ return;
547
+ }
548
+
549
  // 只有token匹配时才同时更新state.allData和setCache
550
  if (_shouldUpdateData(state.allData, newData)) {
551
  console.log(`✅ ${savedTab}_${savedSort} 检测到新数据,执行静默更新`);
 
688
  }
689
  }
690
 
691
+ // 存入缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线仍可展示;搜索结果不存入缓存)
692
  const isCreatorSearch = tab === "creators" && keyword;
693
+ // 🛡️ 空响应防护:创作者搜索命中 0 条是有效答案(下方要渲染「没有搜索到相关内容」),
694
+ // 必须排除在防护之外,否则会把空搜索结果误判成云端故障、改渲染全量列表
695
+ const isEmptyOverwrite = !isCreatorSearch && _isEmptyOverwrite(realData, cachedData, state.allData);
696
+ if (isEmptyOverwrite) {
697
+ console.warn(`🛡️ ${tab}_${sort} 云端返回空列表,保留本地数据,不覆盖缓存`);
698
+ } else if (!isCreatorSearch) {
699
  setCache(cacheKey, realData, getCacheExpireTime(), true);
700
  }
701
 
702
  // 创作者非搜索数据存入 sessionStorage(用于离线降级)
703
+ if (tab === "creators" && !isCreatorSearch && !isEmptyOverwrite) {
704
  saveCreatorsToSessionStorage(realData);
705
  }
706
 
707
  state.isFullyLoaded = true;
708
 
709
+ // 🛡️ 空响应且本地数据已上屏:保持当前画面,不用空列表清屏(与缓存同口径不回写)
710
+ if (isEmptyOverwrite && renderedInstant) {
711
+ return;
712
+ }
713
+
714
  // 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
715
  // 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
716
  // 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
 
961
  }
962
 
963
 
964
+ /**
965
+ * 🛡️ 空响应防护:本次云端返回是否属于「请求成功但列表为空」,且本地已有非空数据
966
+ *
967
+ * 背景:网络失败本身从不清理本地数据(重试耗尽只置云端冷却并回退过期缓存,见技术文档
968
+ * 04 章 4.2.2),但云端偶发返回 HTTP 200 + data:[] 会——例如 Space 冷启动时 HF Dataset
969
+ * 尚未加载完。此时 realData/newData 为 [],照原样回写缓存就把断网降级的唯一数据来源清掉了。
970
+ *
971
+ * 只拦「新数据为空 且 本地已有非空数据」这一种组合:本地本来就是空的(首次使用、刚清过
972
+ * 缓存、云端确实一条都没有)时返回 false,照常写入,不改变任何正常流程。
973
+ * 调用方须自行排除搜索结果场景——搜索命中 0 条是有效答案,不是云端故障。
974
+ *
975
+ * @param {Array} newData - 本次从云端取回的数据
976
+ * @param {...(Array|null|undefined)} localData - 本地已有数据(缓存副本 / 内存 state.allData)
977
+ * @returns {boolean} true = 属于会覆盖本地好数据的空响应,应跳过回写与重渲染
978
+ */
979
+ function _isEmptyOverwrite(newData, ...localData) {
980
+ if (Array.isArray(newData) && newData.length > 0) return false;
981
+ return localData.some(d => Array.isArray(d) && d.length > 0);
982
+ }
983
+
984
  /** 判断是否需要更新数据 —— 对比ID顺序和关键字段 */
985
  function _shouldUpdateData(oldData, newData) {
986
  if (!oldData || !newData) return true;
前端页面/core/全局配置.js CHANGED
@@ -18,7 +18,7 @@
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, SEARCH_COMPONENT_ID: string }} */
22
  export const API = {
23
  // 云端 API 基础地址
24
  BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
@@ -43,6 +43,22 @@ export const API = {
43
  // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
44
  SEARCH_TIMEOUT: 10000,
45
  SEARCH_RETRIES: 1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  // 🔍 创作者搜索请求的取消分组 ID
48
  // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
 
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string }} */
22
  export const API = {
23
  // 云端 API 基础地址
24
  BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
 
43
  // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
44
  SEARCH_TIMEOUT: 10000,
45
  SEARCH_RETRIES: 1,
46
+
47
+ // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
48
+ // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
49
+ // 这次请求纯属后台行为;它每多挂 1 秒就多占 1 个并发额度(全局仅 6 个),
50
+ // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
51
+ // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
52
+ BACKGROUND_TIMEOUT: 10000,
53
+ BACKGROUND_RETRIES: 0,
54
+
55
+ // ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
56
+ // navigator.onLine 只反映本机网卡,云端单独不可达(HF Space 宕机/冷启动、DNS 污染、被墙)
57
+ // 时它仍为 true,于是每个界面、每次轮询都要从头烧一遍完整重试预算,且互不共享失败结论。
58
+ // 首次确证云端不可达后进入冷却:冷却期内所有 GET 一律不联网(有缓存直接返回、无缓存立即失败),
59
+ // 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
60
+ // 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
61
+ CLOUD_DOWN_COOLDOWN: 60 * 1000,
62
 
63
  // 🔍 创作者搜索请求的取消分组 ID
64
  // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
前端页面/core/状态管理.js CHANGED
@@ -8,7 +8,8 @@
8
  // - 所有需要共享状态的组件
9
  // - 顶部导航组件.js (用户登录状态)
10
  // - 侧边栏主程序.js (当前视图状态)
11
- // - 网络请求_基础设施.js / 网络请求_图片代理.js (调用 isOnline() 决定是否走缓存回退)
 
12
  // ==========================================
13
  // 🏗️ P2架构优化:轻量级状态管理
14
  // 🏗️ P2质量优化:JSDoc 类型注释
@@ -20,7 +21,7 @@
20
  * @typedef {import('./类型定义.js').WalletData} WalletData
21
  */
22
 
23
- import { CACHE } from "./全局配置.js";
24
  import { clearSensitiveCache, clearAllCache } from "../components/性能优化工具.js";
25
 
26
 
@@ -54,9 +55,12 @@ const state = {
54
  users: new Map()
55
  },
56
 
57
- // 网络状态(全项目唯一数据源,见下方「🌐 网络状态管理」段落)
58
  network: {
59
- isOnline: navigator.onLine
 
 
 
60
  }
61
  };
62
 
@@ -438,6 +442,9 @@ export function isLoading() {
438
  // 这类多份状态不同步的分裂表现:
439
  // - 网络请求_基础设施.js —— 离线 GET 直返缓存、重试耗尽回退过期缓存
440
  // - 网络请求_图片代理.js —— 离线时不构造远程直链,保持相对路径原样
 
 
 
441
 
442
  /**
443
  * 更新网络状态(仅供内部 online/offline 监听调用)
@@ -450,6 +457,9 @@ export function setNetworkStatus(online) {
450
  if (wasOnline !== online) {
451
  console.log(online ? '🌐 网络已恢复' : '📴 网络已断开');
452
  eventBus.emit(EVENTS.NETWORK_CHANGE, { online });
 
 
 
453
  }
454
  }
455
 
@@ -457,7 +467,7 @@ export function setNetworkStatus(online) {
457
  * 检查是否在线
458
  *
459
  * 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
460
- * 云端不可达(502 / DNS 污染 / 证书失败)由 request() 的重试耗尽兜底负责。
461
  * @returns {boolean}
462
  */
463
  export function isOnline() {
@@ -471,6 +481,51 @@ if (typeof window !== "undefined") {
471
  }
472
 
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  // ==========================================
475
  // 📊 缓存状态管理
476
  // ==========================================
@@ -570,6 +625,9 @@ export default {
570
  // 网络
571
  isOnline,
572
  setNetworkStatus,
 
 
 
573
 
574
  // 缓存
575
  setCacheData,
 
8
  // - 所有需要共享状态的组件
9
  // - 顶部导航组件.js (用户登录状态)
10
  // - 侧边栏主程序.js (当前视图状态)
11
+ // - 网络请求_基础设施.js (调用 isOnline() / isCloudReachable() 决定是否走缓存回退,并回写 markCloudDown/markCloudUp)
12
+ // - 网络请求_图片代理.js (仅调用 isOnline():媒体链路的云端不可达已由本地插件层 api_cache.py 的熔断兜住,见技术文档 02 章 2.4.1.1)
13
  // ==========================================
14
  // 🏗️ P2架构优化:轻量级状态管理
15
  // 🏗️ P2质量优化:JSDoc 类型注释
 
21
  * @typedef {import('./类型定义.js').WalletData} WalletData
22
  */
23
 
24
+ import { API, CACHE } from "./全局配置.js";
25
  import { clearSensitiveCache, clearAllCache } from "../components/性能优化工具.js";
26
 
27
 
 
55
  users: new Map()
56
  },
57
 
58
+ // 网络状态(全项目唯一数据源,见下方「🌐 网络状态管理」与「☁️ 云端可达性」两个段落)
59
  network: {
60
+ isOnline: navigator.onLine,
61
+ // 云端不可达冷却的截止时间戳(0 = 云端可达)。与 isOnline 刻意分开:
62
+ // 前者答「云端服务器能不能连上」,后者只答「本机网卡通不通」
63
+ cloudDownUntil: 0
64
  }
65
  };
66
 
 
442
  // 这类多份状态不同步的分裂表现:
443
  // - 网络请求_基础设施.js —— 离线 GET 直返缓存、重试耗尽回退过期缓存
444
  // - 网络请求_图片代理.js —— 离线时不构造远程直链,保持相对路径原样
445
+ //
446
+ // ⚠️ isOnline() 只能回答「本机网卡通不通」。云端单独不可达(HF Space 宕机/冷启动、
447
+ // DNS 污染、被墙)时它仍为 true,因此另设下方「☁️ 云端可达性」作为第二个共享数据源。
448
 
449
  /**
450
  * 更新网络状态(仅供内部 online/offline 监听调用)
 
457
  if (wasOnline !== online) {
458
  console.log(online ? '🌐 网络已恢复' : '📴 网络已断开');
459
  eventBus.emit(EVENTS.NETWORK_CHANGE, { online });
460
+ // 🔗 本机网卡一旦真正断开,云端必然不可达,直接打上冷却,
461
+ // 免得每个界面还要各自烧一遍重试预算才能得出同一个结论
462
+ if (!online) markCloudDown('本机网络已断开');
463
  }
464
  }
465
 
 
467
  * 检查是否在线
468
  *
469
  * 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
470
+ * 云端可达性请一律改问 isCloudReachable()(见下方段落)。
471
  * @returns {boolean}
472
  */
473
  export function isOnline() {
 
481
  }
482
 
483
 
484
+ // ==========================================
485
+ // ☁️ 云端可达性(全项目共享,与 isOnline() 并列的第二个网络数据源)
486
+ // ==========================================
487
+ // 🏗️ 解决的问题:本机有网但云端连不上时,isOnline() 永远返回 true,
488
+ // 「离线直返缓存」的快速通道永远不触发,于是每个界面、每轮消息轮询都要从头
489
+ // 烧一遍完整重试预算(列表约 61s、其余 GET 约 93s),而全局并发额度只有 6 个,
490
+ // 挂死的后台请求会把前台请求全部挤到排队 —— 表现为「切哪个界面都要等」。
491
+ //
492
+ // 口径(三条,改动前务必看清):
493
+ // - 只有 GET 受冷却约束。POST/PUT/DELETE 是用户主动操作(登录、发布、购买、点赞),
494
+ // 一律照常发出,绝不允许被冷却拦下
495
+ // - 被动探测:冷却期内不额外发任何探测请求;冷却一过,下一个真实请求充当探针
496
+ // - 任意一次成功响应(含非 GET)立即清零,不必等到冷却自然结束
497
+
498
+ /**
499
+ * 云端当前是否可达(冷却已过期或从未进入冷却即为可达)
500
+ * @returns {boolean}
501
+ */
502
+ export function isCloudReachable() {
503
+ return Date.now() >= state.network.cloudDownUntil;
504
+ }
505
+
506
+ /**
507
+ * 标记云端不可达,进入冷却
508
+ * @param {string} [reason] - 仅用于日志定位(如失败的 endpoint)
509
+ */
510
+ export function markCloudDown(reason = '') {
511
+ const wasReachable = isCloudReachable();
512
+ state.network.cloudDownUntil = Date.now() + API.CLOUD_DOWN_COOLDOWN;
513
+ // 只在「可达 → 不可达」的跃迁时打一条日志:冷却期内的重复失败不再刷屏
514
+ if (wasReachable) {
515
+ console.warn(`☁️ 云端不���达,${API.CLOUD_DOWN_COOLDOWN / 1000} 秒内不再联网${reason ? `(${reason})` : ''}`);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * 标记云端已恢复(任意一次成功响应调用),立即清零冷却
521
+ */
522
+ export function markCloudUp() {
523
+ if (state.network.cloudDownUntil === 0) return;
524
+ state.network.cloudDownUntil = 0;
525
+ console.log('☁️ 云端已恢复,重新联网');
526
+ }
527
+
528
+
529
  // ==========================================
530
  // 📊 缓存状态管理
531
  // ==========================================
 
625
  // 网络
626
  isOnline,
627
  setNetworkStatus,
628
+ isCloudReachable,
629
+ markCloudDown,
630
+ markCloudUp,
631
 
632
  // 缓存
633
  setCacheData,
前端页面/core/网络请求_基础设施.js CHANGED
@@ -11,7 +11,7 @@
11
 
12
  import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
  import { API, CACHE } from "./全局配置.js";
14
- import { logoutAndClearUserData, isOnline } from "./状态管理.js";
15
  import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
16
  import { unproxyImages } from "./网络请求_图片代理.js";
17
  // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
@@ -232,17 +232,29 @@ async function request(endpoint, options = {}) {
232
  }
233
  }
234
 
 
 
 
 
 
 
 
235
  // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
236
- if (!isOnline() && method === "GET") {
237
- const { value, expired, found } = getCacheWithMeta(cacheKey, true); // 忽略过期
238
- if (found) {
239
- console.log(`📴 离线模式:返回${expired ? '过期' : ''}缓存 (${endpoint})`);
 
 
 
 
240
  const proxyFn2 = await getProxyImages();
241
- return proxyFn2(value);
242
  }
243
  // 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
244
- // 导致英文界面下仍弹中文提示;现改走词典
245
- throw new Error(t('feedback.offline_no_cache'));
 
246
  }
247
 
248
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
@@ -278,6 +290,16 @@ async function request(endpoint, options = {}) {
278
  defaultRetries = API.LIST_RETRIES;
279
  }
280
  }
 
 
 
 
 
 
 
 
 
 
281
  const requestTimeout = options.timeout || defaultTimeout;
282
  const maxRetries = options.retries ?? defaultRetries;
283
  const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
@@ -285,10 +307,16 @@ async function request(endpoint, options = {}) {
285
  // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
286
  const requestPromise = (async () => {
287
  let lastError = null;
 
 
 
288
 
289
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
290
  // 🚀 P1优化:指数退避延迟
291
  if (attempt > 0) {
 
 
 
292
  const delay = retryDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s...
293
  console.log(`🔄 请求重试 (${attempt}/${maxRetries})...`);
294
  await new Promise(r => setTimeout(r, delay));
@@ -348,6 +376,10 @@ async function request(endpoint, options = {}) {
348
  continue; // 5xx 错误重试
349
  }
350
 
 
 
 
 
351
  // 🚀 P1优化:精确清除相关缓存(替代暴力清空)
352
  if (["POST", "PUT", "DELETE"].includes(method)) {
353
  invalidateRelatedCache(endpoint, method);
@@ -394,9 +426,18 @@ async function request(endpoint, options = {}) {
394
  // 🔧 修复:重试耗尽时不再直接 throw(否则循环后的「过期缓存兜底」
395
  // 分支永远不可达,断网时无法无缝切换本地数据)。记录友好错误后跳出循环,
396
  // 让 GET 请求走过期缓存回退;无缓存时仍以该友好错误抛出。
397
- lastError = error.name === 'AbortError'
 
398
  ? new Error('网络请求超时,请检查网络连接')
399
  : new Error('网络连接失败,请检查网络');
 
 
 
 
 
 
 
 
400
  if (attempt < maxRetries) continue; // 重试
401
  break; // 重试耗尽 → 跳到过期缓存兜底
402
  }
@@ -409,6 +450,10 @@ async function request(endpoint, options = {}) {
409
  }
410
  }
411
 
 
 
 
 
412
  // 所有重试都失败,尝试回退到过期缓存
413
  if (method === "GET") {
414
  const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期
 
11
 
12
  import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
  import { API, CACHE } from "./全局配置.js";
14
+ import { logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
15
  import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
16
  import { unproxyImages } from "./网络请求_图片代理.js";
17
  // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
 
232
  }
233
  }
234
 
235
+ // 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
236
+ // ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
237
+ // ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
238
+ const fallbackMeta = (method === "GET")
239
+ ? getCacheWithMeta(cacheKey, true) // 忽略过期
240
+ : { value: null, expired: false, found: false };
241
+
242
  // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
243
+ // ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
244
+ // 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
245
+ // 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
246
+ // 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
247
+ // 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
248
+ if (method === "GET" && (!isOnline() || !isCloudReachable())) {
249
+ if (fallbackMeta.found) {
250
+ console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存 (${endpoint})`);
251
  const proxyFn2 = await getProxyImages();
252
+ return proxyFn2(fallbackMeta.value);
253
  }
254
  // 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
255
+ // 导致英文界面下仍弹中文提示;现改走词典。云端不可达与本机断网分开用词,
256
+ // 否则本机明明有网的用户会被「网络已断开」误导去查自己的路由器
257
+ throw new Error(isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache'));
258
  }
259
 
260
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
 
290
  defaultRetries = API.LIST_RETRIES;
291
  }
292
  }
293
+ // 🐢 后台补新短预算:本地已有该请求的缓存,说明列表视图已经把内容渲染上屏了
294
+ // (七大列表都是「本地数据先上屏 → 再发请求补新」),这次请求纯属后台行为,
295
+ // 失败要快——它每多挂 1 秒就多占 1 个并发额度(全局仅 6 个)。用 Math.min 只收不放,
296
+ // 不会把搜索类已经收紧过的 10s 预算又放宽回去。
297
+ // noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
298
+ // 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
299
+ if (method === "GET" && !options.noCache && fallbackMeta.found) {
300
+ defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
301
+ defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
302
+ }
303
  const requestTimeout = options.timeout || defaultTimeout;
304
  const maxRetries = options.retries ?? defaultRetries;
305
  const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
 
307
  // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
308
  const requestPromise = (async () => {
309
  let lastError = null;
310
+ // ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
311
+ // 5xx 不置位:服务端已经应答说明云端可达,若也打冷却会让一个坏接口把全站 GET 拖下水
312
+ let networkLevelFailure = false;
313
 
314
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
315
  // 🚀 P1优化:指数退避延迟
316
  if (attempt > 0) {
317
+ // ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
318
+ // 继续重试只是白烧一轮超时预算、白占一个并发额度,直接跳去下方缓存兜底
319
+ if (method === "GET" && !isCloudReachable()) break;
320
  const delay = retryDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s...
321
  console.log(`🔄 请求重试 (${attempt}/${maxRetries})...`);
322
  await new Promise(r => setTimeout(r, delay));
 
376
  continue; // 5xx 错误重试
377
  }
378
 
379
+ // ☁️ 拿到了正常应答即证明云端可达,立即清零冷却(不必等它自然过期)。
380
+ // 非 GET 的成功同样算数——写操作是冷却期内唯一还在联网的请求,天然是探针
381
+ markCloudUp();
382
+
383
  // 🚀 P1优化:精确清除相关缓存(替代暴力清空)
384
  if (["POST", "PUT", "DELETE"].includes(method)) {
385
  invalidateRelatedCache(endpoint, method);
 
426
  // 🔧 修复:重试耗尽时不再直接 throw(否则循环后的「过期缓存兜底」
427
  // 分支永远不可达,断网时无法无缝切换本地数据)。记录友好错误后跳出循环,
428
  // 让 GET 请求走过期缓存回退;无缓存时仍以该友好错误抛出。
429
+ const isTimeout = error.name === 'AbortError';
430
+ lastError = isTimeout
431
  ? new Error('网络请求超时,请检查网络连接')
432
  : new Error('网络连接失败,请检查网络');
433
+ networkLevelFailure = true;
434
+ // ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
435
+ // 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
436
+ // 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫秒级的,
437
+ // 多等一轮只多花约 1 秒,却能挡住偶发抖动误伤 60 秒);
438
+ // 未配重试时(后台补新短预算,界面已用缓存上屏)误判零代价,立即定论。
439
+ // 超时则可能是 HF Space 冷启动(慢但能成功),不在此定论,耗尽后由下方统一处理
440
+ if (!isTimeout && (attempt >= 1 || maxRetries === 0)) markCloudDown(endpoint);
441
  if (attempt < maxRetries) continue; // 重试
442
  break; // 重试耗尽 → 跳到过期缓存兜底
443
  }
 
450
  }
451
  }
452
 
453
+ // ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
454
+ // 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
455
+ if (networkLevelFailure) markCloudDown(endpoint);
456
+
457
  // 所有重试都失败,尝试回退到过期缓存
458
  if (method === "GET") {
459
  const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期