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

Upload 104 files

Browse files
前端页面/core/全局配置.js CHANGED
@@ -1,992 +1,997 @@
1
- // 前端页面/core/全局配置.js
2
- // ==========================================
3
- // ⚙️ 全局配置中心
4
- // ==========================================
5
- // 作用:集中管理所有配置常量,便于维护和修改
6
- // 关联文件:
7
- // - 网络请求API.js (API基础地址)
8
- // - 性能优化工具.js (缓存配置)
9
- // - 侧边栏数据引擎.js (分页配置)
10
- // - 所有业务组件
11
- // ==========================================
12
- // 🏗️ P2架构优化:配置集中化
13
- // 🏗️ P2质量优化:JSDoc 类型注释
14
- // ==========================================
15
-
16
-
17
- // ==========================================
18
- // 🌐 API 配置
19
- // ==========================================
20
-
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, SSE_MAX_RESPONSE_BYTES: number, SSE_MAX_LINE_BYTES: number, SSE_MAX_LINES: number, SSE_YIELD_LINES: number, SSE_YIELD_BYTES: number, SSE_YIELD_CHUNKS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BANNER_IMAGE_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, PROFILE_MEDIA_URL_MAX_BYTES: number, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp, REQUEST_ID_HEADER: string, REQUEST_ID_PATTERN: RegExp, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string, REQUEST_BODY_ERRORS: Record<string, { status: number, msgKey: string }> }} */
22
- export const API = {
23
- REQUEST_ID_HEADER: "X-Ranking-Request-Id",
24
- REQUEST_ID_PATTERN: /^(?:[a-f0-9]{32}|ERR_[0-9]{10,13}_[a-f0-9]{1,8})$/,
25
- ENDPOINT_ERROR_SCOPE_HEADER: "X-Ranking-Error-Scope",
26
- ENDPOINT_ERROR_SCOPE_VALUE: "endpoint",
27
- REQUEST_BODY_ERRORS: {
28
- request_body_invalid: { status: 400, msgKey: 'feedback.invalid_request_data' },
29
- request_body_disconnected: { status: 400, msgKey: 'feedback.network_unavailable' },
30
- request_body_too_large: { status: 413, msgKey: 'feedback.request_too_large' },
31
- request_body_timeout: { status: 408, msgKey: 'feedback.request_receive_timeout' },
32
- request_body_busy: { status: 429, msgKey: 'feedback.request_busy' },
33
- request_body_encoding: { status: 415, msgKey: 'feedback.request_encoding_unsupported' },
34
- },
35
- // 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
36
- PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
37
- CONDITIONAL_ETAG_PATTERN: /^W\/"rk-[a-f0-9]{64}"$/,
38
-
39
- // 云端 API 基础地址
40
- BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
41
-
42
- // 请求超时时间(毫秒)
43
- TIMEOUT: 30000,
44
- VIDEO_UPLOAD_TIMEOUT: 300000,
45
- VIDEO_UPLOAD_RESPONSE_MAX_CHARS: 65536,
46
- LOCAL_CACHE_STATS_TIMEOUT: 10000,
47
- LOCAL_CACHE_CLEAR_TIMEOUT: 60000,
48
- LOCAL_CACHE_RESPONSE_MAX_BYTES: 8192,
49
- // 工作流终态编码上限为512 MiB,另保留64 MiB进度数据;行预算包含data前缀。
50
- SSE_MAX_RESPONSE_BYTES: 576 * 1024 * 1024,
51
- SSE_MAX_LINE_BYTES: 512 * 1024 * 1024,
52
- SSE_MAX_LINES: 262144,
53
- SSE_YIELD_LINES: 256,
54
- SSE_YIELD_BYTES: 1024 * 1024,
55
- SSE_YIELD_CHUNKS: 256,
56
-
57
- // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
58
- // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
59
- // 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
60
- MAX_RETRIES: 2,
61
-
62
- // 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
63
- RETRY_DELAY: 1000,
64
-
65
- // 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
66
- // 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
67
- // 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
68
- // 可能撞上 HF Space 冷启动(本项目无预热机制),故只砍掉一次冗余重试、保留 30s 单次超时,
69
- // 93s → 61s,冷启动仍有机会在前两次尝试内完成
70
- LIST_RETRIES: 1,
71
- // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
72
- SEARCH_TIMEOUT: 10000,
73
- SEARCH_RETRIES: 1,
74
- PROFILE_RESOURCE_TIMEOUT: 5000,
75
- CREATOR_CHART_SCRIPT_URL: "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js",
76
-
77
- // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
78
- // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
79
- // 这次请求纯属后台行为;它每多挂 1 秒就多占 1 个并发额度(全局仅 6 个),
80
- // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
81
- // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
82
- BACKGROUND_TIMEOUT: 10000,
83
- BANNER_IMAGE_TIMEOUT: 75000,
84
- WORKFLOW_DOWNLOAD_TIMEOUT: 120000, // 工作流流式下载预算(2 分钟)
85
- BACKGROUND_RETRIES: 0,
86
-
87
- // ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
88
- // navigator.onLine 只反映本机网卡,云端单独不可达(HF Space 宕机/冷启动、DNS 污染、被墙)
89
- // 时它仍为 true,于是每个界面、每次轮询都要从头烧一遍完整重试预算,且互不共享失败结论。
90
- // 首次确证云端不可达后进入冷却:冷却期内所有 GET 一律不联网(有缓存直接返回、无缓存立即失败),
91
- // 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
92
- // 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
93
- CLOUD_DOWN_COOLDOWN: 60 * 1000,
94
-
95
- // 🔍 创作者搜索请求的取消分组 ID
96
- // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
97
- // 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
98
- // 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
99
- SEARCH_COMPONENT_ID: "sidebar-search",
100
- // 上传相对路径的云端图片读取入口。
101
- IMAGE_PROXY_PATH: "/api/image_proxy",
102
- PROFILE_MEDIA_URL_MAX_BYTES: 16384,
103
- // 本地广告预载要求真实图片,熔断占位图不能作为下载成功。
104
- LOCAL_IMAGE_PROXY_PREFIX: "/community_hub/image?url=",
105
- COMPLETE_IMAGE_QUERY_SUFFIX: "&require_complete=1",
106
- // 身份相关或私有 GET 禁止通用响应缓存;公开列表仍使用原缓存策略。
107
- PRIVATE_GET_PATTERNS: [
108
- /^\/api\/(wallet|messages|chats|admin|disputes|users)(?:\/|$)/,
109
- /^\/api\/my-(posts|prompts|tasks)(?:\/|$)/,
110
- /^\/api\/(tasks|prompts)\/[^/]+$/
111
- ]
112
- };
113
-
114
-
115
- export const LOCAL_PERFORMANCE = Object.freeze({
116
- MAX_ENDPOINT_CHARS: 4096,
117
- MAX_COUNT: 1000000,
118
- MAX_DURATION_MS: 600000,
119
- ITEM_CATEGORIES: Object.freeze({ tool: 'tools', skill: 'skills', app: 'apps', recommend: 'recommends' }),
120
- LIST_CATEGORIES: Object.freeze({ '/api/creators': 'creators', '/api/creators/search': 'creators', '/api/tasks': 'tasks', '/api/posts': 'posts', '/api/prompts': 'prompts' }),
121
- LABELS: Object.freeze({ tools: 'nav.tools', skills: 'nav.skills', apps: 'nav.apps', recommends: 'nav.recommends', creators: 'nav.creators', tasks: 'nav.tasks', posts: 'nav.posts', prompts: 'nav.prompts' })
122
- });
123
-
124
- export const LOCAL_INSTALL_DIRECTORY = Object.freeze({
125
- PATH: '/community_hub/open_install_directory',
126
- TIMEOUT: 25000,
127
- RESPONSE_MAX_BYTES: 8192
128
- });
129
-
130
- export const SKILL_INSTALL = Object.freeze({
131
- PATH: '/community_hub/skill_install',
132
- PREFLIGHT_TIMEOUT: 30000,
133
- CONTROL_TIMEOUT: 45000,
134
- RESULT_TIMEOUT: 5000,
135
- STREAM_TIMEOUT: 1200000,
136
- RESPONSE_MAX_BYTES: 1024 * 1024,
137
- PATH_MAX_BYTES: 32768,
138
- MAX_DIRECTORIES: 128,
139
- MAX_ITEMS: 1,
140
- MODAL_WIDTH: '640px'
141
- });
142
-
143
- export const CHART_LIFECYCLE = Object.freeze({
144
- WIDTH_WAIT_TIMEOUT: 5000,
145
- RESIZE_DELAY: 300
146
- });
147
-
148
- // 发布视频的本地读取预算,不改变云端上传额度。
149
- export const LAZY_IMAGES = Object.freeze({
150
- ROOT_MARGIN: "200px 0px",
151
- THRESHOLD: 0.01,
152
- MOUNT_TIMEOUT: 30000,
153
- READ_TIMEOUT: 30000
154
- });
155
-
156
- export const PUBLISH_MEDIA_LIMITS = Object.freeze({
157
- MAX_IMAGES: 9,
158
- IMAGE_MAX_EDGE: 1920,
159
- IMAGE_QUALITY: 0.85,
160
- VIDEO_MAX_BYTES: 100 * 1024 * 1024,
161
- VIDEO_MAX_SECONDS: 180,
162
- VIDEO_METADATA_TIMEOUT: 15000
163
- });
164
-
165
- // 上传前处理预算;浏览器解码本身仍由宿主管理。
166
- export const IMAGE_PROCESS = {
167
- HEADER_BYTES: 256 * 1024,
168
- MAX_INPUT_BYTES: 50 * 1024 * 1024,
169
- MAX_PIXELS: 40 * 1024 * 1024,
170
- MAX_SOURCE_EDGE: 16384,
171
- MAX_OUTPUT_EDGE: 4096,
172
- MAX_OUTPUT_BYTES: 5 * 1024 * 1024,
173
- TIMEOUT: 15000,
174
- MIN_QUALITY: 0.3,
175
- QUALITY_STEP: 0.1,
176
- AVATAR_MAX_BYTES: 2 * 1024 * 1024,
177
- AVATAR_SIZE: 512
178
- };
179
-
180
- // ==========================================
181
- // 🔐 管理员配置 (P0安全修复:管理员账号配置化)
182
- // ==========================================
183
- // 注意:前端仅用于UI显示控制,后端从环境变量 ADMIN_ACCOUNTS 读取
184
- // 修改此处需同步修改云端环境变量
185
- export const ADMIN_ACCOUNTS = new Set(["123456", "888888"]);
186
-
187
- /**
188
- * 检查账号是否为管理员
189
- * @param {string} account - 用户账号
190
- * @returns {boolean}
191
- */
192
- export function isAdmin(account) {
193
- return ADMIN_ACCOUNTS.has(account);
194
- }
195
-
196
-
197
- // ==========================================
198
- // 💾 缓存配置
199
- // ==========================================
200
- export function getSidebarListCacheKey(tab, sort) {
201
- const list = tab === 'creators' ? 'creators_full' : tab;
202
- return `ListCache_${list}_${sort}`;
203
- }
204
-
205
- const RESOURCE_LIST_PREFIXES = [
206
- "ListCache_tools_", "ListCache_skills_", "ListCache_apps_", "ListCache_recommends_"
207
- ];
208
- export const CACHE = {
209
- // 缓存键前缀
210
- PREFIX: "ComfyRanking_",
211
-
212
- // 默认缓存时间(毫秒)
213
- DEFAULT_TTL: 2 * 60 * 60 * 1000, // 2小时
214
-
215
- // 各类数据缓存时间
216
- TTL: {
217
- LIST_DATA: 5 * 60 * 1000, // 列表数据:5分钟
218
- DETAIL_DATA: 3 * 60 * 1000, // 详情数据:3分钟
219
- USER_PROFILE: 2 * 60 * 1000, // 用户资料:2分钟
220
- WALLET: 30 * 1000, // 钱包数据:30秒
221
- CREATORS: 5 * 60 * 1000, // 创作者列表:5分钟
222
- CHAT_HISTORY: 60 * 1000 // 聊天记录:1分钟
223
- },
224
-
225
- // 内存缓存最大条目数
226
- MAX_MEMORY_ITEMS: 100,
227
-
228
- // 本插件 localStorage 持久缓存的估算容量上限(不影响用户/会话等其它键)
229
- MAX_STORAGE_BYTES: 2 * 1024 * 1024,
230
-
231
- // localStorage 键名(旧版兼容)
232
- SESSION_REMEMBER_TTL: 30 * 24 * 60 * 60 * 1000,
233
- INSTALLED_VERSION_UNCONFIRMED: "__installed_unconfirmed__", // 安装成功但没有实际版本证明
234
- SESSION_TEMP_TTL: 24 * 60 * 60 * 1000,
235
- LEGACY_KEYS: {
236
- USER: "ComfyCommunity_User",
237
- TOKEN: "ComfyCommunity_Token",
238
- LIST_CACHE: "ComfyCommunity_ListCache",
239
- PROFILE_CACHE: "ComfyCommunity_ProfileCache",
240
- CHAT_HISTORY: "ComfyCommunity_ChatHistory"
241
- },
242
-
243
- // 本地背景存储键(不上传云端)
244
- LOCAL_KEYS: {
245
- BANNER_CONFIG: "ComfyRanking_BannerConfig", // 广告配置持久缓存
246
- CREATORS_SESSION: "ComfyRanking_CreatorsCache", // 沿用创作者临时缓存键
247
- SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
248
- PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
249
- ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
250
- PROFILE_LIST_PREFIX: "ProfileList_", // 兼容旧个人列表业务键
251
- INSTALLED_VERSION_PREFIX: "ComfyCommunity_LocalVer_", // 沿用既有安装版本键
252
- ITEM_OWNERSHIP_PREFIX: "ItemOwnership_", // 缓存管理器业务键前缀
253
- // 🚀 新增:私信和通知本地缓存
254
- NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
255
- CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
256
- CHAT_HISTORY_PREFIX: "ComfyRanking_ChatHistory_", // 聊天记录前缀
257
- NOTIF_CLEAR_TIME: "ComfyRanking_NotifClearTime", // 通知清空时间戳
258
- CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
259
- },
260
-
261
- REFUND_CACHE_PREFIXES: [
262
- "api_/api/items", "api_/api/creators", "api_/api/wallet",
263
- ...RESOURCE_LIST_PREFIXES
264
- ],
265
- RESOURCE_LIST_PREFIXES,
266
-
267
- // 🚀 新增:消息轮询配置
268
- MESSAGE_POLL: {
269
- INTERVAL: 30 * 1000, // 轮询间隔:30秒
270
- ENABLED: true // 是否启用定时检查
271
- }
272
- };
273
-
274
-
275
- // ==========================================
276
- // 📜 分页配置
277
- // ==========================================
278
- export const PAGINATION = {
279
- // 工具/应用每页数量
280
- ITEMS_PAGE_SIZE: 20,
281
-
282
- // 创作者每页数量
283
- CREATORS_PAGE_SIZE: 12,
284
- SIDEBAR_ITEM_FETCH_LIMIT: 200,
285
- SIDEBAR_CREATOR_FETCH_LIMIT: 500,
286
- SIDEBAR_CREATOR_SEARCH_LIMIT: 50,
287
- PROFILE_USERS_PAGE_SIZE: 20,
288
- PROFILE_RESOURCE_FETCH_LIMIT: 200,
289
-
290
- // 评论每页数量
291
- COMMENTS_PAGE_SIZE: 10,
292
-
293
- // 消息每页数量
294
- MESSAGES_PAGE_SIZE: 20,
295
- CHAT_MESSAGES_PAGE_SIZE: 50,
296
-
297
- // 滚动加载触发阈值(距底部像素)
298
- SCROLL_THRESHOLD: 300,
299
-
300
- // 防抖延迟(毫秒)
301
- DEBOUNCE_DELAY: 100
302
- };
303
-
304
-
305
- // ==========================================
306
- // 💰 打赏等级配置
307
- // ==========================================
308
- export const TIP_LEVELS = {
309
- // 每颗星星需要的积分
310
- POINTS_PER_STAR: 100,
311
-
312
- // 每个月亮需要的星星数
313
- STARS_PER_MOON: 5,
314
-
315
- // 每个太阳需要的月亮数
316
- MOONS_PER_SUN: 5,
317
-
318
- // 最高等级太阳数
319
- MAX_SUNS: 9,
320
-
321
- // 最高积分(9太阳 = 22500积分)
322
- MAX_POINTS: 22500,
323
-
324
- // 等级图标
325
- ICONS: {
326
- STAR: "⭐",
327
- MOON: "🌙",
328
- SUN: "☀️",
329
- CROWN: "👑"
330
- },
331
-
332
- // 等级称号
333
- TITLES: {
334
- MAX_LEVEL: "👑 至尊赞助者",
335
- HIGH_SUN: "超级赞助者",
336
- LOW_SUN: "黄金赞助者",
337
- HIGH_MOON: "白银赞助者",
338
- LOW_MOON: "青铜赞助者",
339
- HIGH_STAR: "热心粉丝",
340
- DEFAULT: "支持者"
341
- }
342
- };
343
-
344
-
345
- // ==========================================
346
- // 🎨 UI 配置
347
- // ==========================================
348
- export const UI = {
349
- INSTALL_SOURCE: Object.freeze({
350
- MAX_URL_BYTES: 4096,
351
- CASE_INSENSITIVE_HOSTS: Object.freeze(['github.com'])
352
- }),
353
- INSTALL_TRANSFER: Object.freeze({
354
- MAX_BYTES: 2 * 1024 * 1024 * 1024,
355
- MAX_RATE: Number.MAX_SAFE_INTEGER,
356
- BASE: 1024,
357
- STAGES: Object.freeze(['downloading', 'download_done']),
358
- UNIT_KEYS: Object.freeze(['install.transfer_b', 'install.transfer_kib', 'install.transfer_mib', 'install.transfer_gib'])
359
- }),
360
-
361
- CHAT_MAX_MSG_LENGTH: 1000,
362
- CHAT_TIME_GAP_SECONDS: 5 * 60,
363
- MAX_DISPUTE_EVIDENCE: 6,
364
- RECHARGE_POLL_INTERVAL: 3000,
365
- RECHARGE_POLL_LIMIT: 100,
366
- RECHARGE_QR_MAX_BYTES: 2048,
367
- PROFILE_BANNER_CACHE_BYTES: 512 * 1024,
368
- // 与云端公开版本接口的缺省值一致;用于网络不可用时的本地展示。
369
- DEFAULT_PROJECT_VERSION: Object.freeze({ stage: "beta", major: 2, minor: 0, patch: 0 }),
370
- ABOUT: {
371
- AUTHOR_URL: "https://space.bilibili.com/2114638644",
372
- AUTHOR_BADGE_URL: "https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white"
373
- },
374
- // Toast 消息配置
375
- TOAST: {
376
- OFFSET: 50,
377
- GAP: 10,
378
- DURATION: 3000, // 显示时长(毫秒)
379
- MAX_VISIBLE: 3, // 最大同时显示数量
380
- POSITION: "top-center" // 位置
381
- },
382
-
383
- // 动画配置
384
- ANIMATION: {
385
- DURATION_FAST: 150,
386
- DURATION_NORMAL: 300,
387
- DURATION_SLOW: 500,
388
- EASING: "cubic-bezier(0.25, 0.8, 0.25, 1)"
389
- },
390
-
391
- // 颜色主题
392
- COLORS: {
393
- PRIMARY: "#2196F3",
394
- SUCCESS: "#4CAF50",
395
- WARNING: "#FF9800",
396
- ERROR: "#F44336",
397
- INFO: "#2196F3",
398
- TIP: "#E91E63",
399
-
400
- // 深色主题背景
401
- BG_DARK: "var(--comfy-menu-bg)",
402
- BG_CARD: "var(--comfy-input-bg)",
403
- BG_INPUT: "var(--comfy-input-bg)",
404
-
405
- // 边框
406
- BORDER: "#444",
407
- BORDER_LIGHT: "#555"
408
- },
409
-
410
- // 图片占位图(🚀 关键修复:全部改为内联 SVG,避免外部网络请求)
411
- PLACEHOLDERS: {
412
- // 头像占位图(👤 图标)
413
- AVATAR: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Crect fill='%23333' width='150' height='150' rx='75'/%3E%3Ctext x='75' y='100' text-anchor='middle' fill='%23888' font-size='60'%3E👤%3C/text%3E%3C/svg%3E`,
414
- // 封面占位图(🖼️ 图标)
415
- COVER: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='260'%3E%3Crect fill='%23222' width='400' height='260'/%3E%3Ctext x='200' y='140' text-anchor='middle' fill='%23666' font-size='48'%3E🖼️%3C/text%3E%3C/svg%3E`,
416
- // 小头像占位图(用于列表)
417
- AVATAR_SMALL: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect fill='%23333' width='40' height='40' rx='20'/%3E%3Ctext x='20' y='26' text-anchor='middle' fill='%23888' font-size='16'%3E👤%3C/text%3E%3C/svg%3E`,
418
- // 加载占位图
419
- LOADING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%231a1a1a' width='100' height='100'/%3E%3C/svg%3E"
420
- }
421
- };
422
-
423
- // 🚀 导出 PLACEHOLDERS 便于直接使用
424
- export const PLACEHOLDERS = UI.PLACEHOLDERS;
425
-
426
-
427
- // ==========================================
428
- // 📝 表单验证配置
429
- // ==========================================
430
- export const VALIDATION = {
431
- // 账号规则
432
- ACCOUNT: {
433
- MIN_LENGTH: 6,
434
- MAX_LENGTH: 20,
435
- PATTERN: /^[a-zA-Z0-9_]{6,20}$/,
436
- MESSAGE: "账号仅支持6-20位字母、数字、下划线"
437
- },
438
-
439
- // 密码规则
440
- PASSWORD: {
441
- MIN_LENGTH: 6,
442
- PATTERN: /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{6,}$/,
443
- MESSAGE: "密码至少6位,支持字母、数字和常见特殊字符"
444
- },
445
-
446
- // 昵称规则
447
- NAME: {
448
- MIN_LENGTH: 1,
449
- MAX_LENGTH: 20,
450
- MESSAGE: "昵称长度1-20个字符"
451
- },
452
-
453
- // 个人介绍规则
454
- INTRO: {
455
- MAX_LENGTH: 100,
456
- MESSAGE: "个人介绍不能超过100字"
457
- },
458
-
459
- // 验证码规则
460
- VERIFY_CODE: {
461
- LENGTH: 6,
462
- PATTERN: /^\d{6}$/,
463
- EXPIRE_SECONDS: 600,
464
- MESSAGE: "请输入6位数字验证码"
465
- }
466
- };
467
-
468
-
469
- // ==========================================
470
- // 🔧 功能开关
471
- // ==========================================
472
- export const FEATURES = {
473
- // 是否启用调试模式
474
- DEBUG: false,
475
-
476
- // 是否启用性能监控
477
- PERFORMANCE_MONITOR: false,
478
-
479
- // 是否启用离线模式提示
480
- OFFLINE_DETECTION: true,
481
-
482
- // 是否启用图片懒加载
483
- LAZY_LOAD_IMAGES: true,
484
-
485
- // 是否启用分页加载
486
- PAGINATION_ENABLED: true,
487
-
488
- // 是否启用请求缓存
489
- REQUEST_CACHE: true,
490
-
491
- // 是否启用骨架屏
492
- SKELETON_LOADING: true
493
- };
494
-
495
-
496
- // ==========================================
497
- // 🌐 运行环境开关
498
- // ==========================================
499
- // 移动端网页版运行时开关:Web 引导页(云端Space代码/移动端网页/index.html)
500
- // 在模块求值前置位 window.__RANKING_WEB_MODE__ = true;ComfyUI 环境不置位,恒为 false,
501
- // 本地侧边栏行为零变化。所有「仅本地可用」功能的门控均以此为准
502
- export const IS_WEB_MODE = typeof window !== "undefined" && window.__RANKING_WEB_MODE__ === true;
503
-
504
-
505
- // ==========================================
506
- // 📊 统计埋点配置(预留)
507
- // ==========================================
508
- export const ANALYTICS = {
509
- // 是否启用统计
510
- ENABLED: false,
511
-
512
- // 统计服务地址
513
- ENDPOINT: "",
514
-
515
- // 采样率(0-1)
516
- SAMPLE_RATE: 1.0
517
- };
518
-
519
-
520
- // ==========================================
521
- // 🔗 外部链接
522
- // ==========================================
523
- export const LINKS = {
524
- // GitHub 仓库
525
- GITHUB_REPO: "https://github.com/a63976659/ComfyUI-Ranking",
526
-
527
- // 文档地址
528
- DOCS: "",
529
-
530
- // 反馈地址
531
- FEEDBACK: ""
532
- };
533
-
534
-
535
- // ==========================================
536
- // 🛠️ 辅助函数
537
- // ==========================================
538
-
539
- /**
540
- * 获取完整的 API 地址
541
- * @param {string} endpoint - API 端点
542
- * @returns {string}
543
- */
544
- export function getApiUrl(endpoint) {
545
- return `${API.BASE_URL}${endpoint}`;
546
- }
547
-
548
- /**
549
- * 获取当前登录用户账号
550
- * @returns {string|null}
551
- */
552
- export function getCurrentAccount() {
553
- try {
554
- const userStr = localStorage.getItem(CACHE.LEGACY_KEYS.USER) || sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
555
- if (userStr) {
556
- const userData = JSON.parse(userStr);
557
- return userData?.user?.account || null;
558
- }
559
- } catch (e) {
560
- console.warn("获取当前账号失败。");
561
- }
562
- return null;
563
- }
564
-
565
- /**
566
- * 获取账号区分的界面背景图存储键
567
- * @param {string} [account] - 账号,不传则自动获取当前登录账号
568
- * @returns {string}
569
- */
570
- export function getBackgroundKey(account) {
571
- const acc = account || getCurrentAccount();
572
- return acc
573
- ? `${CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND}_${acc}`
574
- : CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND;
575
- }
576
-
577
- /**
578
- * 获取账号区分的个人资料卡背景缓存键
579
- * @param {string} [account] - 账号,不传则自动获取当前登录账号
580
- * @returns {string}
581
- */
582
- export function getBannerCacheKey(account) {
583
- const acc = account || getCurrentAccount();
584
- return acc
585
- ? `${CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE}_${acc}`
586
- : CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE;
587
- }
588
-
589
- /**
590
- * 获取缓存键(带前缀)
591
- * @param {string} key - 缓存键
592
- * @returns {string}
593
- */
594
- export function getCacheKey(key) {
595
- return `${CACHE.PREFIX}${key}`;
596
- }
597
-
598
- /**
599
- * 检查功能是否启用
600
- * @param {string} feature - 功能名
601
- * @returns {boolean}
602
- */
603
- export function isFeatureEnabled(feature) {
604
- return FEATURES[feature] === true;
605
- }
606
-
607
- /**
608
- * 获取打赏等级配置
609
- * @returns {Object}
610
- */
611
- export function getTipLevelConfig() {
612
- return { ...TIP_LEVELS };
613
- }
614
-
615
-
616
- // ==========================================
617
- // 🚀 SWR 头像缓存工具(跨组件共享)
618
- // ==========================================
619
- // 实现原理:
620
- // 1. 从 localStorage 读取缓存,0延迟渲染
621
- // 2. 后台静默校对云端数据
622
- // 3. 有更新时自动刷新 DOM
623
- // 缓存键:ComfyCommunity_ProfileCache_{account}
624
-
625
- const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
626
- const _pendingProfileRequests = new Map(); // 每账号共享请求与全部订阅者
627
- const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
628
- let nextAvatarRenderId = 0;
629
-
630
- // ==========================================
631
- // 🔒 HTML 转义(底层公共工具)
632
- // ==========================================
633
- // 放在本文件(零依赖的底层模块)而非 components/互动工具函数.js,是因为
634
- // UI交互提示组件.js 与 打赏等级工具.js 都需要它,而互动工具函数.js 反向依赖这两者
635
- // (导入 showToast、re-export renderTipBoardHTML),从那里导入会形成循环依赖。
636
- // 互动工具函数.js 仍以 re-export 方式对外暴露同名函数,既有调用方无需改动。
637
-
638
- /**
639
- * HTML转义(统一版:各组件局部副本已归一到此)
640
- * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
641
- * @returns {string} 转义后的字符串
642
- */
643
- export function escapeHtml(str) {
644
- if (str === null || str === undefined || str === "") return "";
645
- return String(str).replace(/&/g, "&amp;")
646
- .replace(/</g, "&lt;")
647
- .replace(/>/g, "&gt;")
648
- .replace(/"/g, "&quot;")
649
- .replace(/'/g, "&#039;");
650
- }
651
-
652
- /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
653
- export function normalizeProfileImageUrl(value, unwrapProxy = false, serviceBaseUrl = API.BASE_URL) {
654
- if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
655
- let source = value;
656
- let layers = 0;
657
- while (source.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX)) {
658
- if (++layers > 10) return '';
659
- let encoded = source.slice(API.LOCAL_IMAGE_PROXY_PREFIX.length);
660
- if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
661
- try { source = decodeURIComponent(encoded); } catch (_) { return ''; }
662
- if (!source || source.trim() !== source || /[\u0000-\u001f\u007f]/.test(source)) return '';
663
- }
664
- if (!layers && /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,.+$/i.test(source)) return value;
665
- if (!layers && /^blob:https?:\/\//i.test(source)) {
666
- try {
667
- const blob = new URL(source);
668
- if (typeof location !== 'undefined' && location.origin && blob.origin !== location.origin) return '';
669
- return blob.origin === 'null' ? '' : value;
670
- } catch (_) { return ''; }
671
- }
672
- if (!/^https?:\/\//i.test(source) || source.includes('\\')) return '';
673
- try {
674
- if (value.length > API.PROFILE_MEDIA_URL_MAX_BYTES
675
- || encodeURIComponent(value).replace(/%[0-9a-f]{2}/gi, 'x').length > API.PROFILE_MEDIA_URL_MAX_BYTES) return '';
676
- const url = new URL(source);
677
- const authority = source.match(/^https?:\/\/([^/?#]*)/i)?.[1];
678
- if (!authority || authority.includes('@') || authority.includes('%') || authority.endsWith(':') || url.port === '0') return '';
679
- if (url.username || url.password) return '';
680
- const host = url.hostname.toLowerCase().replace(/\.$/, '');
681
- if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
682
- && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
683
- const base = new URL(serviceBaseUrl);
684
- const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
685
- if (host === baseHost) {
686
- const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
687
- const sourcePath = source.slice(source.indexOf('://') + 3 + authority.length).split(/[?#]/, 1)[0] || '/';
688
- if (url.protocol !== base.protocol || url.port !== base.port || sourcePath !== expectedPath) return '';
689
- return unwrapProxy ? source : value;
690
- }
691
- if (!host.startsWith('[') && !/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
692
- const labels = host.split('.');
693
- if (host.length > 253 || labels.length < 2 || !labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
694
- || /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(labels.at(-1))) return '';
695
- }
696
- if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return '';
697
- if (host.startsWith('[')) {
698
- if (!/^\[[23][0-9a-f]{0,3}:/i.test(host)) return '';
699
- const pieces = host.slice(1, -1).split('::');
700
- const left = pieces[0] ? pieces[0].split(':').map(part => parseInt(part, 16)) : [];
701
- const right = pieces[1] ? pieces[1].split(':').map(part => parseInt(part, 16)) : [];
702
- const groups = pieces.length === 1 ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right];
703
- const [first, second, third] = groups;
704
- if (first === 0x2002 || (first === 0x3fff && second < 0x1000) || (first === 0x2001 && second === 0xdb8)) return '';
705
- if (first === 0x2001 && second < 0x200) {
706
- const exception = (second === 1 && groups.slice(2, 7).every(part => part === 0) && [1, 2].includes(groups[7]))
707
- || second === 3 || (second === 4 && third === 0x112) || (second >= 0x20 && second <= 0x3f);
708
- if (!exception) return '';
709
- }
710
- } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
711
- const rawHost = authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase();
712
- if (rawHost !== host) return '';
713
- const [a,b,c,last] = host.split('.').map(Number);
714
- if (a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127)
715
- || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
716
- || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19))
717
- || (a === 192 && b === 0 && ((c === 0 && ![9, 10].includes(last)) || c === 2))
718
- || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)) return '';
719
- }
720
- return unwrapProxy ? source : value;
721
- } catch (_) { return ''; }
722
- }
723
-
724
- /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
725
- function _mapAvatarField(profile) {
726
- if (profile && profile.avatarDataUrl && !profile.avatar) {
727
- profile.avatar = profile.avatarDataUrl;
728
- }
729
- }
730
-
731
- const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "intro", "gender", "age",
732
- "country", "region", "created_at", "followers", "following", "privacy", "is_admin", "bannerUrl",
733
- "receivedLikes", "receivedFavorites", "receivedUses"];
734
- function _publicProfile(profile) {
735
- return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
736
- .map(key => [key, ['avatar', 'avatarDataUrl', 'bannerUrl'].includes(key)
737
- ? normalizeProfileImageUrl(profile[key]) : profile[key]]));
738
- }
739
-
740
- function _validProfile(profile) {
741
- return !!profile && typeof profile === 'object' && !Array.isArray(profile)
742
- && ['name', 'avatar', 'avatarDataUrl'].every(field => profile[field] == null || typeof profile[field] === 'string');
743
- }
744
-
745
- /**
746
- * 🚀 获取缓存的用户资料(同步)
747
- * @param {string} account - 用户账号
748
- * @returns {Object|null} 缓存的用户资料
749
- */
750
- export function getCachedProfile(account) {
751
- if (!account) return null;
752
- try {
753
- const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
754
- if (!cached) return null;
755
- const profile = JSON.parse(cached);
756
- if (!_validProfile(profile)) return null;
757
- const publicProfile = _publicProfile(profile);
758
- _mapAvatarField(publicProfile);
759
- // 旧资料缓存可能含联系方式;读取返回过滤副本并尝试迁移,不因迁移失败隐藏公开内容。
760
- if (JSON.stringify(publicProfile) !== cached) {
761
- try { localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(publicProfile)); } catch (_) {}
762
- }
763
- return publicProfile;
764
- } catch {
765
- return null;
766
- }
767
- }
768
-
769
- /**
770
- * 🚀 保存用户资料到缓存
771
- * @param {string} account - 用户账号
772
- * @param {Object} profile - 用户资料
773
- */
774
- export function setCachedProfile(account, profile) {
775
- if (!account || !_validProfile(profile)) return;
776
- try {
777
- localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(_publicProfile(profile)));
778
- } catch {}
779
- }
780
-
781
- /**
782
- * 🚀 SWR 模式获取用户资料(先返回缓存,后台校对)
783
- * @param {string} account - 用户账号
784
- * @param {Function} apiFn - API 调用函数 (account) => Promise<{data: profile}>
785
- * @param {Function} onUpdate - 数据更新时的回调 (profile) => void
786
- * @returns {Object|null} 初始缓存数据
787
- */
788
- export function getProfileWithSWR(account, apiFn, onUpdate) {
789
- if (!account) return null;
790
-
791
- // 第一轨:同步返回缓存
792
- const cached = getCachedProfile(account);
793
-
794
- // 第二轨:同账号只取一次,但每个视图都订阅结果。
795
- let entry = _pendingProfileRequests.get(account);
796
- if (!entry && typeof apiFn === 'function') {
797
- entry = { listeners: new Set(), timer: null };
798
- _pendingProfileRequests.set(account, entry);
799
- const release = () => {
800
- if (_pendingProfileRequests.get(account) === entry) _pendingProfileRequests.delete(account);
801
- clearTimeout(entry.timer);
802
- entry.listeners.clear();
803
- };
804
- entry.timer = setTimeout(release, PENDING_TIMEOUT);
805
- Promise.resolve().then(() => apiFn(account)).then(res => {
806
- // 超时后的旧结果不能覆盖新请求,也不能重新写入旧资料。
807
- if (_pendingProfileRequests.get(account) !== entry) return;
808
- if (res?.status && res.status !== 'success') return;
809
- const data = res && Object.hasOwn(res, 'data') ? res.data : res;
810
- if (!_validProfile(data)) return;
811
- const profile = _publicProfile(data);
812
- _mapAvatarField(profile);
813
- setCachedProfile(account, profile);
814
- for (const listener of Array.from(entry.listeners)) {
815
- try { listener(profile); } catch { /* 单个视图异常不阻断其它订阅者 */ }
816
- }
817
- }).catch(() => {}).finally(release);
818
- }
819
- if (entry && typeof onUpdate === 'function') entry.listeners.add(onUpdate);
820
-
821
- return cached;
822
- }
823
-
824
- /**
825
- * 🚀 渲染带 SWR 缓存的头像+名称 HTML
826
- * @param {Object} options
827
- * @param {string} options.account - 用户账号
828
- * @param {string} options.fallbackAvatar - 备用头像 URL(从原始数据中获取)
829
- * @param {string} options.fallbackName - 备用名称
830
- * @param {number} options.avatarSize - 头像尺寸 (px)
831
- * @param {Function} options.apiFn - API 调用函数
832
- * @param {string} options.containerId - 唯一容器 ID(用于 DOM 更新)
833
- * @returns {string} HTML 字符串
834
- */
835
- export function renderAvatarWithSWR(options) {
836
- const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
837
- const renderId = String(++nextAvatarRenderId);
838
- const size = typeof avatarSize === 'number' || typeof avatarSize === 'string' ? Number(avatarSize) : NaN;
839
- const safeSize = Number.isFinite(size) && size > 0 ? size : 24;
840
-
841
- // 从缓存获取
842
- const cached = getCachedProfile(account);
843
- const avatar = cached?.avatar || cached?.avatarDataUrl || normalizeProfileImageUrl(fallbackAvatar);
844
- const name = cached?.name || fallbackName || account || '';
845
-
846
- // 后台静默校对并更新 DOM
847
- if (apiFn && containerId) {
848
- setTimeout(() => {
849
- const container = document.getElementById(containerId);
850
- if (!container || container.dataset.profileRender !== renderId) return;
851
- getProfileWithSWR(account, apiFn, (profile) => {
852
- if (!container.isConnected || document.getElementById(containerId) !== container
853
- || container.dataset.profileRender !== renderId) return;
854
-
855
- const avatarImg = container.querySelector('.swr-avatar');
856
- const nameSpan = container.querySelector('.swr-name');
857
-
858
- if (avatarImg && profile.avatar) {
859
- if (avatarImg.tagName === 'IMG') avatarImg.src = profile.avatar;
860
- else {
861
- const image = document.createElement('img');
862
- image.className = 'swr-avatar';
863
- image.alt = '';
864
- image.style.cssText = avatarImg.style.cssText;
865
- image.style.objectFit = 'cover';
866
- image.src = profile.avatar;
867
- avatarImg.replaceWith(image);
868
- }
869
- } else if (avatarImg && avatarImg.tagName !== 'IMG' && profile.name) {
870
- avatarImg.textContent = Array.from(profile.name)[0]?.toUpperCase() || 'U';
871
- }
872
- if (nameSpan && profile.name) {
873
- nameSpan.textContent = profile.name;
874
- }
875
- });
876
- }, 0);
877
- }
878
-
879
- // 生成初始 HTML(无头像时显示首字母背景)
880
- // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
881
- // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
882
- const initial = Array.from(String(name || account || 'U'))[0].toUpperCase();
883
- const avatarHtml = avatar
884
- ? `<img class="swr-avatar" alt="" src="${escapeHtml(avatar)}" style="width: ${safeSize}px; height: ${safeSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
885
- : `<div class="swr-avatar" aria-hidden="true" style="width: ${safeSize}px; height: ${safeSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(safeSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${escapeHtml(initial)}</div>`;
886
-
887
- return `<span id="${escapeHtml(containerId)}" data-profile-render="${renderId}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
888
- }
889
-
890
-
891
- // ==========================================
892
- // 📦 导出默认配置对象
893
- // ==========================================
894
- export default {
895
- IMAGE_PROCESS,
896
- API,
897
- CACHE,
898
- PAGINATION,
899
- TIP_LEVELS,
900
- UI,
901
- VALIDATION,
902
- FEATURES,
903
- IS_WEB_MODE,
904
- ANALYTICS,
905
- LINKS,
906
- getApiUrl,
907
- getCacheKey,
908
- isFeatureEnabled,
909
- getCurrentAccount,
910
- getBackgroundKey,
911
- getBannerCacheKey,
912
- getCachedProfile,
913
- setCachedProfile,
914
- getProfileWithSWR,
915
- renderAvatarWithSWR
916
- };
917
-
918
-
919
- // 提示词定价输入边界,与云端 PromptCreate/PromptUpdate 对应。
920
- export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
921
-
922
- // 任务定价边界,与云端 task_publication/task_terms 对应。
923
- export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]), TIP_AMOUNTS: Object.freeze([10, 50, 100, 500]) });
924
-
925
-
926
- export const TYPE_RULES = {
927
- tool: {
928
- recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
929
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
930
- },
931
- skill: {
932
- recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
933
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
934
- },
935
- app: {
936
- recommendForm: false, resType: { fixed: 'json' },
937
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
938
- },
939
- recommend: {
940
- recommendForm: true, resType: { lockedByForm: true },
941
- privateRepoWhenLink: false, price: false, refund: false, originalRequired: false,
942
- formToResType: { recommend_tool: 'link', recommend_skill: 'link', recommend_app: 'json', recommend_link: 'netdisk' }
943
- }
944
- };
945
-
946
- // 🔧 设置项 localStorage Key
947
- const SETTINGS_KEY = "ComfyCommunity_Settings";
948
-
949
- // 🔧 默认设置值
950
- const DEFAULT_SETTINGS = {
951
- enableKeyboardShortcuts: false,
952
- enableLocalPerformanceStats: false,
953
- showCreatorBanner: true, // 创作者卡片显示背景图
954
- enableAnimations: true, // ✨ 榜单切换动画
955
- enableSoundEffects: true, // 🔊 动画音效
956
- language: 'zh-CN', // 🌐 界面语言
957
- cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时
958
- // 🚀 后续可扩展更多设置项
959
- // compactMode: false, // 紧凑模式
960
- // darkMode: true, // 深色模式
961
- };
962
-
963
- /**
964
- * 📦 获取当前设置
965
- */
966
- export function getSettings(options = {}) {
967
- try {
968
- const saved = localStorage.getItem(SETTINGS_KEY);
969
- if (saved) {
970
- const value = JSON.parse(saved);
971
- if (options.strict && (!value || typeof value !== 'object' || Array.isArray(value))) throw new Error('Invalid settings');
972
- return { ...DEFAULT_SETTINGS, ...value };
973
- }
974
- } catch (e) { if (options.strict) throw e; }
975
- return { ...DEFAULT_SETTINGS };
976
- }
977
-
978
- /**
979
- * 💾 保存设置
980
- */
981
- export function areKeyboardShortcutsEnabled() {
982
- return getSettings().enableKeyboardShortcuts === true;
983
- }
984
-
985
- export function saveSettings(settings) {
986
- try {
987
- localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
988
- // 🔔 广播设置变更事件,让其他组件响应
989
- window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
990
- return true;
991
- } catch (e) { return false; }
992
- }
 
 
 
 
 
 
1
+ // 前端页面/core/全局配置.js
2
+ // ==========================================
3
+ // ⚙️ 全局配置中心
4
+ // ==========================================
5
+ // 作用:集中管理所有配置常量,便于维护和修改
6
+ // 关联文件:
7
+ // - 网络请求API.js (API基础地址)
8
+ // - 性能优化工具.js (缓存配置)
9
+ // - 侧边栏数据引擎.js (分页配置)
10
+ // - 所有业务组件
11
+ // ==========================================
12
+ // 🏗️ P2架构优化:配置集中化
13
+ // 🏗️ P2质量优化:JSDoc 类型注释
14
+ // ==========================================
15
+
16
+
17
+ // ==========================================
18
+ // 🌐 API 配置
19
+ // ==========================================
20
+
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, SSE_MAX_RESPONSE_BYTES: number, SSE_MAX_LINE_BYTES: number, SSE_MAX_LINES: number, SSE_YIELD_LINES: number, SSE_YIELD_BYTES: number, SSE_YIELD_CHUNKS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BANNER_IMAGE_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, PROFILE_MEDIA_URL_MAX_BYTES: number, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp, REQUEST_ID_HEADER: string, REQUEST_ID_PATTERN: RegExp, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string, REQUEST_BODY_ERRORS: Record<string, { status: number, msgKey: string }> }} */
22
+ export const API = {
23
+ REQUEST_ID_HEADER: "X-Ranking-Request-Id",
24
+ REQUEST_ID_PATTERN: /^(?:[a-f0-9]{32}|ERR_[0-9]{10,13}_[a-f0-9]{1,8})$/,
25
+ ENDPOINT_ERROR_SCOPE_HEADER: "X-Ranking-Error-Scope",
26
+ ENDPOINT_ERROR_SCOPE_VALUE: "endpoint",
27
+ REQUEST_BODY_ERRORS: {
28
+ request_body_invalid: { status: 400, msgKey: 'feedback.invalid_request_data' },
29
+ request_body_disconnected: { status: 400, msgKey: 'feedback.network_unavailable' },
30
+ request_body_too_large: { status: 413, msgKey: 'feedback.request_too_large' },
31
+ request_body_timeout: { status: 408, msgKey: 'feedback.request_receive_timeout' },
32
+ request_body_busy: { status: 429, msgKey: 'feedback.request_busy' },
33
+ request_body_encoding: { status: 415, msgKey: 'feedback.request_encoding_unsupported' },
34
+ },
35
+ // 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
36
+ PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
37
+ CONDITIONAL_ETAG_PATTERN: /^W\/"rk-[a-f0-9]{64}"$/,
38
+
39
+ // 云端 API 基础地址
40
+ BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
41
+
42
+ // 请求超时时间(��秒)
43
+ TIMEOUT: 30000,
44
+ VIDEO_UPLOAD_TIMEOUT: 300000,
45
+ VIDEO_UPLOAD_RESPONSE_MAX_CHARS: 65536,
46
+ LOCAL_CACHE_STATS_TIMEOUT: 10000,
47
+ LOCAL_CACHE_CLEAR_TIMEOUT: 60000,
48
+ LOCAL_CACHE_RESPONSE_MAX_BYTES: 8192,
49
+ // 工作流终态编码上限为512 MiB,另保留64 MiB进度数据;行预算包含data前缀。
50
+ SSE_MAX_RESPONSE_BYTES: 576 * 1024 * 1024,
51
+ SSE_MAX_LINE_BYTES: 512 * 1024 * 1024,
52
+ SSE_MAX_LINES: 262144,
53
+ SSE_YIELD_LINES: 256,
54
+ SSE_YIELD_BYTES: 1024 * 1024,
55
+ SSE_YIELD_CHUNKS: 256,
56
+
57
+ // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
58
+ // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
59
+ // 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
60
+ MAX_RETRIES: 2,
61
+
62
+ // 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
63
+ RETRY_DELAY: 1000,
64
+
65
+ // 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
66
+ // 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
67
+ // 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
68
+ // 可能撞上 HF Space 冷启动(本项目无预热机制),故只砍掉一次冗余重试、保留 30s 单次超时,
69
+ // 93s → 61s,冷启动仍有机会在前两次尝试内完成
70
+ LIST_RETRIES: 1,
71
+ // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
72
+ SEARCH_TIMEOUT: 10000,
73
+ SEARCH_RETRIES: 1,
74
+ PROFILE_RESOURCE_TIMEOUT: 5000,
75
+ CREATOR_CHART_SCRIPT_URL: "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js",
76
+
77
+ // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
78
+ // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
79
+ // 这次请求纯属后台行为;它每多挂 1 秒就多占 1 个并发额度(全局仅 6 个),
80
+ // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
81
+ // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
82
+ BACKGROUND_TIMEOUT: 10000,
83
+ BANNER_IMAGE_TIMEOUT: 75000,
84
+ WORKFLOW_DOWNLOAD_TIMEOUT: 120000, // 工作流流式下载预算(2 分钟)
85
+ BACKGROUND_RETRIES: 0,
86
+
87
+ // ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
88
+ // navigator.onLine 只反映本机网卡,云端单独不可达(HF Space 宕机/冷启动、DNS 污染、被墙)
89
+ // 时它仍为 true,于是每个界面、每次轮询都要从头烧一遍完整重试预算,且互不共享失败结论。
90
+ // 首次确证云端不可达后进入冷却:冷却期内所有 GET 一律不联网(有缓存直接返回、无缓存立即失败),
91
+ // 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
92
+ // 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
93
+ CLOUD_DOWN_COOLDOWN: 60 * 1000,
94
+
95
+ // 🔍 创作者搜索请求的取消分组 ID
96
+ // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
97
+ // 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
98
+ // 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
99
+ SEARCH_COMPONENT_ID: "sidebar-search",
100
+ // 上传相对路径的云端图片读取入口。
101
+ IMAGE_PROXY_PATH: "/api/image_proxy",
102
+ PROFILE_MEDIA_URL_MAX_BYTES: 16384,
103
+ // 本地广告预载要求真实图片,熔断占位图不能作为下载成功。
104
+ LOCAL_IMAGE_PROXY_PREFIX: "/community_hub/image?url=",
105
+ COMPLETE_IMAGE_QUERY_SUFFIX: "&require_complete=1",
106
+ // 身份相关或私有 GET 禁止通用响应缓存;公开列表仍使用原缓存策略。
107
+ PRIVATE_GET_PATTERNS: [
108
+ /^\/api\/(wallet|messages|chats|admin|disputes|users)(?:\/|$)/,
109
+ /^\/api\/my-(posts|prompts|tasks)(?:\/|$)/,
110
+ /^\/api\/(tasks|prompts)\/[^/]+$/
111
+ ]
112
+ };
113
+
114
+
115
+ export const LOCAL_PERFORMANCE = Object.freeze({
116
+ MAX_ENDPOINT_CHARS: 4096,
117
+ MAX_COUNT: 1000000,
118
+ MAX_DURATION_MS: 600000,
119
+ ITEM_CATEGORIES: Object.freeze({ tool: 'tools', skill: 'skills', app: 'apps', recommend: 'recommends' }),
120
+ LIST_CATEGORIES: Object.freeze({ '/api/creators': 'creators', '/api/creators/search': 'creators', '/api/tasks': 'tasks', '/api/posts': 'posts', '/api/prompts': 'prompts' }),
121
+ LABELS: Object.freeze({ tools: 'nav.tools', skills: 'nav.skills', apps: 'nav.apps', recommends: 'nav.recommends', creators: 'nav.creators', tasks: 'nav.tasks', posts: 'nav.posts', prompts: 'nav.prompts' })
122
+ });
123
+
124
+ export const LOCAL_INSTALL_DIRECTORY = Object.freeze({
125
+ PATH: '/community_hub/open_install_directory',
126
+ TIMEOUT: 25000,
127
+ RESPONSE_MAX_BYTES: 8192
128
+ });
129
+
130
+ export const SKILL_INSTALL = Object.freeze({
131
+ PATH: '/community_hub/skill_install',
132
+ PREFLIGHT_TIMEOUT: 30000,
133
+ CONTROL_TIMEOUT: 45000,
134
+ RESULT_TIMEOUT: 5000,
135
+ STREAM_TIMEOUT: 1200000,
136
+ RESPONSE_MAX_BYTES: 1024 * 1024,
137
+ PATH_MAX_BYTES: 32768,
138
+ MAX_DIRECTORIES: 128,
139
+ MAX_ITEMS: 1,
140
+ MODAL_WIDTH: '640px'
141
+ });
142
+
143
+ export const CHART_LIFECYCLE = Object.freeze({
144
+ WIDTH_WAIT_TIMEOUT: 5000,
145
+ RESIZE_DELAY: 300
146
+ });
147
+
148
+ // 发布视频的本地读取预算,不改变云端上传额度。
149
+ export const LAZY_IMAGES = Object.freeze({
150
+ ROOT_MARGIN: "200px 0px",
151
+ THRESHOLD: 0.01,
152
+ MOUNT_TIMEOUT: 30000,
153
+ READ_TIMEOUT: 30000
154
+ });
155
+
156
+ export const PUBLISH_MEDIA_LIMITS = Object.freeze({
157
+ MAX_IMAGES: 9,
158
+ IMAGE_MAX_EDGE: 1920,
159
+ IMAGE_QUALITY: 0.85,
160
+ VIDEO_MAX_BYTES: 100 * 1024 * 1024,
161
+ VIDEO_MAX_SECONDS: 180,
162
+ VIDEO_METADATA_TIMEOUT: 15000
163
+ });
164
+
165
+ // 上传前处理预算;浏览器解码本身仍由宿主管理。
166
+ export const IMAGE_PROCESS = {
167
+ HEADER_BYTES: 256 * 1024,
168
+ MAX_INPUT_BYTES: 50 * 1024 * 1024,
169
+ MAX_PIXELS: 40 * 1024 * 1024,
170
+ MAX_SOURCE_EDGE: 16384,
171
+ MAX_OUTPUT_EDGE: 4096,
172
+ MAX_OUTPUT_BYTES: 5 * 1024 * 1024,
173
+ TIMEOUT: 15000,
174
+ MIN_QUALITY: 0.3,
175
+ QUALITY_STEP: 0.1,
176
+ AVATAR_MAX_BYTES: 2 * 1024 * 1024,
177
+ AVATAR_SIZE: 512
178
+ };
179
+
180
+ // ==========================================
181
+ // 🔐 管理员配置 (P0安全修复:管理员账号配置化)
182
+ // ==========================================
183
+ // 注意:前端仅用于UI显示控制,后端从环境变量 ADMIN_ACCOUNTS 读取
184
+ // 修改此处需同步修改云端环境变量
185
+ export const ADMIN_ACCOUNTS = new Set(["123456", "888888"]);
186
+
187
+ /**
188
+ * 检查账号是否为管理员
189
+ * @param {string} account - 用户账号
190
+ * @returns {boolean}
191
+ */
192
+ export function isAdmin(account) {
193
+ return ADMIN_ACCOUNTS.has(account);
194
+ }
195
+
196
+
197
+ // ==========================================
198
+ // 💾 缓存配置
199
+ // ==========================================
200
+ export function getSidebarListCacheKey(tab, sort) {
201
+ const list = tab === 'creators' ? 'creators_full' : tab;
202
+ return `ListCache_${list}_${sort}`;
203
+ }
204
+
205
+ const RESOURCE_LIST_PREFIXES = [
206
+ "ListCache_tools_", "ListCache_skills_", "ListCache_apps_", "ListCache_recommends_"
207
+ ];
208
+ export const CACHE = {
209
+ // 缓存键前缀
210
+ PREFIX: "ComfyRanking_",
211
+
212
+ // 默认缓存时间(毫秒)
213
+ DEFAULT_TTL: 2 * 60 * 60 * 1000, // 2小时
214
+
215
+ // 各类数据缓存时间
216
+ TTL: {
217
+ LIST_DATA: 5 * 60 * 1000, // 列表数据:5分钟
218
+ DETAIL_DATA: 3 * 60 * 1000, // 详情数据:3分钟
219
+ USER_PROFILE: 2 * 60 * 1000, // 用户资料:2分钟
220
+ WALLET: 30 * 1000, // 钱包数据:30秒
221
+ CREATORS: 5 * 60 * 1000, // 创作者列表:5分钟
222
+ CHAT_HISTORY: 60 * 1000 // 聊天记录:1分钟
223
+ },
224
+
225
+ // 内存缓存最大条目数
226
+ MAX_MEMORY_ITEMS: 100,
227
+
228
+ // 本插件 localStorage 持久缓存的估算容量上限(不影响用户/会话等其它键)
229
+ MAX_STORAGE_BYTES: 2 * 1024 * 1024,
230
+
231
+ // localStorage 键名(旧版兼容)
232
+ SESSION_REMEMBER_TTL: 30 * 24 * 60 * 60 * 1000,
233
+ INSTALLED_VERSION_UNCONFIRMED: "__installed_unconfirmed__", // 安装成功但没有实际版本证明
234
+ SESSION_TEMP_TTL: 24 * 60 * 60 * 1000,
235
+ LEGACY_KEYS: {
236
+ USER: "ComfyCommunity_User",
237
+ TOKEN: "ComfyCommunity_Token",
238
+ LIST_CACHE: "ComfyCommunity_ListCache",
239
+ PROFILE_CACHE: "ComfyCommunity_ProfileCache",
240
+ CHAT_HISTORY: "ComfyCommunity_ChatHistory"
241
+ },
242
+
243
+ // 本地背景存储键(不上传云端)
244
+ LOCAL_KEYS: {
245
+ BANNER_CONFIG: "ComfyRanking_BannerConfig", // 广告配置持久缓存
246
+ CREATORS_SESSION: "ComfyRanking_CreatorsCache", // 沿用创作者临时缓存键
247
+ SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
248
+ PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
249
+ ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
250
+ PROFILE_LIST_PREFIX: "ProfileList_", // 兼容旧个人列表业务键
251
+ INSTALLED_VERSION_PREFIX: "ComfyCommunity_LocalVer_", // 沿用既有安装版本键
252
+ ITEM_OWNERSHIP_PREFIX: "ItemOwnership_", // 缓存管理器业务键前缀
253
+ // 🚀 新增:私信和通知本地缓存
254
+ NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
255
+ CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
256
+ CHAT_HISTORY_PREFIX: "ComfyRanking_ChatHistory_", // 聊天记录前缀
257
+ NOTIF_CLEAR_TIME: "ComfyRanking_NotifClearTime", // 通知清空时间戳
258
+ CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
259
+ },
260
+
261
+ REFUND_CACHE_PREFIXES: [
262
+ "api_/api/items", "api_/api/creators", "api_/api/wallet",
263
+ ...RESOURCE_LIST_PREFIXES
264
+ ],
265
+ RESOURCE_LIST_PREFIXES,
266
+ PUBLIC_LISTS: {
267
+ tasks: { prefix: "TasksCache_", endpoint: "/api/tasks" },
268
+ posts: { prefix: "PostsCache_", endpoint: "/api/posts" },
269
+ prompts: { prefix: "PromptsCache_", endpoint: "/api/prompts" }
270
+ },
271
+
272
+ // 🚀 新增:消息轮询配置
273
+ MESSAGE_POLL: {
274
+ INTERVAL: 30 * 1000, // 轮询间隔:30秒
275
+ ENABLED: true // 是否启用定时检查
276
+ }
277
+ };
278
+
279
+
280
+ // ==========================================
281
+ // 📜 分页配置
282
+ // ==========================================
283
+ export const PAGINATION = {
284
+ // 工具/应用每页数量
285
+ ITEMS_PAGE_SIZE: 20,
286
+
287
+ // 创作者每页数量
288
+ CREATORS_PAGE_SIZE: 12,
289
+ SIDEBAR_ITEM_FETCH_LIMIT: 200,
290
+ SIDEBAR_CREATOR_FETCH_LIMIT: 500,
291
+ SIDEBAR_CREATOR_SEARCH_LIMIT: 50,
292
+ PROFILE_USERS_PAGE_SIZE: 20,
293
+ PROFILE_RESOURCE_FETCH_LIMIT: 200,
294
+
295
+ // 评论每页数量
296
+ COMMENTS_PAGE_SIZE: 10,
297
+
298
+ // 消息每页数量
299
+ MESSAGES_PAGE_SIZE: 20,
300
+ CHAT_MESSAGES_PAGE_SIZE: 50,
301
+
302
+ // 滚动加载触发阈值(距底部像素)
303
+ SCROLL_THRESHOLD: 300,
304
+
305
+ // 防抖延迟(毫秒)
306
+ DEBOUNCE_DELAY: 100
307
+ };
308
+
309
+
310
+ // ==========================================
311
+ // 💰 打赏等级配置
312
+ // ==========================================
313
+ export const TIP_LEVELS = {
314
+ // 每颗星星需要的积分
315
+ POINTS_PER_STAR: 100,
316
+
317
+ // 每个月亮需要的星星数
318
+ STARS_PER_MOON: 5,
319
+
320
+ // 每个太阳需要的月亮数
321
+ MOONS_PER_SUN: 5,
322
+
323
+ // 最高等级太阳数
324
+ MAX_SUNS: 9,
325
+
326
+ // 最高积分(9太阳 = 22500积分)
327
+ MAX_POINTS: 22500,
328
+
329
+ // 等级图标
330
+ ICONS: {
331
+ STAR: "⭐",
332
+ MOON: "🌙",
333
+ SUN: "☀️",
334
+ CROWN: "👑"
335
+ },
336
+
337
+ // 等级称号
338
+ TITLES: {
339
+ MAX_LEVEL: "👑 至尊赞助者",
340
+ HIGH_SUN: "超级赞助者",
341
+ LOW_SUN: "黄金赞助者",
342
+ HIGH_MOON: "白银赞助者",
343
+ LOW_MOON: "青铜赞助者",
344
+ HIGH_STAR: "热心粉丝",
345
+ DEFAULT: "支持者"
346
+ }
347
+ };
348
+
349
+
350
+ // ==========================================
351
+ // 🎨 UI 配置
352
+ // ==========================================
353
+ export const UI = {
354
+ INSTALL_SOURCE: Object.freeze({
355
+ MAX_URL_BYTES: 4096,
356
+ CASE_INSENSITIVE_HOSTS: Object.freeze(['github.com'])
357
+ }),
358
+ INSTALL_TRANSFER: Object.freeze({
359
+ MAX_BYTES: 2 * 1024 * 1024 * 1024,
360
+ MAX_RATE: Number.MAX_SAFE_INTEGER,
361
+ BASE: 1024,
362
+ STAGES: Object.freeze(['downloading', 'download_done']),
363
+ UNIT_KEYS: Object.freeze(['install.transfer_b', 'install.transfer_kib', 'install.transfer_mib', 'install.transfer_gib'])
364
+ }),
365
+
366
+ CHAT_MAX_MSG_LENGTH: 1000,
367
+ CHAT_TIME_GAP_SECONDS: 5 * 60,
368
+ MAX_DISPUTE_EVIDENCE: 6,
369
+ RECHARGE_POLL_INTERVAL: 3000,
370
+ RECHARGE_POLL_LIMIT: 100,
371
+ RECHARGE_QR_MAX_BYTES: 2048,
372
+ PROFILE_BANNER_CACHE_BYTES: 512 * 1024,
373
+ // 与云端公开版本接口的缺省值一致;用于网络不可用时的本地展示。
374
+ DEFAULT_PROJECT_VERSION: Object.freeze({ stage: "beta", major: 2, minor: 0, patch: 0 }),
375
+ ABOUT: {
376
+ AUTHOR_URL: "https://space.bilibili.com/2114638644",
377
+ AUTHOR_BADGE_URL: "https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white"
378
+ },
379
+ // Toast 消息配置
380
+ TOAST: {
381
+ OFFSET: 50,
382
+ GAP: 10,
383
+ DURATION: 3000, // 显示时长(毫秒)
384
+ MAX_VISIBLE: 3, // 最大同时显示数量
385
+ POSITION: "top-center" // 位置
386
+ },
387
+
388
+ // 动画配置
389
+ ANIMATION: {
390
+ DURATION_FAST: 150,
391
+ DURATION_NORMAL: 300,
392
+ DURATION_SLOW: 500,
393
+ EASING: "cubic-bezier(0.25, 0.8, 0.25, 1)"
394
+ },
395
+
396
+ // 颜色主题
397
+ COLORS: {
398
+ PRIMARY: "#2196F3",
399
+ SUCCESS: "#4CAF50",
400
+ WARNING: "#FF9800",
401
+ ERROR: "#F44336",
402
+ INFO: "#2196F3",
403
+ TIP: "#E91E63",
404
+
405
+ // 深色主题背景
406
+ BG_DARK: "var(--comfy-menu-bg)",
407
+ BG_CARD: "var(--comfy-input-bg)",
408
+ BG_INPUT: "var(--comfy-input-bg)",
409
+
410
+ // 边框
411
+ BORDER: "#444",
412
+ BORDER_LIGHT: "#555"
413
+ },
414
+
415
+ // 图片占位图(🚀 关键修复:全部改为内联 SVG,避免外部网络请求)
416
+ PLACEHOLDERS: {
417
+ // 头像占位图(👤 图标)
418
+ AVATAR: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Crect fill='%23333' width='150' height='150' rx='75'/%3E%3Ctext x='75' y='100' text-anchor='middle' fill='%23888' font-size='60'%3E👤%3C/text%3E%3C/svg%3E`,
419
+ // 封面占位图(🖼️ 图标)
420
+ COVER: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='260'%3E%3Crect fill='%23222' width='400' height='260'/%3E%3Ctext x='200' y='140' text-anchor='middle' fill='%23666' font-size='48'%3E🖼️%3C/text%3E%3C/svg%3E`,
421
+ // 小头像占位图(用于列表)
422
+ AVATAR_SMALL: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect fill='%23333' width='40' height='40' rx='20'/%3E%3Ctext x='20' y='26' text-anchor='middle' fill='%23888' font-size='16'%3E👤%3C/text%3E%3C/svg%3E`,
423
+ // 加载占位图
424
+ LOADING: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%231a1a1a' width='100' height='100'/%3E%3C/svg%3E"
425
+ }
426
+ };
427
+
428
+ // 🚀 导出 PLACEHOLDERS 便于直接使用
429
+ export const PLACEHOLDERS = UI.PLACEHOLDERS;
430
+
431
+
432
+ // ==========================================
433
+ // 📝 表单验证配置
434
+ // ==========================================
435
+ export const VALIDATION = {
436
+ // 账号规则
437
+ ACCOUNT: {
438
+ MIN_LENGTH: 6,
439
+ MAX_LENGTH: 20,
440
+ PATTERN: /^[a-zA-Z0-9_]{6,20}$/,
441
+ MESSAGE: "账号仅支持6-20位字母、数字、下划线"
442
+ },
443
+
444
+ // 密码规则
445
+ PASSWORD: {
446
+ MIN_LENGTH: 6,
447
+ PATTERN: /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{6,}$/,
448
+ MESSAGE: "密码至少6位,支持字母、数字和常见特殊字符"
449
+ },
450
+
451
+ // 昵称规则
452
+ NAME: {
453
+ MIN_LENGTH: 1,
454
+ MAX_LENGTH: 20,
455
+ MESSAGE: "昵称长度1-20个字符"
456
+ },
457
+
458
+ // 个人介绍规则
459
+ INTRO: {
460
+ MAX_LENGTH: 100,
461
+ MESSAGE: "个人介绍不能超过100字"
462
+ },
463
+
464
+ // 验证码规则
465
+ VERIFY_CODE: {
466
+ LENGTH: 6,
467
+ PATTERN: /^\d{6}$/,
468
+ EXPIRE_SECONDS: 600,
469
+ MESSAGE: "请输入6位数字验证码"
470
+ }
471
+ };
472
+
473
+
474
+ // ==========================================
475
+ // 🔧 功能开关
476
+ // ==========================================
477
+ export const FEATURES = {
478
+ // 是否启用调试模式
479
+ DEBUG: false,
480
+
481
+ // 是否启用性能监控
482
+ PERFORMANCE_MONITOR: false,
483
+
484
+ // 是否启用离线模式提示
485
+ OFFLINE_DETECTION: true,
486
+
487
+ // 是否启用图片懒加载
488
+ LAZY_LOAD_IMAGES: true,
489
+
490
+ // 是否启用分页加载
491
+ PAGINATION_ENABLED: true,
492
+
493
+ // 是否启用请求缓存
494
+ REQUEST_CACHE: true,
495
+
496
+ // 是否启用骨架屏
497
+ SKELETON_LOADING: true
498
+ };
499
+
500
+
501
+ // ==========================================
502
+ // 🌐 运行环境开关
503
+ // ==========================================
504
+ // 移动端网页版运行时开关:Web 引导页(云端Space代码/移动端网页/index.html)
505
+ // 在模块求值前置位 window.__RANKING_WEB_MODE__ = true;ComfyUI 环境不置位,恒为 false,
506
+ // 本地侧边栏行为零变化。所有「仅本地可用」功能的门控均以此为准
507
+ export const IS_WEB_MODE = typeof window !== "undefined" && window.__RANKING_WEB_MODE__ === true;
508
+
509
+
510
+ // ==========================================
511
+ // 📊 统计埋点配置(预留)
512
+ // ==========================================
513
+ export const ANALYTICS = {
514
+ // 是否启用统计
515
+ ENABLED: false,
516
+
517
+ // 统计服务地址
518
+ ENDPOINT: "",
519
+
520
+ // 采样率(0-1)
521
+ SAMPLE_RATE: 1.0
522
+ };
523
+
524
+
525
+ // ==========================================
526
+ // 🔗 外部链接
527
+ // ==========================================
528
+ export const LINKS = {
529
+ // GitHub 仓库
530
+ GITHUB_REPO: "https://github.com/a63976659/ComfyUI-Ranking",
531
+
532
+ // 文档地址
533
+ DOCS: "",
534
+
535
+ // 反馈地址
536
+ FEEDBACK: ""
537
+ };
538
+
539
+
540
+ // ==========================================
541
+ // 🛠️ 辅助函数
542
+ // ==========================================
543
+
544
+ /**
545
+ * 获取完整的 API 地址
546
+ * @param {string} endpoint - API 端点
547
+ * @returns {string}
548
+ */
549
+ export function getApiUrl(endpoint) {
550
+ return `${API.BASE_URL}${endpoint}`;
551
+ }
552
+
553
+ /**
554
+ * 获取当前登录用户账号
555
+ * @returns {string|null}
556
+ */
557
+ export function getCurrentAccount() {
558
+ try {
559
+ const userStr = localStorage.getItem(CACHE.LEGACY_KEYS.USER) || sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
560
+ if (userStr) {
561
+ const userData = JSON.parse(userStr);
562
+ return userData?.user?.account || null;
563
+ }
564
+ } catch (e) {
565
+ console.warn("获取当前账号失败。");
566
+ }
567
+ return null;
568
+ }
569
+
570
+ /**
571
+ * 获取账号区分的界面背景图存储键
572
+ * @param {string} [account] - 账号,不传则自动获取当前登录账号
573
+ * @returns {string}
574
+ */
575
+ export function getBackgroundKey(account) {
576
+ const acc = account || getCurrentAccount();
577
+ return acc
578
+ ? `${CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND}_${acc}`
579
+ : CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND;
580
+ }
581
+
582
+ /**
583
+ * 获取账号区分的个人资料卡背景缓存键
584
+ * @param {string} [account] - 账号,不传则自动获取当前登录账号
585
+ * @returns {string}
586
+ */
587
+ export function getBannerCacheKey(account) {
588
+ const acc = account || getCurrentAccount();
589
+ return acc
590
+ ? `${CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE}_${acc}`
591
+ : CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE;
592
+ }
593
+
594
+ /**
595
+ * 获取缓存键(带前缀)
596
+ * @param {string} key - 缓存键
597
+ * @returns {string}
598
+ */
599
+ export function getCacheKey(key) {
600
+ return `${CACHE.PREFIX}${key}`;
601
+ }
602
+
603
+ /**
604
+ * 检查功能是否启用
605
+ * @param {string} feature - 功能名
606
+ * @returns {boolean}
607
+ */
608
+ export function isFeatureEnabled(feature) {
609
+ return FEATURES[feature] === true;
610
+ }
611
+
612
+ /**
613
+ * 获取打赏等级配置
614
+ * @returns {Object}
615
+ */
616
+ export function getTipLevelConfig() {
617
+ return { ...TIP_LEVELS };
618
+ }
619
+
620
+
621
+ // ==========================================
622
+ // 🚀 SWR 头像缓存工具(跨组件共享)
623
+ // ==========================================
624
+ // 实现原理:
625
+ // 1. 从 localStorage 读取缓存,0延迟渲染
626
+ // 2. 后台静默校对云端数据
627
+ // 3. 有更新时自动刷新 DOM
628
+ // 缓存键:ComfyCommunity_ProfileCache_{account}
629
+
630
+ const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
631
+ const _pendingProfileRequests = new Map(); // 每账号共享请求与全部订阅者
632
+ const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
633
+ let nextAvatarRenderId = 0;
634
+
635
+ // ==========================================
636
+ // 🔒 HTML 转义(底层公共工具)
637
+ // ==========================================
638
+ // 放在本文件(零依赖的底层模块)而非 components/互动工具函数.js,是因为
639
+ // UI交互提示组件.js 与 打赏等级工具.js 都需要它,而互动工具函数.js 反向依赖这两者
640
+ // (导入 showToast、re-export renderTipBoardHTML),从那里导入会形成循环依赖。
641
+ // 互动工具函数.js 仍以 re-export 方式对外暴露同名函数,既有调用方无需改动。
642
+
643
+ /**
644
+ * HTML转义(统一版:各组件局部副本已归一到此)
645
+ * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
646
+ * @returns {string} 转义后的字符串
647
+ */
648
+ export function escapeHtml(str) {
649
+ if (str === null || str === undefined || str === "") return "";
650
+ return String(str).replace(/&/g, "&amp;")
651
+ .replace(/</g, "&lt;")
652
+ .replace(/>/g, "&gt;")
653
+ .replace(/"/g, "&quot;")
654
+ .replace(/'/g, "&#039;");
655
+ }
656
+
657
+ /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
658
+ export function normalizeProfileImageUrl(value, unwrapProxy = false, serviceBaseUrl = API.BASE_URL) {
659
+ if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
660
+ let source = value;
661
+ let layers = 0;
662
+ while (source.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX)) {
663
+ if (++layers > 10) return '';
664
+ let encoded = source.slice(API.LOCAL_IMAGE_PROXY_PREFIX.length);
665
+ if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
666
+ try { source = decodeURIComponent(encoded); } catch (_) { return ''; }
667
+ if (!source || source.trim() !== source || /[\u0000-\u001f\u007f]/.test(source)) return '';
668
+ }
669
+ if (!layers && /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,.+$/i.test(source)) return value;
670
+ if (!layers && /^blob:https?:\/\//i.test(source)) {
671
+ try {
672
+ const blob = new URL(source);
673
+ if (typeof location !== 'undefined' && location.origin && blob.origin !== location.origin) return '';
674
+ return blob.origin === 'null' ? '' : value;
675
+ } catch (_) { return ''; }
676
+ }
677
+ if (!/^https?:\/\//i.test(source) || source.includes('\\')) return '';
678
+ try {
679
+ if (value.length > API.PROFILE_MEDIA_URL_MAX_BYTES
680
+ || encodeURIComponent(value).replace(/%[0-9a-f]{2}/gi, 'x').length > API.PROFILE_MEDIA_URL_MAX_BYTES) return '';
681
+ const url = new URL(source);
682
+ const authority = source.match(/^https?:\/\/([^/?#]*)/i)?.[1];
683
+ if (!authority || authority.includes('@') || authority.includes('%') || authority.endsWith(':') || url.port === '0') return '';
684
+ if (url.username || url.password) return '';
685
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
686
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
687
+ && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
688
+ const base = new URL(serviceBaseUrl);
689
+ const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
690
+ if (host === baseHost) {
691
+ const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
692
+ const sourcePath = source.slice(source.indexOf('://') + 3 + authority.length).split(/[?#]/, 1)[0] || '/';
693
+ if (url.protocol !== base.protocol || url.port !== base.port || sourcePath !== expectedPath) return '';
694
+ return unwrapProxy ? source : value;
695
+ }
696
+ if (!host.startsWith('[') && !/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
697
+ const labels = host.split('.');
698
+ if (host.length > 253 || labels.length < 2 || !labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
699
+ || /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(labels.at(-1))) return '';
700
+ }
701
+ if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return '';
702
+ if (host.startsWith('[')) {
703
+ if (!/^\[[23][0-9a-f]{0,3}:/i.test(host)) return '';
704
+ const pieces = host.slice(1, -1).split('::');
705
+ const left = pieces[0] ? pieces[0].split(':').map(part => parseInt(part, 16)) : [];
706
+ const right = pieces[1] ? pieces[1].split(':').map(part => parseInt(part, 16)) : [];
707
+ const groups = pieces.length === 1 ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right];
708
+ const [first, second, third] = groups;
709
+ if (first === 0x2002 || (first === 0x3fff && second < 0x1000) || (first === 0x2001 && second === 0xdb8)) return '';
710
+ if (first === 0x2001 && second < 0x200) {
711
+ const exception = (second === 1 && groups.slice(2, 7).every(part => part === 0) && [1, 2].includes(groups[7]))
712
+ || second === 3 || (second === 4 && third === 0x112) || (second >= 0x20 && second <= 0x3f);
713
+ if (!exception) return '';
714
+ }
715
+ } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
716
+ const rawHost = authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase();
717
+ if (rawHost !== host) return '';
718
+ const [a,b,c,last] = host.split('.').map(Number);
719
+ if (a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127)
720
+ || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
721
+ || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19))
722
+ || (a === 192 && b === 0 && ((c === 0 && ![9, 10].includes(last)) || c === 2))
723
+ || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)) return '';
724
+ }
725
+ return unwrapProxy ? source : value;
726
+ } catch (_) { return ''; }
727
+ }
728
+
729
+ /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
730
+ function _mapAvatarField(profile) {
731
+ if (profile && profile.avatarDataUrl && !profile.avatar) {
732
+ profile.avatar = profile.avatarDataUrl;
733
+ }
734
+ }
735
+
736
+ const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "intro", "gender", "age",
737
+ "country", "region", "created_at", "followers", "following", "privacy", "is_admin", "bannerUrl",
738
+ "receivedLikes", "receivedFavorites", "receivedUses"];
739
+ function _publicProfile(profile) {
740
+ return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
741
+ .map(key => [key, ['avatar', 'avatarDataUrl', 'bannerUrl'].includes(key)
742
+ ? normalizeProfileImageUrl(profile[key]) : profile[key]]));
743
+ }
744
+
745
+ function _validProfile(profile) {
746
+ return !!profile && typeof profile === 'object' && !Array.isArray(profile)
747
+ && ['name', 'avatar', 'avatarDataUrl'].every(field => profile[field] == null || typeof profile[field] === 'string');
748
+ }
749
+
750
+ /**
751
+ * 🚀 获取缓存的用户资料(同步)
752
+ * @param {string} account - 用户账号
753
+ * @returns {Object|null} 缓存的用户资料
754
+ */
755
+ export function getCachedProfile(account) {
756
+ if (!account) return null;
757
+ try {
758
+ const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
759
+ if (!cached) return null;
760
+ const profile = JSON.parse(cached);
761
+ if (!_validProfile(profile)) return null;
762
+ const publicProfile = _publicProfile(profile);
763
+ _mapAvatarField(publicProfile);
764
+ // 旧资料缓存可能含联系方式;读取返回过滤副本并尝试迁移,不因迁移失败隐藏公开内容。
765
+ if (JSON.stringify(publicProfile) !== cached) {
766
+ try { localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(publicProfile)); } catch (_) {}
767
+ }
768
+ return publicProfile;
769
+ } catch {
770
+ return null;
771
+ }
772
+ }
773
+
774
+ /**
775
+ * 🚀 保存用户资料到缓存
776
+ * @param {string} account - 用户账号
777
+ * @param {Object} profile - 用户资料
778
+ */
779
+ export function setCachedProfile(account, profile) {
780
+ if (!account || !_validProfile(profile)) return;
781
+ try {
782
+ localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(_publicProfile(profile)));
783
+ } catch {}
784
+ }
785
+
786
+ /**
787
+ * 🚀 SWR 模式获取用户资料(先返回缓存,后台校对)
788
+ * @param {string} account - 用户账号
789
+ * @param {Function} apiFn - API 调用函数 (account) => Promise<{data: profile}>
790
+ * @param {Function} onUpdate - 数据更新时的回调 (profile) => void
791
+ * @returns {Object|null} 初始缓存数据
792
+ */
793
+ export function getProfileWithSWR(account, apiFn, onUpdate) {
794
+ if (!account) return null;
795
+
796
+ // 第一轨:同步返回缓存
797
+ const cached = getCachedProfile(account);
798
+
799
+ // 第二轨:同账号只取一次,但每个视图都订阅结果。
800
+ let entry = _pendingProfileRequests.get(account);
801
+ if (!entry && typeof apiFn === 'function') {
802
+ entry = { listeners: new Set(), timer: null };
803
+ _pendingProfileRequests.set(account, entry);
804
+ const release = () => {
805
+ if (_pendingProfileRequests.get(account) === entry) _pendingProfileRequests.delete(account);
806
+ clearTimeout(entry.timer);
807
+ entry.listeners.clear();
808
+ };
809
+ entry.timer = setTimeout(release, PENDING_TIMEOUT);
810
+ Promise.resolve().then(() => apiFn(account)).then(res => {
811
+ // 超时后的旧结果不能覆盖新请求,也不能重新写入旧资料。
812
+ if (_pendingProfileRequests.get(account) !== entry) return;
813
+ if (res?.status && res.status !== 'success') return;
814
+ const data = res && Object.hasOwn(res, 'data') ? res.data : res;
815
+ if (!_validProfile(data)) return;
816
+ const profile = _publicProfile(data);
817
+ _mapAvatarField(profile);
818
+ setCachedProfile(account, profile);
819
+ for (const listener of Array.from(entry.listeners)) {
820
+ try { listener(profile); } catch { /* 单个视图异常不阻断其它订阅者 */ }
821
+ }
822
+ }).catch(() => {}).finally(release);
823
+ }
824
+ if (entry && typeof onUpdate === 'function') entry.listeners.add(onUpdate);
825
+
826
+ return cached;
827
+ }
828
+
829
+ /**
830
+ * 🚀 渲染带 SWR 缓存的头像+名称 HTML
831
+ * @param {Object} options
832
+ * @param {string} options.account - 用户账号
833
+ * @param {string} options.fallbackAvatar - 备用头像 URL(从原始数据中获取)
834
+ * @param {string} options.fallbackName - 备用名称
835
+ * @param {number} options.avatarSize - 头像尺寸 (px)
836
+ * @param {Function} options.apiFn - API 调用函数
837
+ * @param {string} options.containerId - 唯一容器 ID(用于 DOM 更新)
838
+ * @returns {string} HTML 字符串
839
+ */
840
+ export function renderAvatarWithSWR(options) {
841
+ const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
842
+ const renderId = String(++nextAvatarRenderId);
843
+ const size = typeof avatarSize === 'number' || typeof avatarSize === 'string' ? Number(avatarSize) : NaN;
844
+ const safeSize = Number.isFinite(size) && size > 0 ? size : 24;
845
+
846
+ // 从缓存获取
847
+ const cached = getCachedProfile(account);
848
+ const avatar = cached?.avatar || cached?.avatarDataUrl || normalizeProfileImageUrl(fallbackAvatar);
849
+ const name = cached?.name || fallbackName || account || '';
850
+
851
+ // 后台静默校对并更新 DOM
852
+ if (apiFn && containerId) {
853
+ setTimeout(() => {
854
+ const container = document.getElementById(containerId);
855
+ if (!container || container.dataset.profileRender !== renderId) return;
856
+ getProfileWithSWR(account, apiFn, (profile) => {
857
+ if (!container.isConnected || document.getElementById(containerId) !== container
858
+ || container.dataset.profileRender !== renderId) return;
859
+
860
+ const avatarImg = container.querySelector('.swr-avatar');
861
+ const nameSpan = container.querySelector('.swr-name');
862
+
863
+ if (avatarImg && profile.avatar) {
864
+ if (avatarImg.tagName === 'IMG') avatarImg.src = profile.avatar;
865
+ else {
866
+ const image = document.createElement('img');
867
+ image.className = 'swr-avatar';
868
+ image.alt = '';
869
+ image.style.cssText = avatarImg.style.cssText;
870
+ image.style.objectFit = 'cover';
871
+ image.src = profile.avatar;
872
+ avatarImg.replaceWith(image);
873
+ }
874
+ } else if (avatarImg && avatarImg.tagName !== 'IMG' && profile.name) {
875
+ avatarImg.textContent = Array.from(profile.name)[0]?.toUpperCase() || 'U';
876
+ }
877
+ if (nameSpan && profile.name) {
878
+ nameSpan.textContent = profile.name;
879
+ }
880
+ });
881
+ }, 0);
882
+ }
883
+
884
+ // 生成初始 HTML(无头像时显示首字母背景)
885
+ // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
886
+ // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
887
+ const initial = Array.from(String(name || account || 'U'))[0].toUpperCase();
888
+ const avatarHtml = avatar
889
+ ? `<img class="swr-avatar" alt="" src="${escapeHtml(avatar)}" style="width: ${safeSize}px; height: ${safeSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
890
+ : `<div class="swr-avatar" aria-hidden="true" style="width: ${safeSize}px; height: ${safeSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(safeSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${escapeHtml(initial)}</div>`;
891
+
892
+ return `<span id="${escapeHtml(containerId)}" data-profile-render="${renderId}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
893
+ }
894
+
895
+
896
+ // ==========================================
897
+ // 📦 导出默认配置对象
898
+ // ==========================================
899
+ export default {
900
+ IMAGE_PROCESS,
901
+ API,
902
+ CACHE,
903
+ PAGINATION,
904
+ TIP_LEVELS,
905
+ UI,
906
+ VALIDATION,
907
+ FEATURES,
908
+ IS_WEB_MODE,
909
+ ANALYTICS,
910
+ LINKS,
911
+ getApiUrl,
912
+ getCacheKey,
913
+ isFeatureEnabled,
914
+ getCurrentAccount,
915
+ getBackgroundKey,
916
+ getBannerCacheKey,
917
+ getCachedProfile,
918
+ setCachedProfile,
919
+ getProfileWithSWR,
920
+ renderAvatarWithSWR
921
+ };
922
+
923
+
924
+ // 提示词定价输入边界,与云端 PromptCreate/PromptUpdate 对应。
925
+ export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
926
+
927
+ // 任务定价边界,与云端 task_publication/task_terms 对应。
928
+ export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]), TIP_AMOUNTS: Object.freeze([10, 50, 100, 500]) });
929
+
930
+
931
+ export const TYPE_RULES = {
932
+ tool: {
933
+ recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
934
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
935
+ },
936
+ skill: {
937
+ recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
938
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
939
+ },
940
+ app: {
941
+ recommendForm: false, resType: { fixed: 'json' },
942
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
943
+ },
944
+ recommend: {
945
+ recommendForm: true, resType: { lockedByForm: true },
946
+ privateRepoWhenLink: false, price: false, refund: false, originalRequired: false,
947
+ formToResType: { recommend_tool: 'link', recommend_skill: 'link', recommend_app: 'json', recommend_link: 'netdisk' }
948
+ }
949
+ };
950
+
951
+ // 🔧 设置项 localStorage Key
952
+ const SETTINGS_KEY = "ComfyCommunity_Settings";
953
+
954
+ // 🔧 默认设置值
955
+ const DEFAULT_SETTINGS = {
956
+ enableKeyboardShortcuts: false,
957
+ enableLocalPerformanceStats: false,
958
+ showCreatorBanner: true, // 创作者卡片显示背景图
959
+ enableAnimations: true, // ✨ 榜单切换动画
960
+ enableSoundEffects: true, // 🔊 动画音效
961
+ language: 'zh-CN', // 🌐 界面语言
962
+ cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时
963
+ // 🚀 后续可扩展更多设置项
964
+ // compactMode: false, // 紧凑模式
965
+ // darkMode: true, // 深色模式
966
+ };
967
+
968
+ /**
969
+ * 📦 获取当前设置
970
+ */
971
+ export function getSettings(options = {}) {
972
+ try {
973
+ const saved = localStorage.getItem(SETTINGS_KEY);
974
+ if (saved) {
975
+ const value = JSON.parse(saved);
976
+ if (options.strict && (!value || typeof value !== 'object' || Array.isArray(value))) throw new Error('Invalid settings');
977
+ return { ...DEFAULT_SETTINGS, ...value };
978
+ }
979
+ } catch (e) { if (options.strict) throw e; }
980
+ return { ...DEFAULT_SETTINGS };
981
+ }
982
+
983
+ /**
984
+ * 💾 保存设置
985
+ */
986
+ export function areKeyboardShortcutsEnabled() {
987
+ return getSettings().enableKeyboardShortcuts === true;
988
+ }
989
+
990
+ export function saveSettings(settings) {
991
+ try {
992
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
993
+ // 🔔 广播设置变更事件,让其他组件响应
994
+ window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
995
+ return true;
996
+ } catch (e) { return false; }
997
+ }
前端页面/core/榜单数据来源.js CHANGED
@@ -1,93 +1,121 @@
1
- import { api, requestCancelManager } from './网络请求API.js';
2
- import { API, CACHE, PAGINATION } from './全局配置.js';
3
- import { requireListResponseData, getCacheWithMeta, removeCache } from '../components/性能优化工具.js';
4
-
5
- import { captureCacheWrite } from './网络请求_缓存管理.js';
6
- import { getResponseCacheSource } from './网络请求_基础设施.js';
7
-
8
- const sidebarResponseSources = new WeakMap();
9
- export const getSidebarResponseCacheSource = data => sidebarResponseSources.get(data) || null;
10
-
11
- const itemTypes = Object.freeze({ tools: 'tool', skills: 'skill', apps: 'app', recommends: 'recommend' });
12
-
13
- // 只读取与校验,不提交视图状态或缓存;提交时由调用方核查界面代次。
14
- export async function fetchSidebarList({ tab, sort, keyword = '', force = false, background = false }) {
15
- const options = background
16
- ? { noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }
17
- : (force ? { noCache: true } : {});
18
- const endpoint = Object.hasOwn(itemTypes, tab) ? "/api/items" : keyword ? "/api/creators/search" : "/api/creators";
19
- const isCurrent = captureCacheWrite(`api_${endpoint}`);
20
- let response;
21
- if (Object.hasOwn(itemTypes, tab)) {
22
- response = await api.getItems(itemTypes[tab], sort, PAGINATION.SIDEBAR_ITEM_FETCH_LIMIT, options);
23
- } else if (tab === 'creators') {
24
- if (keyword) {
25
- requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
26
- response = await api.searchCreators(keyword, sort, PAGINATION.SIDEBAR_CREATOR_SEARCH_LIMIT, options);
27
- } else {
28
- response = await api.getCreators(sort, PAGINATION.SIDEBAR_CREATOR_FETCH_LIMIT, options);
29
- }
30
- } else {
31
- throw new TypeError('Unsupported sidebar list');
32
- }
33
- if (!isCurrent()) {
34
- const error = new Error();
35
- error.name = "ListCacheChangedError";
36
- throw error;
37
- }
38
- const source = getResponseCacheSource(response);
39
- if (source && !source.source?.isAvailable()) {
40
- const error = new Error();
41
- error.name = "ListCacheChangedError";
42
- throw error;
43
- }
44
- const data = requireListResponseData(response);
45
- sidebarResponseSources.delete(data);
46
- if (source) sidebarResponseSources.set(data, source);
47
- return data;
48
- }
49
-
50
- function validCreatorList(data) {
51
- try { requireListResponseData({ status: 'success', data }); }
52
- catch { return false; }
53
- return data.every(item => ['name', 'account', 'shortDesc'].every(key =>
54
- item[key] == null || typeof item[key] === 'string'));
55
- }
56
-
57
- export function readSidebarListCache(key, { creators = false } = {}) {
58
- const miss = { found: false, expired: true, value: null };
59
- let metadata;
60
- try {
61
- metadata = getCacheWithMeta(key, true); // 过期副本仍可显示。
62
- if (!metadata.found) return metadata;
63
- } catch { return miss; } // 读取失败不代表数据损坏,不删除未知副本。
64
- try {
65
- requireListResponseData({ status: 'success', data: metadata.value });
66
- if (!creators || validCreatorList(metadata.value)) return metadata;
67
- } catch { /* 确定无效的列表不进入代理、排序或视图状态。 */ }
68
- try { removeCache(key); }
69
- catch { /* 删除失败仍按未命中处理,避免损坏内容阻断界面。 */ }
70
- return miss;
71
- }
72
-
73
- export function readCreatorsSessionCache() {
74
- let raw;
75
- try { raw = sessionStorage.getItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
76
- catch { return null; } // 无法读取不等于损坏,保留未知内容。
77
- if (raw === null) return null;
78
- try {
79
- const data = JSON.parse(raw);
80
- if (validCreatorList(data)) return data;
81
- } catch { /* 仅已读到的损坏记录可尝试删除。 */ }
82
- try { sessionStorage.removeItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
83
- catch { /* 无法删除不阻断界面。 */ }
84
- return null;
85
- }
86
-
87
- export function writeCreatorsSessionCache(data) {
88
- if (!validCreatorList(data)) return false;
89
- try {
90
- sessionStorage.setItem(CACHE.LOCAL_KEYS.CREATORS_SESSION, JSON.stringify(data));
91
- return true;
92
- } catch { return false; }
93
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api, requestCancelManager } from './网络请求API.js';
2
+ import { API, CACHE, PAGINATION } from './全局配置.js';
3
+ import { requireListResponseData, getCacheWithMeta, removeCache, writeListCache } from '../components/性能优化工具.js';
4
+
5
+ import { captureCacheWrite } from './网络请求_缓存管理.js';
6
+ import { getResponseCacheSource } from './网络请求_基础设施.js';
7
+
8
+
9
+ export function captureListRead(cacheKey) {
10
+ const definition = Object.values(CACHE.PUBLIC_LISTS).find(list => typeof cacheKey === 'string' && cacheKey.startsWith(list.prefix));
11
+ if (!definition) throw new TypeError('Unsupported list cache');
12
+ const listCurrent = captureCacheWrite(cacheKey);
13
+ const apiCurrent = captureCacheWrite(`api_${definition.endpoint}`);
14
+ const isCurrent = () => listCurrent() && apiCurrent();
15
+ return response => {
16
+ const source = getResponseCacheSource(response);
17
+ if (!isCurrent() || (source && !source.source?.isAvailable())) {
18
+ const error = new Error();
19
+ error.name = 'ListCacheChangedError';
20
+ throw error;
21
+ }
22
+ const data = requireListResponseData(response);
23
+ return {
24
+ data,
25
+ write(ttl) {
26
+ if (!isCurrent()) return false;
27
+ requireListResponseData({ status: 'success', data });
28
+ if (source) return source.source.copyTo(cacheKey, data);
29
+ writeListCache(cacheKey, data, ttl);
30
+ return true;
31
+ }
32
+ };
33
+ };
34
+ }
35
+
36
+ const sidebarResponseSources = new WeakMap();
37
+ export const getSidebarResponseCacheSource = data => sidebarResponseSources.get(data) || null;
38
+
39
+ const itemTypes = Object.freeze({ tools: 'tool', skills: 'skill', apps: 'app', recommends: 'recommend' });
40
+
41
+ // 只读取与校验,不提交视图状态或缓存;提交时由调用方核查界面代次。
42
+ export async function fetchSidebarList({ tab, sort, keyword = '', force = false, background = false }) {
43
+ const options = background
44
+ ? { noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }
45
+ : (force ? { noCache: true } : {});
46
+ const endpoint = Object.hasOwn(itemTypes, tab) ? "/api/items" : keyword ? "/api/creators/search" : "/api/creators";
47
+ const isCurrent = captureCacheWrite(`api_${endpoint}`);
48
+ let response;
49
+ if (Object.hasOwn(itemTypes, tab)) {
50
+ response = await api.getItems(itemTypes[tab], sort, PAGINATION.SIDEBAR_ITEM_FETCH_LIMIT, options);
51
+ } else if (tab === 'creators') {
52
+ if (keyword) {
53
+ requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
54
+ response = await api.searchCreators(keyword, sort, PAGINATION.SIDEBAR_CREATOR_SEARCH_LIMIT, options);
55
+ } else {
56
+ response = await api.getCreators(sort, PAGINATION.SIDEBAR_CREATOR_FETCH_LIMIT, options);
57
+ }
58
+ } else {
59
+ throw new TypeError('Unsupported sidebar list');
60
+ }
61
+ if (!isCurrent()) {
62
+ const error = new Error();
63
+ error.name = "ListCacheChangedError";
64
+ throw error;
65
+ }
66
+ const source = getResponseCacheSource(response);
67
+ if (source && !source.source?.isAvailable()) {
68
+ const error = new Error();
69
+ error.name = "ListCacheChangedError";
70
+ throw error;
71
+ }
72
+ const data = requireListResponseData(response);
73
+ sidebarResponseSources.delete(data);
74
+ if (source) sidebarResponseSources.set(data, source);
75
+ return data;
76
+ }
77
+
78
+ function validCreatorList(data) {
79
+ try { requireListResponseData({ status: 'success', data }); }
80
+ catch { return false; }
81
+ return data.every(item => ['name', 'account', 'shortDesc'].every(key =>
82
+ item[key] == null || typeof item[key] === 'string'));
83
+ }
84
+
85
+ export function readSidebarListCache(key, { creators = false } = {}) {
86
+ const miss = { found: false, expired: true, value: null };
87
+ let metadata;
88
+ try {
89
+ metadata = getCacheWithMeta(key, true); // 过期副本仍可显示。
90
+ if (!metadata.found) return metadata;
91
+ } catch { return miss; } // 读取失败不代表数据损坏,不删除未知副本。
92
+ try {
93
+ requireListResponseData({ status: 'success', data: metadata.value });
94
+ if (!creators || validCreatorList(metadata.value)) return metadata;
95
+ } catch { /* 确定无效的列表不进入代理、排序或视图状态。 */ }
96
+ try { removeCache(key); }
97
+ catch { /* 删除失败仍按未命中处理,避免损坏内容阻断界面。 */ }
98
+ return miss;
99
+ }
100
+
101
+ export function readCreatorsSessionCache() {
102
+ let raw;
103
+ try { raw = sessionStorage.getItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
104
+ catch { return null; } // 无法读取不等于损坏,保留未知内容。
105
+ if (raw === null) return null;
106
+ try {
107
+ const data = JSON.parse(raw);
108
+ if (validCreatorList(data)) return data;
109
+ } catch { /* 仅已读到的损坏记录可尝试删除。 */ }
110
+ try { sessionStorage.removeItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
111
+ catch { /* 无法删除不阻断界面。 */ }
112
+ return null;
113
+ }
114
+
115
+ export function writeCreatorsSessionCache(data) {
116
+ if (!validCreatorList(data)) return false;
117
+ try {
118
+ sessionStorage.setItem(CACHE.LOCAL_KEYS.CREATORS_SESSION, JSON.stringify(data));
119
+ return true;
120
+ } catch { return false; }
121
+ }
前端页面/core/网络请求_缓存管理.js CHANGED
@@ -35,7 +35,7 @@ const memoryOnlyEntries = new WeakSet();
35
 
36
  let cacheClearToken = {};
37
  const cacheWriteScopes = [
38
- ...[...CACHE.RESOURCE_LIST_PREFIXES, getSidebarListCacheKey('creators', '')].map(prefix =>
39
  ({ prefix, matches: key => key.startsWith(prefix), token: {} })),
40
  ...API.PUBLIC_CONDITIONAL_GET_PATHS.map(path => {
41
  const prefix = `api_${path}`;
@@ -72,7 +72,7 @@ export function captureCacheSource(key) {
72
  copyTo(targetKey, value) {
73
  const normalized = targetKey;
74
  if (!isFresh() || typeof normalized !== 'string' ||
75
- !CACHE.RESOURCE_LIST_PREFIXES.concat(getSidebarListCacheKey('creators', '')).some(prefix => normalized.startsWith(prefix))) return false;
76
  _storeCache(normalized, value, cached.expireAt, true, {});
77
  return true;
78
  }
@@ -107,10 +107,13 @@ function _isValidCacheEntry(cached) {
107
  }
108
 
109
  // 同前缀的本地记录/配置使用独立格式;自动缓存清理不能处理它们。
110
- function _isManagedStorageKey(key) {
 
 
 
 
111
  if (!key || !key.startsWith(CACHE_PREFIX)) return false;
112
- return !Object.values(CACHE.LOCAL_KEYS).some(localKey => key === localKey
113
- || key.startsWith(localKey.endsWith('_') ? localKey : localKey + '_'));
114
  }
115
 
116
  /**
@@ -337,8 +340,9 @@ export function getCacheKeysByPrefix(prefix) {
337
  if (key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
338
  }
339
  try {
340
- for (let index = 0; index < localStorage.length; index++) {
341
- const key = localStorage.key(index);
 
342
  if (key && key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
343
  }
344
  } catch {
@@ -370,13 +374,15 @@ function _enforceStorageBudget(protectedKey = null) {
370
  && now - lastStorageBudgetSweepAt < 60_000) return;
371
 
372
  try {
 
 
373
  const entries = [];
374
  const invalidKeys = [];
375
  let totalBytes = 0;
376
- for (let i = 0; i < localStorage.length; i++) {
377
- const key = localStorage.key(i);
378
- if (!_isManagedStorageKey(key)) continue;
379
- const raw = localStorage.getItem(key);
380
  if (raw === null) continue;
381
  let cached;
382
  try {
@@ -394,12 +400,12 @@ function _enforceStorageBudget(protectedKey = null) {
394
  entries.push({ key, sizeBytes, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
395
  totalBytes += sizeBytes;
396
  }
397
- invalidKeys.forEach(key => localStorage.removeItem(key));
398
 
399
  let evicted = 0;
400
  for (const entry of [...entries]) {
401
  if (entry.key === protectedKey && entry.sizeBytes > MAX_STORAGE_BYTES) {
402
- localStorage.removeItem(entry.key);
403
  totalBytes -= entry.sizeBytes;
404
  entries.splice(entries.indexOf(entry), 1);
405
  evicted++;
@@ -408,7 +414,7 @@ function _enforceStorageBudget(protectedKey = null) {
408
  entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
409
  while (totalBytes > MAX_STORAGE_BYTES && entries.length > 0) {
410
  const oldest = entries.shift();
411
- localStorage.removeItem(oldest.key);
412
  totalBytes -= oldest.sizeBytes;
413
  evicted++;
414
  }
@@ -470,13 +476,14 @@ function _removeCacheByPrefixes(prefixes) {
470
  }
471
 
472
  try {
 
473
  const keysToRemove = [];
474
- for (let i = 0; i < localStorage.length; i++) {
475
- const k = localStorage.key(i);
476
  if (k && isMatch(k)) keysToRemove.push(k);
477
  }
478
  keysToRemove.forEach(k => {
479
- localStorage.removeItem(k);
480
  removed.add(k);
481
  });
482
  } catch {}
@@ -495,14 +502,15 @@ export function clearAllCache() {
495
  storageUsageBytes = null;
496
 
497
  try {
 
498
  const keysToRemove = [];
499
- for (let i = 0; i < localStorage.length; i++) {
500
- const key = localStorage.key(i);
501
  if (key && key.startsWith(CACHE_PREFIX)) {
502
  keysToRemove.push(key);
503
  }
504
  }
505
- keysToRemove.forEach(k => localStorage.removeItem(k));
506
  return true;
507
  } catch {
508
  return false;
@@ -528,17 +536,19 @@ function _enforceCacheLimit() {
528
  // @param {boolean} aggressive - 是否激进模式(同时清除所有项而非仅过期项)
529
  function _cleanExpiredStorage(aggressive = false) {
530
  try {
 
 
531
  const now = Date.now();
532
  const keysToRemove = [];
533
- for (let i = 0; i < localStorage.length; i++) {
534
- const key = localStorage.key(i);
535
- if (_isManagedStorageKey(key)) {
536
  if (aggressive) {
537
  // 激进模式:清除所有缓存项
538
  keysToRemove.push(key);
539
  } else {
540
  let stored;
541
- try { stored = localStorage.getItem(key); } catch { continue; }
542
  if (stored === null) continue;
543
  try {
544
  const cached = JSON.parse(stored);
@@ -551,7 +561,7 @@ function _cleanExpiredStorage(aggressive = false) {
551
  }
552
  }
553
  }
554
- keysToRemove.forEach(k => localStorage.removeItem(k));
555
  if (keysToRemove.length > 0) {
556
  console.log(`🧹 清理 localStorage 过期缓存: ${keysToRemove.length} 个项`);
557
  }
@@ -561,13 +571,15 @@ function _cleanExpiredStorage(aggressive = false) {
561
  // LRU 淘汰:按最近使用时间删除最旧的 localStorage 缓存项
562
  function _evictOldestStorage() {
563
  try {
 
 
564
  const entries = [];
565
  const invalidKeys = [];
566
- for (let i = 0; i < localStorage.length; i++) {
567
- const key = localStorage.key(i);
568
- if (_isManagedStorageKey(key)) {
569
  let stored;
570
- try { stored = localStorage.getItem(key); } catch { continue; }
571
  if (stored === null) continue;
572
  try {
573
  const cached = JSON.parse(stored);
@@ -583,7 +595,7 @@ function _evictOldestStorage() {
583
  }
584
  }
585
 
586
- invalidKeys.forEach(key => localStorage.removeItem(key));
587
  if (entries.length === 0) return invalidKeys.length > 0;
588
 
589
  entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
@@ -591,7 +603,7 @@ function _evictOldestStorage() {
591
  // 🧹 批量淘汰策略:每次淘汰最旧的 30%(温和策略,减少频繁全量扫描)
592
  const evictCount = Math.max(1, Math.ceil(entries.length * 0.3));
593
  for (let i = 0; i < evictCount; i++) {
594
- localStorage.removeItem(entries[i].key);
595
  }
596
  console.warn(`⚠️ LRU 淘汰: 删除了 ${evictCount} 个最旧 localStorage 缓存项`);
597
  return true;
@@ -676,17 +688,17 @@ const CACHE_CONFIG = {
676
  const CACHE_INVALIDATION_MAP = {
677
  // 任务相关
678
  "/api/tasks": {
679
- "POST": ["api_/api/tasks", "ComfyRanking_ListCache_tasks"], // 创建任务,清除列表缓存
680
- "PUT": ["api_/api/tasks"], // 更新任务,清除列表缓存
681
- "DELETE": ["api_/api/tasks", "ComfyRanking_ListCache_tasks"], // 删除任务,清除列表缓存
682
- "INTERACTION": ["api_/api/tasks", "ComfyRanking_ListCache_tasks"] // 👍 互动操作(view/like/favorite/tip)
683
  },
684
  // 帖子相关
685
  "/api/posts": {
686
- "POST": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 创建帖子,清除列表缓存
687
- "PUT": ["api_/api/posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 更新帖子,清除列表缓存
688
- "DELETE": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 删除帖子,清除列表缓存
689
- "INTERACTION": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like/favorite/tip)
690
  },
691
  // 评论相关
692
  "/api/comments": {
@@ -701,10 +713,10 @@ const CACHE_INVALIDATION_MAP = {
701
  "INTERACTION": ["api_/api/items", "api_/api/creators", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like)
702
  },
703
  "/api/prompts": {
704
- "POST": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
705
- "PUT": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
706
- "DELETE": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
707
- "INTERACTION": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX]
708
  },
709
  "/api/interactions": { "POST": ["api_/api/items", "api_/api/creators", ...CACHE.RESOURCE_LIST_PREFIXES, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] },
710
  // 用户相关
@@ -806,7 +818,8 @@ export function isPrivateApiGet(endpoint) {
806
  export function purgePrivateApiCache() {
807
  const keys = new Set(memoryCache.keys());
808
  try {
809
- for (let index = 0; index < localStorage.length; index++) keys.add(localStorage.key(index));
 
810
  } catch (_) {}
811
  const prefix = CACHE_PREFIX + "api_";
812
  for (const fullKey of keys) {
 
35
 
36
  let cacheClearToken = {};
37
  const cacheWriteScopes = [
38
+ ...[...CACHE.RESOURCE_LIST_PREFIXES, getSidebarListCacheKey('creators', ''), ...Object.values(CACHE.PUBLIC_LISTS).map(list => list.prefix)].map(prefix =>
39
  ({ prefix, matches: key => key.startsWith(prefix), token: {} })),
40
  ...API.PUBLIC_CONDITIONAL_GET_PATHS.map(path => {
41
  const prefix = `api_${path}`;
 
72
  copyTo(targetKey, value) {
73
  const normalized = targetKey;
74
  if (!isFresh() || typeof normalized !== 'string' ||
75
+ !CACHE.RESOURCE_LIST_PREFIXES.concat(getSidebarListCacheKey('creators', ''), Object.values(CACHE.PUBLIC_LISTS).map(list => list.prefix)).some(prefix => normalized.startsWith(prefix))) return false;
76
  _storeCache(normalized, value, cached.expireAt, true, {});
77
  return true;
78
  }
 
107
  }
108
 
109
  // 同前缀的本地记录/配置使用独立格式;自动缓存清理不能处理它们。
110
+ function _storageExclusions() {
111
+ return Object.values(CACHE.LOCAL_KEYS).map(key => [key, key.endsWith('_') ? key : key + '_']);
112
+ }
113
+
114
+ function _isManagedStorageKey(key, exclusions) {
115
  if (!key || !key.startsWith(CACHE_PREFIX)) return false;
116
+ return !exclusions.some(([exact, prefix]) => key === exact || key.startsWith(prefix));
 
117
  }
118
 
119
  /**
 
340
  if (key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
341
  }
342
  try {
343
+ const storage = localStorage;
344
+ for (let index = 0; index < storage.length; index++) {
345
+ const key = storage.key(index);
346
  if (key && key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
347
  }
348
  } catch {
 
374
  && now - lastStorageBudgetSweepAt < 60_000) return;
375
 
376
  try {
377
+ const storage = localStorage;
378
+ const exclusions = _storageExclusions();
379
  const entries = [];
380
  const invalidKeys = [];
381
  let totalBytes = 0;
382
+ for (let i = 0; i < storage.length; i++) {
383
+ const key = storage.key(i);
384
+ if (!_isManagedStorageKey(key, exclusions)) continue;
385
+ const raw = storage.getItem(key);
386
  if (raw === null) continue;
387
  let cached;
388
  try {
 
400
  entries.push({ key, sizeBytes, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
401
  totalBytes += sizeBytes;
402
  }
403
+ invalidKeys.forEach(key => storage.removeItem(key));
404
 
405
  let evicted = 0;
406
  for (const entry of [...entries]) {
407
  if (entry.key === protectedKey && entry.sizeBytes > MAX_STORAGE_BYTES) {
408
+ storage.removeItem(entry.key);
409
  totalBytes -= entry.sizeBytes;
410
  entries.splice(entries.indexOf(entry), 1);
411
  evicted++;
 
414
  entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
415
  while (totalBytes > MAX_STORAGE_BYTES && entries.length > 0) {
416
  const oldest = entries.shift();
417
+ storage.removeItem(oldest.key);
418
  totalBytes -= oldest.sizeBytes;
419
  evicted++;
420
  }
 
476
  }
477
 
478
  try {
479
+ const storage = localStorage;
480
  const keysToRemove = [];
481
+ for (let i = 0; i < storage.length; i++) {
482
+ const k = storage.key(i);
483
  if (k && isMatch(k)) keysToRemove.push(k);
484
  }
485
  keysToRemove.forEach(k => {
486
+ storage.removeItem(k);
487
  removed.add(k);
488
  });
489
  } catch {}
 
502
  storageUsageBytes = null;
503
 
504
  try {
505
+ const storage = localStorage;
506
  const keysToRemove = [];
507
+ for (let i = 0; i < storage.length; i++) {
508
+ const key = storage.key(i);
509
  if (key && key.startsWith(CACHE_PREFIX)) {
510
  keysToRemove.push(key);
511
  }
512
  }
513
+ keysToRemove.forEach(k => storage.removeItem(k));
514
  return true;
515
  } catch {
516
  return false;
 
536
  // @param {boolean} aggressive - 是否激进模式(同时清除所有项而非仅过期项)
537
  function _cleanExpiredStorage(aggressive = false) {
538
  try {
539
+ const storage = localStorage;
540
+ const exclusions = _storageExclusions();
541
  const now = Date.now();
542
  const keysToRemove = [];
543
+ for (let i = 0; i < storage.length; i++) {
544
+ const key = storage.key(i);
545
+ if (_isManagedStorageKey(key, exclusions)) {
546
  if (aggressive) {
547
  // 激进模式:清除所有缓存项
548
  keysToRemove.push(key);
549
  } else {
550
  let stored;
551
+ try { stored = storage.getItem(key); } catch { continue; }
552
  if (stored === null) continue;
553
  try {
554
  const cached = JSON.parse(stored);
 
561
  }
562
  }
563
  }
564
+ keysToRemove.forEach(k => storage.removeItem(k));
565
  if (keysToRemove.length > 0) {
566
  console.log(`🧹 清理 localStorage 过期缓存: ${keysToRemove.length} 个项`);
567
  }
 
571
  // LRU 淘汰:按最近使用时间删除最旧的 localStorage 缓存项
572
  function _evictOldestStorage() {
573
  try {
574
+ const storage = localStorage;
575
+ const exclusions = _storageExclusions();
576
  const entries = [];
577
  const invalidKeys = [];
578
+ for (let i = 0; i < storage.length; i++) {
579
+ const key = storage.key(i);
580
+ if (_isManagedStorageKey(key, exclusions)) {
581
  let stored;
582
+ try { stored = storage.getItem(key); } catch { continue; }
583
  if (stored === null) continue;
584
  try {
585
  const cached = JSON.parse(stored);
 
595
  }
596
  }
597
 
598
+ invalidKeys.forEach(key => storage.removeItem(key));
599
  if (entries.length === 0) return invalidKeys.length > 0;
600
 
601
  entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
 
603
  // 🧹 批量淘汰策略:每次淘汰最旧的 30%(温和策略,减少频繁全量扫描)
604
  const evictCount = Math.max(1, Math.ceil(entries.length * 0.3));
605
  for (let i = 0; i < evictCount; i++) {
606
+ storage.removeItem(entries[i].key);
607
  }
608
  console.warn(`⚠️ LRU 淘汰: 删除了 ${evictCount} 个最旧 localStorage 缓存项`);
609
  return true;
 
688
  const CACHE_INVALIDATION_MAP = {
689
  // 任务相关
690
  "/api/tasks": {
691
+ "POST": ["api_/api/tasks", CACHE.PUBLIC_LISTS.tasks.prefix], // 创建任务,清除列表缓存
692
+ "PUT": ["api_/api/tasks", CACHE.PUBLIC_LISTS.tasks.prefix], // 更新任务,清除列���缓存
693
+ "DELETE": ["api_/api/tasks", CACHE.PUBLIC_LISTS.tasks.prefix], // 删除任务,清除列表缓存
694
+ "INTERACTION": ["api_/api/tasks", CACHE.PUBLIC_LISTS.tasks.prefix] // 👍 互动操作(view/like/favorite/tip)
695
  },
696
  // 帖子相关
697
  "/api/posts": {
698
+ "POST": ["api_/api/posts", CACHE.PUBLIC_LISTS.posts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 创建帖子,清除列表缓存
699
+ "PUT": ["api_/api/posts", CACHE.PUBLIC_LISTS.posts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 更新帖子,清除列表缓存
700
+ "DELETE": ["api_/api/posts", CACHE.PUBLIC_LISTS.posts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 删除帖子,清除列表缓存
701
+ "INTERACTION": ["api_/api/posts", CACHE.PUBLIC_LISTS.posts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like/favorite/tip)
702
  },
703
  // 评论相关
704
  "/api/comments": {
 
713
  "INTERACTION": ["api_/api/items", "api_/api/creators", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like)
714
  },
715
  "/api/prompts": {
716
+ "POST": ["api_/api/prompts", CACHE.PUBLIC_LISTS.prompts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
717
+ "PUT": ["api_/api/prompts", CACHE.PUBLIC_LISTS.prompts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
718
+ "DELETE": ["api_/api/prompts", CACHE.PUBLIC_LISTS.prompts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
719
+ "INTERACTION": ["api_/api/prompts", CACHE.PUBLIC_LISTS.prompts.prefix, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX]
720
  },
721
  "/api/interactions": { "POST": ["api_/api/items", "api_/api/creators", ...CACHE.RESOURCE_LIST_PREFIXES, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] },
722
  // 用户相关
 
818
  export function purgePrivateApiCache() {
819
  const keys = new Set(memoryCache.keys());
820
  try {
821
+ const storage = localStorage;
822
+ for (let index = 0; index < storage.length; index++) keys.add(storage.key(index));
823
  } catch (_) {}
824
  const prefix = CACHE_PREFIX + "api_";
825
  for (const fullKey of keys) {
前端页面/post/讨论区组件.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // 前端页面/post/讨论区组件.js
2
  // ==========================================
3
  // 💬 讨论区组件(小红书风格瀑布流列表)
@@ -18,7 +19,7 @@ import { createCardNavigationScope } from "../components/卡片导航生命周
18
  import { api, captureRequestSession } from "../core/网络请求API.js";
19
  import { proxyImages } from "../core/网络请求API.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
21
- import { readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
22
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
23
  import { t } from "../components/用户体验增强.js";
24
  import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
@@ -26,7 +27,7 @@ import { createRatingStars } from "../social/评论与互动组件.js";
26
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
27
 
28
  // 缓存配置
29
- const CACHE_KEY_PREFIX = "PostsCache";
30
  const PAGE_SIZE = 20;
31
 
32
  // 缓存当前用户
@@ -136,9 +137,11 @@ export function createPostsView(currentUser, keyword = "") {
136
  const loadMorePosts = async (page, pageSize, { isCurrent = () => true } = {}) => {
137
  if (disposed || !isCurrent()) return [];
138
  try {
 
139
  const res = await api.getPosts(page, pageSize, currentSort);
140
  if (disposed || !isCurrent()) return [];
141
- const posts = requireListResponseData(res);
 
142
 
143
  if (posts.length === 0) {
144
  if (searchKeyword && !allPostsData.some(post => _matchesSearch(post, searchKeyword))) {
@@ -346,6 +349,7 @@ export function createPostsView(currentUser, keyword = "") {
346
 
347
  requestGeneration = ++networkRequestGeneration;
348
  isLoadingFromNetwork = true;
 
349
  const res = await api.getPosts(page, PAGE_SIZE, requestedSort);
350
  if (viewGeneration !== listViewGeneration || requestGeneration !== networkRequestGeneration) {
351
  if (requestGeneration === networkRequestGeneration) {
@@ -354,7 +358,8 @@ export function createPostsView(currentUser, keyword = "") {
354
  }
355
  return;
356
  }
357
- const posts = requireListResponseData(res);
 
358
  if (page === 1) firstPageReady = true;
359
  const total = res.total || 0;
360
  isLoadingFromNetwork = false;
@@ -362,7 +367,7 @@ export function createPostsView(currentUser, keyword = "") {
362
  // 缓存第一页数据
363
  if (page === 1) {
364
  allPostsData = proxyImages(posts);
365
- writeListCache(cacheKey, posts, getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
366
  } else {
367
  allPostsData = [...allPostsData, ...proxyImages(posts)];
368
  }
@@ -437,7 +442,7 @@ export function createPostsView(currentUser, keyword = "") {
437
  renderPostsFromCache(allPostsData);
438
  showToast(t('post.network_cache'), "warning");
439
  } else {
440
- renderListLoadError(postsGrid, t('post.load_failed'), () => loadPosts(1, false), t, err);
441
  }
442
  }
443
  }
@@ -502,6 +507,7 @@ export function createPostsView(currentUser, keyword = "") {
502
  const requestGeneration = ++networkRequestGeneration;
503
  try {
504
  isLoadingFromNetwork = true;
 
505
  const res = await api.getPosts(1, PAGE_SIZE, requestedSort, {
506
  noCache: true,
507
  timeout: API.BACKGROUND_TIMEOUT,
@@ -514,14 +520,15 @@ export function createPostsView(currentUser, keyword = "") {
514
  }
515
  return;
516
  }
517
- const posts = requireListResponseData(res);
 
518
  isLoadingFromNetwork = false;
519
 
520
  if (isEmptyListRefreshWithExistingData(posts, allPostsData)) {
521
  console.warn(`🛡️ 讨论区 ${requestedSort} 后台返回空列表,保留当前数据与缓存`);
522
  } else {
523
  // 更新缓存
524
- writeListCache(cacheKey, posts, getCacheTTL());
525
 
526
  // 对比新旧数据,有变化时重新渲染
527
  if (_postsDataChanged(allPostsData, posts)) {
 
1
+ import { captureListRead } from "../core/榜单数据来源.js";
2
  // 前端页面/post/讨论区组件.js
3
  // ==========================================
4
  // 💬 讨论区组件(小红书风格瀑布流列表)
 
19
  import { api, captureRequestSession } from "../core/网络请求API.js";
20
  import { proxyImages } from "../core/网络请求API.js";
21
  import { showToast } from "../components/UI交互提示组件.js";
22
+ import { readListCache, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
23
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
24
  import { t } from "../components/用户体验增强.js";
25
  import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
 
27
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
28
 
29
  // 缓存配置
30
+ const CACHE_KEY_PREFIX = CACHE.PUBLIC_LISTS.posts.prefix.slice(0, -1);
31
  const PAGE_SIZE = 20;
32
 
33
  // 缓存当前用户
 
137
  const loadMorePosts = async (page, pageSize, { isCurrent = () => true } = {}) => {
138
  if (disposed || !isCurrent()) return [];
139
  try {
140
+ const readResponse = captureListRead(`${CACHE_KEY_PREFIX}_${currentSort}`);
141
  const res = await api.getPosts(page, pageSize, currentSort);
142
  if (disposed || !isCurrent()) return [];
143
+ const responseData = readResponse(res);
144
+ const posts = responseData.data;
145
 
146
  if (posts.length === 0) {
147
  if (searchKeyword && !allPostsData.some(post => _matchesSearch(post, searchKeyword))) {
 
349
 
350
  requestGeneration = ++networkRequestGeneration;
351
  isLoadingFromNetwork = true;
352
+ const readResponse = captureListRead(cacheKey);
353
  const res = await api.getPosts(page, PAGE_SIZE, requestedSort);
354
  if (viewGeneration !== listViewGeneration || requestGeneration !== networkRequestGeneration) {
355
  if (requestGeneration === networkRequestGeneration) {
 
358
  }
359
  return;
360
  }
361
+ const responseData = readResponse(res);
362
+ const posts = responseData.data;
363
  if (page === 1) firstPageReady = true;
364
  const total = res.total || 0;
365
  isLoadingFromNetwork = false;
 
367
  // 缓存第一页数据
368
  if (page === 1) {
369
  allPostsData = proxyImages(posts);
370
+ responseData.write(getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
371
  } else {
372
  allPostsData = [...allPostsData, ...proxyImages(posts)];
373
  }
 
442
  renderPostsFromCache(allPostsData);
443
  showToast(t('post.network_cache'), "warning");
444
  } else {
445
+ renderListLoadError(postsGrid, err?.name === 'ListCacheChangedError' ? t('feedback.list_changed_retry') : t('post.load_failed'), () => loadPosts(1, false), t, err);
446
  }
447
  }
448
  }
 
507
  const requestGeneration = ++networkRequestGeneration;
508
  try {
509
  isLoadingFromNetwork = true;
510
+ const readResponse = captureListRead(cacheKey);
511
  const res = await api.getPosts(1, PAGE_SIZE, requestedSort, {
512
  noCache: true,
513
  timeout: API.BACKGROUND_TIMEOUT,
 
520
  }
521
  return;
522
  }
523
+ const responseData = readResponse(res);
524
+ const posts = responseData.data;
525
  isLoadingFromNetwork = false;
526
 
527
  if (isEmptyListRefreshWithExistingData(posts, allPostsData)) {
528
  console.warn(`🛡️ 讨论区 ${requestedSort} 后台返回空列表,保留当前数据与缓存`);
529
  } else {
530
  // 更新缓存
531
+ responseData.write(getCacheTTL());
532
 
533
  // 对比新旧数据,有变化时重新渲染
534
  if (_postsDataChanged(allPostsData, posts)) {
前端页面/prompt/提示词组件.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // ==========================================
2
  // 🧩 提示词组件(小红书风格瀑布流列表)
3
  // ==========================================
@@ -21,14 +22,14 @@ import { api, proxyImages, captureRequestSession } from "../core/网络请求API
21
  import { showToast } from "../components/UI交互提示组件.js";
22
  // 📦 列表缓存走 readListCache/writeListCache 统一口径;分类缓存形态不同(空数组也是
23
  // 有效缓存态),仍用底层 setCache/getCacheWithMeta
24
- import { setCache, getCacheWithMeta, readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
25
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
26
  import { t, tIfExists } from "../components/用户体验增强.js";
27
  import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
28
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
29
 
30
  // 缓存配置
31
- const CACHE_KEY_PREFIX = "PromptsCache";
32
  const CATEGORIES_CACHE_PREFIX = "PromptsCategoriesCache"; // 📴 分类缓存键前缀(离线容灾)
33
  const PAGE_SIZE = 20;
34
 
@@ -279,9 +280,11 @@ export function createPromptsView(currentUser, keyword = "") {
279
  const loadMorePrompts = async (page, pageSize, { isCurrent = () => true } = {}) => {
280
  if (disposed || !isCurrent()) return [];
281
  try {
 
282
  const res = await api.getPrompts(currentType, currentCategory, page, pageSize, currentSort);
283
  if (disposed || !isCurrent()) return [];
284
- const prompts = requireListResponseData(res);
 
285
 
286
  if (prompts.length === 0) {
287
  if (searchKeyword && !allPromptsData.some(prompt => _matchesSearch(prompt, searchKeyword))) {
@@ -469,6 +472,7 @@ export function createPromptsView(currentUser, keyword = "") {
469
 
470
  requestGeneration = ++networkRequestGeneration;
471
  isLoadingFromNetwork = true;
 
472
  const res = await api.getPrompts(requestedType, requestedCategory, page, PAGE_SIZE, requestedSort);
473
  if (viewGeneration !== listViewGeneration || requestGeneration !== networkRequestGeneration) {
474
  if (requestGeneration === networkRequestGeneration) {
@@ -477,7 +481,8 @@ export function createPromptsView(currentUser, keyword = "") {
477
  }
478
  return;
479
  }
480
- const prompts = requireListResponseData(res);
 
481
  if (page === 1) firstPageReady = true;
482
  const total = res.total || 0;
483
  isLoadingFromNetwork = false;
@@ -485,7 +490,7 @@ export function createPromptsView(currentUser, keyword = "") {
485
  // 缓存第一页数据
486
  if (page === 1) {
487
  allPromptsData = proxyImages(prompts);
488
- writeListCache(cacheKey, prompts, getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
489
  } else {
490
  allPromptsData = [...allPromptsData, ...proxyImages(prompts)];
491
  }
@@ -559,7 +564,7 @@ export function createPromptsView(currentUser, keyword = "") {
559
  renderPromptsFromCache(allPromptsData);
560
  showToast(t('prompt.network_cache'), "warning");
561
  } else {
562
- renderListLoadError(promptsGrid, t('prompt.load_failed'), () => loadPrompts(1, false), t, err);
563
  }
564
  }
565
  }
@@ -626,6 +631,7 @@ export function createPromptsView(currentUser, keyword = "") {
626
  const requestGeneration = ++networkRequestGeneration;
627
  try {
628
  isLoadingFromNetwork = true;
 
629
  const res = await api.getPrompts(requestedType, requestedCategory, 1, PAGE_SIZE, requestedSort, {
630
  noCache: true,
631
  timeout: API.BACKGROUND_TIMEOUT,
@@ -638,14 +644,15 @@ export function createPromptsView(currentUser, keyword = "") {
638
  }
639
  return;
640
  }
641
- const prompts = requireListResponseData(res);
 
642
  isLoadingFromNetwork = false;
643
 
644
  if (isEmptyListRefreshWithExistingData(prompts, allPromptsData)) {
645
  console.warn(`🛡️ 提示词 ${requestedType}/${requestedSort} 后台返回空列表,保留当前数据与缓存`);
646
  } else {
647
  // 更新缓存
648
- writeListCache(cacheKey, prompts, getCacheTTL());
649
 
650
  // 对比新旧数据,有变化时重新渲染
651
  if (_promptsDataChanged(allPromptsData, prompts)) {
 
1
+ import { captureListRead } from "../core/榜单数据来源.js";
2
  // ==========================================
3
  // 🧩 提示词组件(小红书风格瀑布流列表)
4
  // ==========================================
 
22
  import { showToast } from "../components/UI交互提示组件.js";
23
  // 📦 列表缓存走 readListCache/writeListCache 统一口径;分类缓存形态不同(空数组也是
24
  // 有效缓存态),仍用底层 setCache/getCacheWithMeta
25
+ import { setCache, getCacheWithMeta, readListCache, isEmptyListRefreshWithExistingData, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
26
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
27
  import { t, tIfExists } from "../components/用户体验增强.js";
28
  import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
29
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
30
 
31
  // 缓存配置
32
+ const CACHE_KEY_PREFIX = CACHE.PUBLIC_LISTS.prompts.prefix.slice(0, -1);
33
  const CATEGORIES_CACHE_PREFIX = "PromptsCategoriesCache"; // 📴 分类缓存键前缀(离线容灾)
34
  const PAGE_SIZE = 20;
35
 
 
280
  const loadMorePrompts = async (page, pageSize, { isCurrent = () => true } = {}) => {
281
  if (disposed || !isCurrent()) return [];
282
  try {
283
+ const readResponse = captureListRead(promptListCacheKey(currentType, currentCategory, currentSort));
284
  const res = await api.getPrompts(currentType, currentCategory, page, pageSize, currentSort);
285
  if (disposed || !isCurrent()) return [];
286
+ const responseData = readResponse(res);
287
+ const prompts = responseData.data;
288
 
289
  if (prompts.length === 0) {
290
  if (searchKeyword && !allPromptsData.some(prompt => _matchesSearch(prompt, searchKeyword))) {
 
472
 
473
  requestGeneration = ++networkRequestGeneration;
474
  isLoadingFromNetwork = true;
475
+ const readResponse = captureListRead(cacheKey);
476
  const res = await api.getPrompts(requestedType, requestedCategory, page, PAGE_SIZE, requestedSort);
477
  if (viewGeneration !== listViewGeneration || requestGeneration !== networkRequestGeneration) {
478
  if (requestGeneration === networkRequestGeneration) {
 
481
  }
482
  return;
483
  }
484
+ const responseData = readResponse(res);
485
+ const prompts = responseData.data;
486
  if (page === 1) firstPageReady = true;
487
  const total = res.total || 0;
488
  isLoadingFromNetwork = false;
 
490
  // 缓存第一页数据
491
  if (page === 1) {
492
  allPromptsData = proxyImages(prompts);
493
+ responseData.write(getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
494
  } else {
495
  allPromptsData = [...allPromptsData, ...proxyImages(prompts)];
496
  }
 
564
  renderPromptsFromCache(allPromptsData);
565
  showToast(t('prompt.network_cache'), "warning");
566
  } else {
567
+ renderListLoadError(promptsGrid, err?.name === 'ListCacheChangedError' ? t('feedback.list_changed_retry') : t('prompt.load_failed'), () => loadPrompts(1, false), t, err);
568
  }
569
  }
570
  }
 
631
  const requestGeneration = ++networkRequestGeneration;
632
  try {
633
  isLoadingFromNetwork = true;
634
+ const readResponse = captureListRead(cacheKey);
635
  const res = await api.getPrompts(requestedType, requestedCategory, 1, PAGE_SIZE, requestedSort, {
636
  noCache: true,
637
  timeout: API.BACKGROUND_TIMEOUT,
 
644
  }
645
  return;
646
  }
647
+ const responseData = readResponse(res);
648
+ const prompts = responseData.data;
649
  isLoadingFromNetwork = false;
650
 
651
  if (isEmptyListRefreshWithExistingData(prompts, allPromptsData)) {
652
  console.warn(`🛡️ 提示词 ${requestedType}/${requestedSort} 后台返回空列表,保留当前数据与缓存`);
653
  } else {
654
  // 更新缓存
655
+ responseData.write(getCacheTTL());
656
 
657
  // 对比新旧数据,有变化时重新渲染
658
  if (_promptsDataChanged(allPromptsData, prompts)) {
前端页面/task/任务榜组件.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // 前端页面/task/任务榜组件.js
2
  // ==========================================
3
  // 📝 任务榜组件(任务列表视图)
@@ -20,14 +21,14 @@ import { api, captureRequestSession } from "../core/网络请求API.js";
20
  import { getTaskApplicantCount } from "./任务数据工具.js";
21
  import { proxyImages } from "../core/网络请求API.js";
22
  import { showToast } from "../components/UI交互提示组件.js";
23
- import { readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
24
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
25
  import { t } from "../components/用户体验增强.js";
26
  import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
27
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
28
 
29
  // 缓存配置
30
- const CACHE_KEY_PREFIX = "TasksCache";
31
  const PAGE_SIZE = 20;
32
 
33
  // 缓存当前用户
@@ -263,6 +264,7 @@ export function createTasksView(currentUser, keyword = "") {
263
  requestGeneration = ++networkRequestGeneration;
264
  isLoadingFromNetwork = true;
265
 
 
266
  const res = await api.getTasks(page, PAGE_SIZE, requestedStatus || null, requestedSort);
267
  if (viewGeneration !== listViewGeneration || requestGeneration !== networkRequestGeneration) {
268
  if (requestGeneration === networkRequestGeneration) {
@@ -271,7 +273,8 @@ export function createTasksView(currentUser, keyword = "") {
271
  }
272
  return;
273
  }
274
- const tasks = requireListResponseData(res);
 
275
  if (page === 1) firstPageReady = true;
276
  const total = res.total || 0;
277
  isLoadingFromNetwork = false;
@@ -279,7 +282,7 @@ export function createTasksView(currentUser, keyword = "") {
279
  // 缓存第一页数据
280
  if (page === 1) {
281
  allTasksData = tasks;
282
- writeListCache(cacheKey, tasks, getCacheTTL()); // 持久化到 localStorage,供离线容灾
283
  } else {
284
  allTasksData = [...allTasksData, ...tasks];
285
  }
@@ -345,7 +348,7 @@ export function createTasksView(currentUser, keyword = "") {
345
  showToast(t('task.network_cache'), "warning");
346
  continueSearchPagination();
347
  } else {
348
- renderListLoadError(tasksList, t('task.load_failed'), () => loadTasks(1, false), t, err);
349
  }
350
  }
351
  }
@@ -406,6 +409,7 @@ export function createTasksView(currentUser, keyword = "") {
406
  const requestGeneration = ++networkRequestGeneration;
407
  try {
408
  isLoadingFromNetwork = true;
 
409
  const res = await api.getTasks(1, PAGE_SIZE, requestedStatus || null, requestedSort, {
410
  noCache: true,
411
  timeout: API.BACKGROUND_TIMEOUT,
@@ -418,14 +422,15 @@ export function createTasksView(currentUser, keyword = "") {
418
  }
419
  return;
420
  }
421
- const tasks = requireListResponseData(res);
 
422
  isLoadingFromNetwork = false;
423
 
424
  if (isEmptyListRefreshWithExistingData(tasks, allTasksData)) {
425
  console.warn(`🛡️ 任务榜 ${requestedSort} 后台返回空列表,保留当前数据与缓存`);
426
  } else {
427
  // 更新缓存
428
- writeListCache(cacheKey, tasks, getCacheTTL());
429
 
430
  // 对比新旧数据,有变化时重新渲染
431
  if (_tasksDataChanged(allTasksData, tasks)) {
@@ -498,9 +503,11 @@ export function createTasksView(currentUser, keyword = "") {
498
  loadMore: async (page, pageSize, { isCurrent = () => true } = {}) => {
499
  if (disposed || !isCurrent()) return [];
500
  try {
 
501
  const res = await api.getTasks(page, pageSize, currentStatus || null, currentSort);
502
  if (disposed || !isCurrent()) return [];
503
- const tasks = requireListResponseData(res);
 
504
  if (tasks.length === 0) {
505
  if (searchKeyword && !allTasksData.some(task => _filterTasksByKeyword([task], searchKeyword).length)) {
506
  renderListSearchMessage(tasksList, t('pagination.search_empty'));
 
1
+ import { captureListRead } from "../core/榜单数据来源.js";
2
  // 前端页面/task/任务榜组件.js
3
  // ==========================================
4
  // 📝 任务榜组件(任务列表视图)
 
21
  import { getTaskApplicantCount } from "./任务数据工具.js";
22
  import { proxyImages } from "../core/网络请求API.js";
23
  import { showToast } from "../components/UI交互提示组件.js";
24
+ import { readListCache, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
25
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
26
  import { t } from "../components/用户体验增强.js";
27
  import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
28
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
29
 
30
  // 缓存配置
31
+ const CACHE_KEY_PREFIX = CACHE.PUBLIC_LISTS.tasks.prefix.slice(0, -1);
32
  const PAGE_SIZE = 20;
33
 
34
  // 缓存当前用户
 
264
  requestGeneration = ++networkRequestGeneration;
265
  isLoadingFromNetwork = true;
266
 
267
+ const readResponse = captureListRead(cacheKey);
268
  const res = await api.getTasks(page, PAGE_SIZE, requestedStatus || null, requestedSort);
269
  if (viewGeneration !== listViewGeneration || requestGeneration !== networkRequestGeneration) {
270
  if (requestGeneration === networkRequestGeneration) {
 
273
  }
274
  return;
275
  }
276
+ const responseData = readResponse(res);
277
+ const tasks = responseData.data;
278
  if (page === 1) firstPageReady = true;
279
  const total = res.total || 0;
280
  isLoadingFromNetwork = false;
 
282
  // 缓存第一页数据
283
  if (page === 1) {
284
  allTasksData = tasks;
285
+ responseData.write(getCacheTTL()); // 持久化到 localStorage,供离线容灾
286
  } else {
287
  allTasksData = [...allTasksData, ...tasks];
288
  }
 
348
  showToast(t('task.network_cache'), "warning");
349
  continueSearchPagination();
350
  } else {
351
+ renderListLoadError(tasksList, err?.name === 'ListCacheChangedError' ? t('feedback.list_changed_retry') : t('task.load_failed'), () => loadTasks(1, false), t, err);
352
  }
353
  }
354
  }
 
409
  const requestGeneration = ++networkRequestGeneration;
410
  try {
411
  isLoadingFromNetwork = true;
412
+ const readResponse = captureListRead(cacheKey);
413
  const res = await api.getTasks(1, PAGE_SIZE, requestedStatus || null, requestedSort, {
414
  noCache: true,
415
  timeout: API.BACKGROUND_TIMEOUT,
 
422
  }
423
  return;
424
  }
425
+ const responseData = readResponse(res);
426
+ const tasks = responseData.data;
427
  isLoadingFromNetwork = false;
428
 
429
  if (isEmptyListRefreshWithExistingData(tasks, allTasksData)) {
430
  console.warn(`🛡️ 任务榜 ${requestedSort} 后台返回空列表,保留当前数据与缓存`);
431
  } else {
432
  // 更新缓存
433
+ responseData.write(getCacheTTL());
434
 
435
  // 对比新旧数据,有变化时重新渲染
436
  if (_tasksDataChanged(allTasksData, tasks)) {
 
503
  loadMore: async (page, pageSize, { isCurrent = () => true } = {}) => {
504
  if (disposed || !isCurrent()) return [];
505
  try {
506
+ const readResponse = captureListRead(`${CACHE_KEY_PREFIX}_${currentStatus}_${currentSort}`);
507
  const res = await api.getTasks(page, pageSize, currentStatus || null, currentSort);
508
  if (disposed || !isCurrent()) return [];
509
+ const responseData = readResponse(res);
510
+ const tasks = responseData.data;
511
  if (tasks.length === 0) {
512
  if (searchKeyword && !allTasksData.some(task => _filterTasksByKeyword([task], searchKeyword).length)) {
513
  renderListSearchMessage(tasksList, t('pagination.search_empty'));