Spaces:
Running
Running
Upload 62 files
Browse files- 前端页面/components/用户体验_国际化.js +4 -0
- 前端页面/core/侧边栏数据引擎.js +42 -3
- 前端页面/core/全局配置.js +17 -1
- 前端页面/core/状态管理.js +63 -5
- 前端页面/core/网络请求_基础设施.js +54 -9
前端页面/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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
* 云端
|
| 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 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
const proxyFn2 = await getProxyImages();
|
| 241 |
-
return proxyFn2(value);
|
| 242 |
}
|
| 243 |
// 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
|
| 244 |
-
// 导致英文界面下仍弹中文提示;现改走词典
|
| 245 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 = 允许过期
|