ZHIWEI666 commited on
Commit
b2bb548
·
verified ·
1 Parent(s): a0bd9c4

Upload 103 files

Browse files
前端页面/components/用户体验_国际化.js CHANGED
The diff for this file is too large to render. See raw diff
 
前端页面/core/全局配置.js CHANGED
@@ -1,117 +1,117 @@
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,
@@ -121,849 +121,861 @@ export const LOCAL_PERFORMANCE = Object.freeze({
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 SKILL_INSTALL = Object.freeze({
125
- PATH: '/community_hub/skill_install',
126
- PREFLIGHT_TIMEOUT: 30000,
127
- CONTROL_TIMEOUT: 45000,
128
- RESULT_TIMEOUT: 5000,
129
- STREAM_TIMEOUT: 1200000,
130
- RESPONSE_MAX_BYTES: 1024 * 1024,
131
- PATH_MAX_BYTES: 32768,
132
- MAX_DIRECTORIES: 128,
133
- MAX_ITEMS: 1,
134
- MODAL_WIDTH: '640px'
135
- });
136
-
137
- export const CHART_LIFECYCLE = Object.freeze({
138
- WIDTH_WAIT_TIMEOUT: 5000,
139
- RESIZE_DELAY: 300
140
- });
141
-
142
- // 发布视频的本地读取预算,不改变云端上传额度。
143
- export const LAZY_IMAGES = Object.freeze({
144
- ROOT_MARGIN: "200px 0px",
145
- THRESHOLD: 0.01,
146
- MOUNT_TIMEOUT: 30000,
147
- READ_TIMEOUT: 30000
148
- });
149
-
150
- export const PUBLISH_MEDIA_LIMITS = Object.freeze({
151
- MAX_IMAGES: 9,
152
- IMAGE_MAX_EDGE: 1920,
153
- IMAGE_QUALITY: 0.85,
154
- VIDEO_MAX_BYTES: 100 * 1024 * 1024,
155
- VIDEO_MAX_SECONDS: 180,
156
- VIDEO_METADATA_TIMEOUT: 15000
157
- });
158
-
159
- // 上传前处理预算;浏览器解码本身仍由宿主管理。
160
- export const IMAGE_PROCESS = {
161
- HEADER_BYTES: 256 * 1024,
162
- MAX_INPUT_BYTES: 50 * 1024 * 1024,
163
- MAX_PIXELS: 40 * 1024 * 1024,
164
- MAX_SOURCE_EDGE: 16384,
165
- MAX_OUTPUT_EDGE: 4096,
166
- MAX_OUTPUT_BYTES: 5 * 1024 * 1024,
167
- TIMEOUT: 15000,
168
- MIN_QUALITY: 0.3,
169
- QUALITY_STEP: 0.1,
170
- AVATAR_MAX_BYTES: 2 * 1024 * 1024,
171
- AVATAR_SIZE: 512
172
- };
173
-
174
- // ==========================================
175
- // 🔐 管理员配置 (P0安全修复:管理员账号配置化)
176
- // ==========================================
177
- // 注意:前端仅用于UI显示控制,后端从环境变量 ADMIN_ACCOUNTS 读取
178
- // 修改此处需同步修改云端环境变量
179
- export const ADMIN_ACCOUNTS = new Set(["123456", "888888"]);
180
-
181
- /**
182
- * 检查账号是否为管理员
183
- * @param {string} account - 用户账号
184
- * @returns {boolean}
185
- */
186
- export function isAdmin(account) {
187
- return ADMIN_ACCOUNTS.has(account);
188
- }
189
-
190
-
191
- // ==========================================
192
- // 💾 缓存配置
193
- // ==========================================
194
- const RESOURCE_LIST_PREFIXES = [
195
- "ListCache_tools_", "ListCache_skills_", "ListCache_apps_", "ListCache_recommends_"
196
- ];
197
- export const CACHE = {
198
- // 缓存键前缀
199
- PREFIX: "ComfyRanking_",
200
-
201
- // 默认缓存时间(毫秒)
202
- DEFAULT_TTL: 2 * 60 * 60 * 1000, // 2小时
203
-
204
- // 各类数据缓存时间
205
- TTL: {
206
- LIST_DATA: 5 * 60 * 1000, // 列表数据:5分钟
207
- DETAIL_DATA: 3 * 60 * 1000, // 详情数据:3分钟
208
- USER_PROFILE: 2 * 60 * 1000, // 用户资料:2分钟
209
- WALLET: 30 * 1000, // 钱包数据:30秒
210
- CREATORS: 5 * 60 * 1000, // 创作者列表:5分钟
211
- CHAT_HISTORY: 60 * 1000 // 聊天记录:1分钟
212
- },
213
-
214
- // 内存缓存最大条目数
215
- MAX_MEMORY_ITEMS: 100,
216
-
217
- // 本插件 localStorage 持久缓存的估算容量上限(不影响用户/会话等其它键)
218
- MAX_STORAGE_BYTES: 2 * 1024 * 1024,
219
-
220
- // localStorage 键名(旧版兼容)
221
- SESSION_REMEMBER_TTL: 30 * 24 * 60 * 60 * 1000,
222
- INSTALLED_VERSION_UNCONFIRMED: "__installed_unconfirmed__", // 安装成功但没有实际版本证明
223
- SESSION_TEMP_TTL: 24 * 60 * 60 * 1000,
224
- LEGACY_KEYS: {
225
- USER: "ComfyCommunity_User",
226
- TOKEN: "ComfyCommunity_Token",
227
- LIST_CACHE: "ComfyCommunity_ListCache",
228
- PROFILE_CACHE: "ComfyCommunity_ProfileCache",
229
- CHAT_HISTORY: "ComfyCommunity_ChatHistory"
230
- },
231
-
232
- // 本地背景存储键(不上传云端)
233
- LOCAL_KEYS: {
234
- BANNER_CONFIG: "ComfyRanking_BannerConfig", // 广告配置持久缓存
235
- CREATORS_SESSION: "ComfyRanking_CreatorsCache", // 沿用创作者临时缓存键
236
- SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
237
- PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
238
- ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
239
- PROFILE_LIST_PREFIX: "ProfileList_", // 兼容旧个人列表业务键
240
- INSTALLED_VERSION_PREFIX: "ComfyCommunity_LocalVer_", // 沿用既有安装版本键
241
- ITEM_OWNERSHIP_PREFIX: "ItemOwnership_", // 缓存管理器业务键前缀
242
- // 🚀 新增:私信和通知本地缓存
243
- NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
244
- CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
245
- CHAT_HISTORY_PREFIX: "ComfyRanking_ChatHistory_", // 聊天记录前缀
246
- NOTIF_CLEAR_TIME: "ComfyRanking_NotifClearTime", // 通知清空时间戳
247
- CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
248
- },
249
-
250
- REFUND_CACHE_PREFIXES: [
251
- "api_/api/items", "api_/api/creators", "api_/api/wallet",
252
- ...RESOURCE_LIST_PREFIXES
253
- ],
254
- RESOURCE_LIST_PREFIXES,
255
-
256
- // 🚀 新增:消息轮询配置
257
- MESSAGE_POLL: {
258
- INTERVAL: 30 * 1000, // 轮询间隔:30秒
259
- ENABLED: true // 是否启用定时检查
260
- }
261
- };
262
-
263
-
264
- // ==========================================
265
- // 📜 分页配置
266
- // ==========================================
267
- export const PAGINATION = {
268
- // 工具/应用每页数量
269
- ITEMS_PAGE_SIZE: 20,
270
-
271
- // 创作者每页数量
272
- CREATORS_PAGE_SIZE: 12,
273
- SIDEBAR_ITEM_FETCH_LIMIT: 200,
274
- SIDEBAR_CREATOR_FETCH_LIMIT: 500,
275
- SIDEBAR_CREATOR_SEARCH_LIMIT: 50,
276
- PROFILE_USERS_PAGE_SIZE: 20,
277
- PROFILE_RESOURCE_FETCH_LIMIT: 200,
278
-
279
- // 评论每页数量
280
- COMMENTS_PAGE_SIZE: 10,
281
-
282
- // 消息每页数量
283
- MESSAGES_PAGE_SIZE: 20,
284
- CHAT_MESSAGES_PAGE_SIZE: 50,
285
-
286
- // 滚动加载触发阈值(距底部像素)
287
- SCROLL_THRESHOLD: 300,
288
-
289
- // 防抖延迟(毫秒)
290
- DEBOUNCE_DELAY: 100
291
- };
292
-
293
-
294
- // ==========================================
295
- // 💰 打赏等级配置
296
- // ==========================================
297
- export const TIP_LEVELS = {
298
- // 每颗星星需要的积分
299
- POINTS_PER_STAR: 100,
300
-
301
- // 每个月亮需要的星星数
302
- STARS_PER_MOON: 5,
303
-
304
- // 每个太阳需要的月亮数
305
- MOONS_PER_SUN: 5,
306
-
307
- // 最高等级太阳数
308
- MAX_SUNS: 9,
309
-
310
- // 最高积分(9太阳 = 22500积分)
311
- MAX_POINTS: 22500,
312
-
313
- // 等级图标
314
- ICONS: {
315
- STAR: "⭐",
316
- MOON: "🌙",
317
- SUN: "☀️",
318
- CROWN: "👑"
319
- },
320
-
321
- // 等级称号
322
- TITLES: {
323
- MAX_LEVEL: "👑 至尊赞助者",
324
- HIGH_SUN: "超级赞助者",
325
- LOW_SUN: "黄金赞助者",
326
- HIGH_MOON: "白银赞助者",
327
- LOW_MOON: "青铜赞助者",
328
- HIGH_STAR: "热心粉丝",
329
- DEFAULT: "支持者"
330
- }
331
- };
332
-
333
-
334
- // ==========================================
335
- // 🎨 UI 配置
336
- // ==========================================
337
- export const UI = {
338
- CHAT_MAX_MSG_LENGTH: 1000,
339
- CHAT_TIME_GAP_SECONDS: 5 * 60,
340
- MAX_DISPUTE_EVIDENCE: 6,
341
- RECHARGE_POLL_INTERVAL: 3000,
342
- RECHARGE_POLL_LIMIT: 100,
343
- RECHARGE_QR_MAX_BYTES: 2048,
344
- PROFILE_BANNER_CACHE_BYTES: 512 * 1024,
345
- // 与云端公开版本接口的缺省值一致;用于网络不可用时的本地展示。
346
- DEFAULT_PROJECT_VERSION: Object.freeze({ stage: "beta", major: 2, minor: 0, patch: 0 }),
347
- ABOUT: {
348
- AUTHOR_URL: "https://space.bilibili.com/2114638644",
349
- AUTHOR_BADGE_URL: "https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white"
350
- },
351
- // Toast 消息配置
352
- TOAST: {
353
- OFFSET: 50,
354
- GAP: 10,
355
- DURATION: 3000, // 显示时长(毫秒)
356
- MAX_VISIBLE: 3, // 最大同时显示数量
357
- POSITION: "top-center" // 位置
358
- },
359
-
360
- // 动画配置
361
- ANIMATION: {
362
- DURATION_FAST: 150,
363
- DURATION_NORMAL: 300,
364
- DURATION_SLOW: 500,
365
- EASING: "cubic-bezier(0.25, 0.8, 0.25, 1)"
366
- },
367
-
368
- // 颜色主题
369
- COLORS: {
370
- PRIMARY: "#2196F3",
371
- SUCCESS: "#4CAF50",
372
- WARNING: "#FF9800",
373
- ERROR: "#F44336",
374
- INFO: "#2196F3",
375
- TIP: "#E91E63",
376
-
377
- // 深色主题背景
378
- BG_DARK: "var(--comfy-menu-bg)",
379
- BG_CARD: "var(--comfy-input-bg)",
380
- BG_INPUT: "var(--comfy-input-bg)",
381
-
382
- // 边框
383
- BORDER: "#444",
384
- BORDER_LIGHT: "#555"
385
- },
386
-
387
- // 图片占位图(🚀 关键修复:全部改为内联 SVG,避免外部网络请求)
388
- PLACEHOLDERS: {
389
- // 头像占位图(👤 图标)
390
- 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`,
391
- // 封面占位图(🖼️ 图标)
392
- 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`,
393
- // 小头像占位图(用于列表)
394
- 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`,
395
- // 加载占位图
396
- 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"
397
- }
398
- };
399
-
400
- // 🚀 导出 PLACEHOLDERS 便于直接使用
401
- export const PLACEHOLDERS = UI.PLACEHOLDERS;
402
-
403
-
404
- // ==========================================
405
- // 📝 表单验证配置
406
- // ==========================================
407
- export const VALIDATION = {
408
- // 账号规则
409
- ACCOUNT: {
410
- MIN_LENGTH: 6,
411
- MAX_LENGTH: 20,
412
- PATTERN: /^[a-zA-Z0-9_]{6,20}$/,
413
- MESSAGE: "账号仅支持6-20位字母、数字、下划线"
414
- },
415
-
416
- // 密码规则
417
- PASSWORD: {
418
- MIN_LENGTH: 6,
419
- PATTERN: /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{6,}$/,
420
- MESSAGE: "密码至少6位,支持字母、数字和常见特殊字符"
421
- },
422
-
423
- // 昵称规则
424
- NAME: {
425
- MIN_LENGTH: 1,
426
- MAX_LENGTH: 20,
427
- MESSAGE: "昵称长度1-20个字符"
428
- },
429
-
430
- // 个人介绍规则
431
- INTRO: {
432
- MAX_LENGTH: 100,
433
- MESSAGE: "个人介绍不能超过100字"
434
- },
435
-
436
- // 验证码规则
437
- VERIFY_CODE: {
438
- LENGTH: 6,
439
- PATTERN: /^\d{6}$/,
440
- EXPIRE_SECONDS: 600,
441
- MESSAGE: "请输入6位数字验证码"
442
- }
443
- };
444
-
445
-
446
- // ==========================================
447
- // 🔧 功能开关
448
- // ==========================================
449
- export const FEATURES = {
450
- // 是否启用调试模式
451
- DEBUG: false,
452
-
453
- // 是否启用性能监控
454
- PERFORMANCE_MONITOR: false,
455
-
456
- // 是否启用离线模式提示
457
- OFFLINE_DETECTION: true,
458
-
459
- // 是否启用图片懒加载
460
- LAZY_LOAD_IMAGES: true,
461
-
462
- // 是否启用分页加载
463
- PAGINATION_ENABLED: true,
464
-
465
- // 是否启用请求缓存
466
- REQUEST_CACHE: true,
467
-
468
- // 是否启用骨架屏
469
- SKELETON_LOADING: true
470
- };
471
-
472
-
473
- // ==========================================
474
- // 🌐 运行环境开关
475
- // ==========================================
476
- // 移动端网页版运行时开关:Web 引导页(云端Space代码/移动端网页/index.html)
477
- // 在模块求值前置位 window.__RANKING_WEB_MODE__ = true;ComfyUI 环境不置位,恒为 false,
478
- // 本地侧边栏行为零变化。所有「仅本地可用」功能的门控均以此为准
479
- export const IS_WEB_MODE = typeof window !== "undefined" && window.__RANKING_WEB_MODE__ === true;
480
-
481
-
482
- // ==========================================
483
- // 📊 统计埋点配置(预留)
484
- // ==========================================
485
- export const ANALYTICS = {
486
- // 是否启用统计
487
- ENABLED: false,
488
-
489
- // 统计服务地址
490
- ENDPOINT: "",
491
-
492
- // 采样率(0-1)
493
- SAMPLE_RATE: 1.0
494
- };
495
-
496
-
497
- // ==========================================
498
- // 🔗 外部链接
499
- // ==========================================
500
- export const LINKS = {
501
- // GitHub 仓库
502
- GITHUB_REPO: "https://github.com/a63976659/ComfyUI-Ranking",
503
-
504
- // 文档地址
505
- DOCS: "",
506
-
507
- // 反馈地址
508
- FEEDBACK: ""
509
- };
510
-
511
-
512
- // ==========================================
513
- // 🛠️ 辅助函数
514
- // ==========================================
515
-
516
- /**
517
- * 获取完整的 API 地址
518
- * @param {string} endpoint - API 端点
519
- * @returns {string}
520
- */
521
- export function getApiUrl(endpoint) {
522
- return `${API.BASE_URL}${endpoint}`;
523
- }
524
-
525
- /**
526
- * 获取当前登录用户账号
527
- * @returns {string|null}
528
- */
529
- export function getCurrentAccount() {
530
- try {
531
- const userStr = localStorage.getItem(CACHE.LEGACY_KEYS.USER) || sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
532
- if (userStr) {
533
- const userData = JSON.parse(userStr);
534
- return userData?.user?.account || null;
535
- }
536
- } catch (e) {
537
- console.warn("获取当前账号失败。");
538
- }
539
- return null;
540
- }
541
-
542
- /**
543
- * 获取账号区分的界面背景图存储键
544
- * @param {string} [account] - 账号,不传则自动获取当前登录账号
545
- * @returns {string}
546
- */
547
- export function getBackgroundKey(account) {
548
- const acc = account || getCurrentAccount();
549
- return acc
550
- ? `${CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND}_${acc}`
551
- : CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND;
552
- }
553
-
554
- /**
555
- * 获取账号区分的个人资料卡背景缓存键
556
- * @param {string} [account] - 账号,不传则自动获取当前登录账��
557
- * @returns {string}
558
- */
559
- export function getBannerCacheKey(account) {
560
- const acc = account || getCurrentAccount();
561
- return acc
562
- ? `${CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE}_${acc}`
563
- : CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE;
564
- }
565
-
566
- /**
567
- * 获取缓存键(带前缀)
568
- * @param {string} key - 缓存键
569
- * @returns {string}
570
- */
571
- export function getCacheKey(key) {
572
- return `${CACHE.PREFIX}${key}`;
573
- }
574
-
575
- /**
576
- * 检查功能是否启用
577
- * @param {string} feature - 功能名
578
- * @returns {boolean}
579
- */
580
- export function isFeatureEnabled(feature) {
581
- return FEATURES[feature] === true;
582
- }
583
-
584
- /**
585
- * 获取打赏等级配置
586
- * @returns {Object}
587
- */
588
- export function getTipLevelConfig() {
589
- return { ...TIP_LEVELS };
590
- }
591
-
592
-
593
- // ==========================================
594
- // 🚀 SWR 头像缓存工具(跨组件共享)
595
- // ==========================================
596
- // 实现原理:
597
- // 1. 从 localStorage 读取缓存,0延迟渲染
598
- // 2. 后台静默校对云端数据
599
- // 3. 有更新时自动刷新 DOM
600
- // 缓存键:ComfyCommunity_ProfileCache_{account}
601
-
602
- const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
603
- const _pendingProfileRequests = new Map(); // 每账号共享请求与全部订阅者
604
- const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
605
- let nextAvatarRenderId = 0;
606
-
607
- // ==========================================
608
- // 🔒 HTML 转义(底层公共工具)
609
- // ==========================================
610
- // 放在本文件(零依赖的底层模块)而非 components/互动工具函数.js,是因为
611
- // UI交互提示组件.js 与 打赏等级工具.js 都需要它,而互动工具函数.js 反向依赖这两者
612
- // (导入 showToast、re-export renderTipBoardHTML),从那里导入会形成循环依赖。
613
- // 互动工具函数.js 仍以 re-export 方式对外暴露同名函数,既有调用方无需改动。
614
-
615
- /**
616
- * HTML转义(统一版:各组件局部副本已归一到此)
617
- * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
618
- * @returns {string} 转义后的字符串
619
- */
620
- export function escapeHtml(str) {
621
- if (str === null || str === undefined || str === "") return "";
622
- return String(str).replace(/&/g, "&amp;")
623
- .replace(/</g, "&lt;")
624
- .replace(/>/g, "&gt;")
625
- .replace(/"/g, "&quot;")
626
- .replace(/'/g, "&#039;");
627
- }
628
-
629
- /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
630
- export function normalizeProfileImageUrl(value, unwrapProxy = false, serviceBaseUrl = API.BASE_URL) {
631
- if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
632
- let source = value;
633
- let layers = 0;
634
- while (source.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX)) {
635
- if (++layers > 10) return '';
636
- let encoded = source.slice(API.LOCAL_IMAGE_PROXY_PREFIX.length);
637
- if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
638
- try { source = decodeURIComponent(encoded); } catch (_) { return ''; }
639
- if (!source || source.trim() !== source || /[\u0000-\u001f\u007f]/.test(source)) return '';
640
- }
641
- if (!layers && /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,.+$/i.test(source)) return value;
642
- if (!layers && /^blob:https?:\/\//i.test(source)) {
643
- try {
644
- const blob = new URL(source);
645
- if (typeof location !== 'undefined' && location.origin && blob.origin !== location.origin) return '';
646
- return blob.origin === 'null' ? '' : value;
647
- } catch (_) { return ''; }
648
- }
649
- if (!/^https?:\/\//i.test(source) || source.includes('\\')) return '';
650
- try {
651
- if (value.length > API.PROFILE_MEDIA_URL_MAX_BYTES
652
- || encodeURIComponent(value).replace(/%[0-9a-f]{2}/gi, 'x').length > API.PROFILE_MEDIA_URL_MAX_BYTES) return '';
653
- const url = new URL(source);
654
- const authority = source.match(/^https?:\/\/([^/?#]*)/i)?.[1];
655
- if (!authority || authority.includes('@') || authority.includes('%') || authority.endsWith(':') || url.port === '0') return '';
656
- if (url.username || url.password) return '';
657
- const host = url.hostname.toLowerCase().replace(/\.$/, '');
658
- if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
659
- && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
660
- const base = new URL(serviceBaseUrl);
661
- const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
662
- if (host === baseHost) {
663
- const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
664
- const sourcePath = source.slice(source.indexOf('://') + 3 + authority.length).split(/[?#]/, 1)[0] || '/';
665
- if (url.protocol !== base.protocol || url.port !== base.port || sourcePath !== expectedPath) return '';
666
- return unwrapProxy ? source : value;
667
- }
668
- if (!host.startsWith('[') && !/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
669
- const labels = host.split('.');
670
- if (host.length > 253 || labels.length < 2 || !labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
671
- || /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(labels.at(-1))) return '';
672
- }
673
- if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return '';
674
- if (host.startsWith('[')) {
675
- if (!/^\[[23][0-9a-f]{0,3}:/i.test(host)) return '';
676
- const pieces = host.slice(1, -1).split('::');
677
- const left = pieces[0] ? pieces[0].split(':').map(part => parseInt(part, 16)) : [];
678
- const right = pieces[1] ? pieces[1].split(':').map(part => parseInt(part, 16)) : [];
679
- const groups = pieces.length === 1 ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right];
680
- const [first, second, third] = groups;
681
- if (first === 0x2002 || (first === 0x3fff && second < 0x1000) || (first === 0x2001 && second === 0xdb8)) return '';
682
- if (first === 0x2001 && second < 0x200) {
683
- const exception = (second === 1 && groups.slice(2, 7).every(part => part === 0) && [1, 2].includes(groups[7]))
684
- || second === 3 || (second === 4 && third === 0x112) || (second >= 0x20 && second <= 0x3f);
685
- if (!exception) return '';
686
- }
687
- } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
688
- const rawHost = authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase();
689
- if (rawHost !== host) return '';
690
- const [a,b,c,last] = host.split('.').map(Number);
691
- if (a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127)
692
- || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
693
- || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19))
694
- || (a === 192 && b === 0 && ((c === 0 && ![9, 10].includes(last)) || c === 2))
695
- || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)) return '';
696
- }
697
- return unwrapProxy ? source : value;
698
- } catch (_) { return ''; }
699
- }
700
-
701
- /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
702
- function _mapAvatarField(profile) {
703
- if (profile && profile.avatarDataUrl && !profile.avatar) {
704
- profile.avatar = profile.avatarDataUrl;
705
- }
706
- }
707
-
708
- const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "intro", "gender", "age",
709
- "country", "region", "created_at", "followers", "following", "privacy", "is_admin", "bannerUrl",
710
- "receivedLikes", "receivedFavorites", "receivedUses"];
711
- function _publicProfile(profile) {
712
- return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
713
- .map(key => [key, ['avatar', 'avatarDataUrl', 'bannerUrl'].includes(key)
714
- ? normalizeProfileImageUrl(profile[key]) : profile[key]]));
715
- }
716
-
717
- function _validProfile(profile) {
718
- return !!profile && typeof profile === 'object' && !Array.isArray(profile)
719
- && ['name', 'avatar', 'avatarDataUrl'].every(field => profile[field] == null || typeof profile[field] === 'string');
720
- }
721
-
722
- /**
723
- * 🚀 获取缓存的用户资料(同步)
724
- * @param {string} account - 用户账号
725
- * @returns {Object|null} 缓存的用户资料
726
- */
727
- export function getCachedProfile(account) {
728
- if (!account) return null;
729
- try {
730
- const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
731
- if (!cached) return null;
732
- const profile = JSON.parse(cached);
733
- if (!_validProfile(profile)) return null;
734
- const publicProfile = _publicProfile(profile);
735
- _mapAvatarField(publicProfile);
736
- // 旧资料缓存可能含联系方式;读取返回过滤副本并尝试迁移,不因迁移失败隐藏公开内容。
737
- if (JSON.stringify(publicProfile) !== cached) {
738
- try { localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(publicProfile)); } catch (_) {}
739
- }
740
- return publicProfile;
741
- } catch {
742
- return null;
743
- }
744
- }
745
-
746
- /**
747
- * 🚀 保存用户资料到缓存
748
- * @param {string} account - 用户账号
749
- * @param {Object} profile - 用户资料
750
- */
751
- export function setCachedProfile(account, profile) {
752
- if (!account || !_validProfile(profile)) return;
753
- try {
754
- localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(_publicProfile(profile)));
755
- } catch {}
756
- }
757
-
758
- /**
759
- * 🚀 SWR 模式获取用户资料(先返回缓存,后台校对)
760
- * @param {string} account - 用户账号
761
- * @param {Function} apiFn - API 调用函数 (account) => Promise<{data: profile}>
762
- * @param {Function} onUpdate - 数据更新时的回调 (profile) => void
763
- * @returns {Object|null} 初始缓存数据
764
- */
765
- export function getProfileWithSWR(account, apiFn, onUpdate) {
766
- if (!account) return null;
767
-
768
- // 第一轨:同步返回缓存
769
- const cached = getCachedProfile(account);
770
-
771
- // 第二轨:同账号只取一次,但每个视图都订阅结果。
772
- let entry = _pendingProfileRequests.get(account);
773
- if (!entry && typeof apiFn === 'function') {
774
- entry = { listeners: new Set(), timer: null };
775
- _pendingProfileRequests.set(account, entry);
776
- const release = () => {
777
- if (_pendingProfileRequests.get(account) === entry) _pendingProfileRequests.delete(account);
778
- clearTimeout(entry.timer);
779
- entry.listeners.clear();
780
- };
781
- entry.timer = setTimeout(release, PENDING_TIMEOUT);
782
- Promise.resolve().then(() => apiFn(account)).then(res => {
783
- // 超时后的旧结果不能覆盖新请求,也不能重新写入旧资料。
784
- if (_pendingProfileRequests.get(account) !== entry) return;
785
- if (res?.status && res.status !== 'success') return;
786
- const data = res && Object.hasOwn(res, 'data') ? res.data : res;
787
- if (!_validProfile(data)) return;
788
- const profile = _publicProfile(data);
789
- _mapAvatarField(profile);
790
- setCachedProfile(account, profile);
791
- for (const listener of Array.from(entry.listeners)) {
792
- try { listener(profile); } catch { /* 单个视图异常不阻断其它订阅者 */ }
793
- }
794
- }).catch(() => {}).finally(release);
795
- }
796
- if (entry && typeof onUpdate === 'function') entry.listeners.add(onUpdate);
797
-
798
- return cached;
799
- }
800
-
801
- /**
802
- * 🚀 渲染带 SWR 缓存的头像+名称 HTML
803
- * @param {Object} options
804
- * @param {string} options.account - 用户账号
805
- * @param {string} options.fallbackAvatar - 备用头像 URL(从原始数据中获取)
806
- * @param {string} options.fallbackName - 备用名称
807
- * @param {number} options.avatarSize - 头像尺寸 (px)
808
- * @param {Function} options.apiFn - API 调用函数
809
- * @param {string} options.containerId - 唯一容器 ID(用于 DOM 更新)
810
- * @returns {string} HTML 字符串
811
- */
812
- export function renderAvatarWithSWR(options) {
813
- const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
814
- const renderId = String(++nextAvatarRenderId);
815
- const size = typeof avatarSize === 'number' || typeof avatarSize === 'string' ? Number(avatarSize) : NaN;
816
- const safeSize = Number.isFinite(size) && size > 0 ? size : 24;
817
-
818
- // 从缓存获取
819
- const cached = getCachedProfile(account);
820
- const avatar = cached?.avatar || cached?.avatarDataUrl || normalizeProfileImageUrl(fallbackAvatar);
821
- const name = cached?.name || fallbackName || account || '';
822
-
823
- // 后台静默校对并更新 DOM
824
- if (apiFn && containerId) {
825
- setTimeout(() => {
826
- const container = document.getElementById(containerId);
827
- if (!container || container.dataset.profileRender !== renderId) return;
828
- getProfileWithSWR(account, apiFn, (profile) => {
829
- if (!container.isConnected || document.getElementById(containerId) !== container
830
- || container.dataset.profileRender !== renderId) return;
831
-
832
- const avatarImg = container.querySelector('.swr-avatar');
833
- const nameSpan = container.querySelector('.swr-name');
834
-
835
- if (avatarImg && profile.avatar) {
836
- if (avatarImg.tagName === 'IMG') avatarImg.src = profile.avatar;
837
- else {
838
- const image = document.createElement('img');
839
- image.className = 'swr-avatar';
840
- image.alt = '';
841
- image.style.cssText = avatarImg.style.cssText;
842
- image.style.objectFit = 'cover';
843
- image.src = profile.avatar;
844
- avatarImg.replaceWith(image);
845
- }
846
- } else if (avatarImg && avatarImg.tagName !== 'IMG' && profile.name) {
847
- avatarImg.textContent = Array.from(profile.name)[0]?.toUpperCase() || 'U';
848
- }
849
- if (nameSpan && profile.name) {
850
- nameSpan.textContent = profile.name;
851
- }
852
- });
853
- }, 0);
854
- }
855
-
856
- // 生成初始 HTML(无头像时显示首字母背景)
857
- // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
858
- // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
859
- const initial = Array.from(String(name || account || 'U'))[0].toUpperCase();
860
- const avatarHtml = avatar
861
- ? `<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;">`
862
- : `<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>`;
863
-
864
- 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>`;
865
- }
866
-
867
-
868
- // ==========================================
869
- // 📦 导出默认配置对象
870
- // ==========================================
871
- export default {
872
- IMAGE_PROCESS,
873
- API,
874
- CACHE,
875
- PAGINATION,
876
- TIP_LEVELS,
877
- UI,
878
- VALIDATION,
879
- FEATURES,
880
- IS_WEB_MODE,
881
- ANALYTICS,
882
- LINKS,
883
- getApiUrl,
884
- getCacheKey,
885
- isFeatureEnabled,
886
- getCurrentAccount,
887
- getBackgroundKey,
888
- getBannerCacheKey,
889
- getCachedProfile,
890
- setCachedProfile,
891
- getProfileWithSWR,
892
- renderAvatarWithSWR
893
- };
894
-
895
-
896
- // 提示词定价输入边界,与云端 PromptCreate/PromptUpdate 对应。
897
- export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
898
-
899
- // 任务定价边界,与云端 task_publication/task_terms 对应。
900
- 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]) });
901
-
902
-
903
- export const TYPE_RULES = {
904
- tool: {
905
- recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
906
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
907
- },
908
- skill: {
909
- recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
910
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
911
- },
912
- app: {
913
- recommendForm: false, resType: { fixed: 'json' },
914
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
915
- },
916
- recommend: {
917
- recommendForm: true, resType: { lockedByForm: true },
918
- privateRepoWhenLink: false, price: false, refund: false, originalRequired: false,
919
- formToResType: { recommend_tool: 'link', recommend_skill: 'link', recommend_app: 'json', recommend_link: 'netdisk' }
920
- }
921
- };
922
-
923
- // 🔧 设置项 localStorage Key
924
- const SETTINGS_KEY = "ComfyCommunity_Settings";
925
-
926
- // 🔧 默认设置值
927
- const DEFAULT_SETTINGS = {
 
 
 
 
 
 
 
 
 
 
 
 
928
  enableKeyboardShortcuts: false,
929
- enableLocalPerformanceStats: false,
930
- showCreatorBanner: true, // 创作者卡片显示背景图
931
- enableAnimations: true, // ✨ 榜单切换动画
932
- enableSoundEffects: true, // 🔊 动画音效
933
- language: 'zh-CN', // 🌐 界面语言
934
- cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时
935
- // 🚀 后续可扩展更多设置项
936
- // compactMode: false, // 紧凑模式
937
- // darkMode: true, // 深色模式
938
- };
939
-
940
- /**
941
- * 📦 获取当前设置
942
- */
943
- export function getSettings(options = {}) {
944
- try {
945
- const saved = localStorage.getItem(SETTINGS_KEY);
946
- if (saved) {
947
- const value = JSON.parse(saved);
948
- if (options.strict && (!value || typeof value !== 'object' || Array.isArray(value))) throw new Error('Invalid settings');
949
- return { ...DEFAULT_SETTINGS, ...value };
950
- }
951
- } catch (e) { if (options.strict) throw e; }
952
- return { ...DEFAULT_SETTINGS };
953
- }
954
-
955
- /**
956
- * 💾 保存设置
957
- */
958
- export function areKeyboardShortcutsEnabled() {
959
- return getSettings().enableKeyboardShortcuts === true;
960
- }
961
-
962
- export function saveSettings(settings) {
963
- try {
964
- localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
965
- // 🔔 广播设置变更事件,让其他组件响应
966
- window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
967
- return true;
968
- } catch (e) { return false; }
969
- }
 
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,
 
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 SKILL_INSTALL = Object.freeze({
125
+ PATH: '/community_hub/skill_install',
126
+ PREFLIGHT_TIMEOUT: 30000,
127
+ CONTROL_TIMEOUT: 45000,
128
+ RESULT_TIMEOUT: 5000,
129
+ STREAM_TIMEOUT: 1200000,
130
+ RESPONSE_MAX_BYTES: 1024 * 1024,
131
+ PATH_MAX_BYTES: 32768,
132
+ MAX_DIRECTORIES: 128,
133
+ MAX_ITEMS: 1,
134
+ MODAL_WIDTH: '640px'
135
+ });
136
+
137
+ export const CHART_LIFECYCLE = Object.freeze({
138
+ WIDTH_WAIT_TIMEOUT: 5000,
139
+ RESIZE_DELAY: 300
140
+ });
141
+
142
+ // 发布视频的本地读取预算,不改变云端上传额度。
143
+ export const LAZY_IMAGES = Object.freeze({
144
+ ROOT_MARGIN: "200px 0px",
145
+ THRESHOLD: 0.01,
146
+ MOUNT_TIMEOUT: 30000,
147
+ READ_TIMEOUT: 30000
148
+ });
149
+
150
+ export const PUBLISH_MEDIA_LIMITS = Object.freeze({
151
+ MAX_IMAGES: 9,
152
+ IMAGE_MAX_EDGE: 1920,
153
+ IMAGE_QUALITY: 0.85,
154
+ VIDEO_MAX_BYTES: 100 * 1024 * 1024,
155
+ VIDEO_MAX_SECONDS: 180,
156
+ VIDEO_METADATA_TIMEOUT: 15000
157
+ });
158
+
159
+ // 上传前处理预算;浏览器解码本身仍由宿主管理。
160
+ export const IMAGE_PROCESS = {
161
+ HEADER_BYTES: 256 * 1024,
162
+ MAX_INPUT_BYTES: 50 * 1024 * 1024,
163
+ MAX_PIXELS: 40 * 1024 * 1024,
164
+ MAX_SOURCE_EDGE: 16384,
165
+ MAX_OUTPUT_EDGE: 4096,
166
+ MAX_OUTPUT_BYTES: 5 * 1024 * 1024,
167
+ TIMEOUT: 15000,
168
+ MIN_QUALITY: 0.3,
169
+ QUALITY_STEP: 0.1,
170
+ AVATAR_MAX_BYTES: 2 * 1024 * 1024,
171
+ AVATAR_SIZE: 512
172
+ };
173
+
174
+ // ==========================================
175
+ // 🔐 管理员配置 (P0安全修复:管理员账号配置化)
176
+ // ==========================================
177
+ // 注意:前端仅用于UI显示控制,后端从环境变量 ADMIN_ACCOUNTS 读取
178
+ // 修改此处需同步修改云端环境变量
179
+ export const ADMIN_ACCOUNTS = new Set(["123456", "888888"]);
180
+
181
+ /**
182
+ * 检查账号是否为管理员
183
+ * @param {string} account - 用户账号
184
+ * @returns {boolean}
185
+ */
186
+ export function isAdmin(account) {
187
+ return ADMIN_ACCOUNTS.has(account);
188
+ }
189
+
190
+
191
+ // ==========================================
192
+ // 💾 缓存配置
193
+ // ==========================================
194
+ const RESOURCE_LIST_PREFIXES = [
195
+ "ListCache_tools_", "ListCache_skills_", "ListCache_apps_", "ListCache_recommends_"
196
+ ];
197
+ export const CACHE = {
198
+ // 缓存键前缀
199
+ PREFIX: "ComfyRanking_",
200
+
201
+ // 默认缓存时���(毫秒)
202
+ DEFAULT_TTL: 2 * 60 * 60 * 1000, // 2小时
203
+
204
+ // 各类数据缓存时间
205
+ TTL: {
206
+ LIST_DATA: 5 * 60 * 1000, // 列表数据:5分钟
207
+ DETAIL_DATA: 3 * 60 * 1000, // 详情数据:3分钟
208
+ USER_PROFILE: 2 * 60 * 1000, // 用户资料:2分钟
209
+ WALLET: 30 * 1000, // 钱包数据:30秒
210
+ CREATORS: 5 * 60 * 1000, // 创作者列表:5分钟
211
+ CHAT_HISTORY: 60 * 1000 // 聊天记录:1分钟
212
+ },
213
+
214
+ // 内存缓存最大条目数
215
+ MAX_MEMORY_ITEMS: 100,
216
+
217
+ // 本插件 localStorage 持久缓存的估算容量上限(不影响用户/会话等其它键)
218
+ MAX_STORAGE_BYTES: 2 * 1024 * 1024,
219
+
220
+ // localStorage 键名(旧版兼容)
221
+ SESSION_REMEMBER_TTL: 30 * 24 * 60 * 60 * 1000,
222
+ INSTALLED_VERSION_UNCONFIRMED: "__installed_unconfirmed__", // 安装成功但没有实际版本证明
223
+ SESSION_TEMP_TTL: 24 * 60 * 60 * 1000,
224
+ LEGACY_KEYS: {
225
+ USER: "ComfyCommunity_User",
226
+ TOKEN: "ComfyCommunity_Token",
227
+ LIST_CACHE: "ComfyCommunity_ListCache",
228
+ PROFILE_CACHE: "ComfyCommunity_ProfileCache",
229
+ CHAT_HISTORY: "ComfyCommunity_ChatHistory"
230
+ },
231
+
232
+ // 本地背景存储键(不上传云端)
233
+ LOCAL_KEYS: {
234
+ BANNER_CONFIG: "ComfyRanking_BannerConfig", // 广告配置持久缓存
235
+ CREATORS_SESSION: "ComfyRanking_CreatorsCache", // 沿用创作者临时缓存键
236
+ SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
237
+ PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
238
+ ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
239
+ PROFILE_LIST_PREFIX: "ProfileList_", // 兼容旧个人列表业务键
240
+ INSTALLED_VERSION_PREFIX: "ComfyCommunity_LocalVer_", // 沿用既有安装版本键
241
+ ITEM_OWNERSHIP_PREFIX: "ItemOwnership_", // 缓存管理器业务键前缀
242
+ // 🚀 新增:私信和通知本地缓存
243
+ NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
244
+ CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
245
+ CHAT_HISTORY_PREFIX: "ComfyRanking_ChatHistory_", // 聊天记录前缀
246
+ NOTIF_CLEAR_TIME: "ComfyRanking_NotifClearTime", // 通知清空时间戳
247
+ CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
248
+ },
249
+
250
+ REFUND_CACHE_PREFIXES: [
251
+ "api_/api/items", "api_/api/creators", "api_/api/wallet",
252
+ ...RESOURCE_LIST_PREFIXES
253
+ ],
254
+ RESOURCE_LIST_PREFIXES,
255
+
256
+ // 🚀 新增:消息轮询配置
257
+ MESSAGE_POLL: {
258
+ INTERVAL: 30 * 1000, // 轮询间隔:30秒
259
+ ENABLED: true // 是否启用定时检查
260
+ }
261
+ };
262
+
263
+
264
+ // ==========================================
265
+ // 📜 分页配置
266
+ // ==========================================
267
+ export const PAGINATION = {
268
+ // 工具/应用每页数量
269
+ ITEMS_PAGE_SIZE: 20,
270
+
271
+ // 创作者每页数量
272
+ CREATORS_PAGE_SIZE: 12,
273
+ SIDEBAR_ITEM_FETCH_LIMIT: 200,
274
+ SIDEBAR_CREATOR_FETCH_LIMIT: 500,
275
+ SIDEBAR_CREATOR_SEARCH_LIMIT: 50,
276
+ PROFILE_USERS_PAGE_SIZE: 20,
277
+ PROFILE_RESOURCE_FETCH_LIMIT: 200,
278
+
279
+ // 评论每页数量
280
+ COMMENTS_PAGE_SIZE: 10,
281
+
282
+ // 消息每页数量
283
+ MESSAGES_PAGE_SIZE: 20,
284
+ CHAT_MESSAGES_PAGE_SIZE: 50,
285
+
286
+ // 滚动加载触发阈值(距底部像素)
287
+ SCROLL_THRESHOLD: 300,
288
+
289
+ // 防抖延迟(毫秒)
290
+ DEBOUNCE_DELAY: 100
291
+ };
292
+
293
+
294
+ // ==========================================
295
+ // 💰 打赏等级配置
296
+ // ==========================================
297
+ export const TIP_LEVELS = {
298
+ // 每颗星星需要的积分
299
+ POINTS_PER_STAR: 100,
300
+
301
+ // 每个月亮需要的星星数
302
+ STARS_PER_MOON: 5,
303
+
304
+ // 每个太阳需要的月亮数
305
+ MOONS_PER_SUN: 5,
306
+
307
+ // 最高等级太阳数
308
+ MAX_SUNS: 9,
309
+
310
+ // 最高积分(9太阳 = 22500积分)
311
+ MAX_POINTS: 22500,
312
+
313
+ // 等级图标
314
+ ICONS: {
315
+ STAR: "⭐",
316
+ MOON: "🌙",
317
+ SUN: "☀️",
318
+ CROWN: "👑"
319
+ },
320
+
321
+ // 等级称号
322
+ TITLES: {
323
+ MAX_LEVEL: "👑 至尊赞助者",
324
+ HIGH_SUN: "超级赞助者",
325
+ LOW_SUN: "黄金赞助者",
326
+ HIGH_MOON: "白银赞助者",
327
+ LOW_MOON: "青铜赞助者",
328
+ HIGH_STAR: "热心粉丝",
329
+ DEFAULT: "支持者"
330
+ }
331
+ };
332
+
333
+
334
+ // ==========================================
335
+ // 🎨 UI 配置
336
+ // ==========================================
337
+ export const UI = {
338
+ INSTALL_SOURCE: Object.freeze({
339
+ MAX_URL_BYTES: 4096,
340
+ CASE_INSENSITIVE_HOSTS: Object.freeze(['github.com'])
341
+ }),
342
+ INSTALL_TRANSFER: Object.freeze({
343
+ MAX_BYTES: 2 * 1024 * 1024 * 1024,
344
+ MAX_RATE: Number.MAX_SAFE_INTEGER,
345
+ BASE: 1024,
346
+ STAGES: Object.freeze(['downloading', 'download_done']),
347
+ UNIT_KEYS: Object.freeze(['install.transfer_b', 'install.transfer_kib', 'install.transfer_mib', 'install.transfer_gib'])
348
+ }),
349
+
350
+ CHAT_MAX_MSG_LENGTH: 1000,
351
+ CHAT_TIME_GAP_SECONDS: 5 * 60,
352
+ MAX_DISPUTE_EVIDENCE: 6,
353
+ RECHARGE_POLL_INTERVAL: 3000,
354
+ RECHARGE_POLL_LIMIT: 100,
355
+ RECHARGE_QR_MAX_BYTES: 2048,
356
+ PROFILE_BANNER_CACHE_BYTES: 512 * 1024,
357
+ // 与云端公开版本接口的缺省值一致;用于网络不可用时的本地展示。
358
+ DEFAULT_PROJECT_VERSION: Object.freeze({ stage: "beta", major: 2, minor: 0, patch: 0 }),
359
+ ABOUT: {
360
+ AUTHOR_URL: "https://space.bilibili.com/2114638644",
361
+ AUTHOR_BADGE_URL: "https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white"
362
+ },
363
+ // Toast 消息配置
364
+ TOAST: {
365
+ OFFSET: 50,
366
+ GAP: 10,
367
+ DURATION: 3000, // 显示时长(毫秒)
368
+ MAX_VISIBLE: 3, // 最大同时显示数量
369
+ POSITION: "top-center" // 位置
370
+ },
371
+
372
+ // 动画配置
373
+ ANIMATION: {
374
+ DURATION_FAST: 150,
375
+ DURATION_NORMAL: 300,
376
+ DURATION_SLOW: 500,
377
+ EASING: "cubic-bezier(0.25, 0.8, 0.25, 1)"
378
+ },
379
+
380
+ // 颜色主题
381
+ COLORS: {
382
+ PRIMARY: "#2196F3",
383
+ SUCCESS: "#4CAF50",
384
+ WARNING: "#FF9800",
385
+ ERROR: "#F44336",
386
+ INFO: "#2196F3",
387
+ TIP: "#E91E63",
388
+
389
+ // 深色主题背景
390
+ BG_DARK: "var(--comfy-menu-bg)",
391
+ BG_CARD: "var(--comfy-input-bg)",
392
+ BG_INPUT: "var(--comfy-input-bg)",
393
+
394
+ // 边框
395
+ BORDER: "#444",
396
+ BORDER_LIGHT: "#555"
397
+ },
398
+
399
+ // 图片占位图(🚀 关键修复:全部改为内联 SVG,避免外部网络请求)
400
+ PLACEHOLDERS: {
401
+ // 头像占位图(👤 图标)
402
+ 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`,
403
+ // 封面占位图(🖼️ 图标)
404
+ 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`,
405
+ // 小头像占位图(用于列表)
406
+ 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`,
407
+ // 加载占位图
408
+ 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"
409
+ }
410
+ };
411
+
412
+ // 🚀 导出 PLACEHOLDERS 便于直接使用
413
+ export const PLACEHOLDERS = UI.PLACEHOLDERS;
414
+
415
+
416
+ // ==========================================
417
+ // 📝 表单验证配置
418
+ // ==========================================
419
+ export const VALIDATION = {
420
+ // 账号规则
421
+ ACCOUNT: {
422
+ MIN_LENGTH: 6,
423
+ MAX_LENGTH: 20,
424
+ PATTERN: /^[a-zA-Z0-9_]{6,20}$/,
425
+ MESSAGE: "账号仅支持6-20位字母、数字、下划线"
426
+ },
427
+
428
+ // 密码规则
429
+ PASSWORD: {
430
+ MIN_LENGTH: 6,
431
+ PATTERN: /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{6,}$/,
432
+ MESSAGE: "密码至少6位,支持字母、数字和常见特殊字符"
433
+ },
434
+
435
+ // 昵称规则
436
+ NAME: {
437
+ MIN_LENGTH: 1,
438
+ MAX_LENGTH: 20,
439
+ MESSAGE: "昵称长度1-20个字符"
440
+ },
441
+
442
+ // 个人介绍规则
443
+ INTRO: {
444
+ MAX_LENGTH: 100,
445
+ MESSAGE: "个人介绍不能超过100字"
446
+ },
447
+
448
+ // 验证码规则
449
+ VERIFY_CODE: {
450
+ LENGTH: 6,
451
+ PATTERN: /^\d{6}$/,
452
+ EXPIRE_SECONDS: 600,
453
+ MESSAGE: "请输入6位数字验证码"
454
+ }
455
+ };
456
+
457
+
458
+ // ==========================================
459
+ // 🔧 功能开关
460
+ // ==========================================
461
+ export const FEATURES = {
462
+ // 是否启用调试模式
463
+ DEBUG: false,
464
+
465
+ // 是否启用性能监控
466
+ PERFORMANCE_MONITOR: false,
467
+
468
+ // 是否启用离线模式提示
469
+ OFFLINE_DETECTION: true,
470
+
471
+ // 是否启用图片懒加载
472
+ LAZY_LOAD_IMAGES: true,
473
+
474
+ // 是否启用分页加载
475
+ PAGINATION_ENABLED: true,
476
+
477
+ // 是否启用请求缓存
478
+ REQUEST_CACHE: true,
479
+
480
+ // 是否启用骨架屏
481
+ SKELETON_LOADING: true
482
+ };
483
+
484
+
485
+ // ==========================================
486
+ // 🌐 运行环境开关
487
+ // ==========================================
488
+ // 移动端网页版运行时开关:Web 引导页(云端Space代码/移动端网页/index.html)
489
+ // 在模块求值前置位 window.__RANKING_WEB_MODE__ = true;ComfyUI 环境不置位,恒为 false,
490
+ // 本地侧边栏行为零变化。所有「仅本地可用」功能的门控均以此为准
491
+ export const IS_WEB_MODE = typeof window !== "undefined" && window.__RANKING_WEB_MODE__ === true;
492
+
493
+
494
+ // ==========================================
495
+ // 📊 统计埋点配置(预留)
496
+ // ==========================================
497
+ export const ANALYTICS = {
498
+ // 是否启用统计
499
+ ENABLED: false,
500
+
501
+ // 统计服务地址
502
+ ENDPOINT: "",
503
+
504
+ // 采样率(0-1)
505
+ SAMPLE_RATE: 1.0
506
+ };
507
+
508
+
509
+ // ==========================================
510
+ // 🔗 外部链接
511
+ // ==========================================
512
+ export const LINKS = {
513
+ // GitHub 仓库
514
+ GITHUB_REPO: "https://github.com/a63976659/ComfyUI-Ranking",
515
+
516
+ // 文档地址
517
+ DOCS: "",
518
+
519
+ // 反馈地址
520
+ FEEDBACK: ""
521
+ };
522
+
523
+
524
+ // ==========================================
525
+ // 🛠️ 辅助函数
526
+ // ==========================================
527
+
528
+ /**
529
+ * 获取完整的 API 地址
530
+ * @param {string} endpoint - API 端点
531
+ * @returns {string}
532
+ */
533
+ export function getApiUrl(endpoint) {
534
+ return `${API.BASE_URL}${endpoint}`;
535
+ }
536
+
537
+ /**
538
+ * 获取当前登录用户账号
539
+ * @returns {string|null}
540
+ */
541
+ export function getCurrentAccount() {
542
+ try {
543
+ const userStr = localStorage.getItem(CACHE.LEGACY_KEYS.USER) || sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
544
+ if (userStr) {
545
+ const userData = JSON.parse(userStr);
546
+ return userData?.user?.account || null;
547
+ }
548
+ } catch (e) {
549
+ console.warn("获取当前账号失败。");
550
+ }
551
+ return null;
552
+ }
553
+
554
+ /**
555
+ * 获取账号区分的界面背景图存储键
556
+ * @param {string} [account] - 账号,不传则自动获取当前登录账号
557
+ * @returns {string}
558
+ */
559
+ export function getBackgroundKey(account) {
560
+ const acc = account || getCurrentAccount();
561
+ return acc
562
+ ? `${CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND}_${acc}`
563
+ : CACHE.LOCAL_KEYS.SIDEBAR_BACKGROUND;
564
+ }
565
+
566
+ /**
567
+ * 获取账号区分的个人资料卡背景缓存键
568
+ * @param {string} [account] - 账号,不传则自动获取当前登录账号
569
+ * @returns {string}
570
+ */
571
+ export function getBannerCacheKey(account) {
572
+ const acc = account || getCurrentAccount();
573
+ return acc
574
+ ? `${CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE}_${acc}`
575
+ : CACHE.LOCAL_KEYS.PROFILE_BANNER_CACHE;
576
+ }
577
+
578
+ /**
579
+ * 获取缓存键(带前缀)
580
+ * @param {string} key - 缓存键
581
+ * @returns {string}
582
+ */
583
+ export function getCacheKey(key) {
584
+ return `${CACHE.PREFIX}${key}`;
585
+ }
586
+
587
+ /**
588
+ * 检查功能是否启用
589
+ * @param {string} feature - 功能名
590
+ * @returns {boolean}
591
+ */
592
+ export function isFeatureEnabled(feature) {
593
+ return FEATURES[feature] === true;
594
+ }
595
+
596
+ /**
597
+ * 获取打赏等级配置
598
+ * @returns {Object}
599
+ */
600
+ export function getTipLevelConfig() {
601
+ return { ...TIP_LEVELS };
602
+ }
603
+
604
+
605
+ // ==========================================
606
+ // 🚀 SWR 头像缓存工具(跨组件共享)
607
+ // ==========================================
608
+ // 实现原理:
609
+ // 1. 从 localStorage 读取缓存,0延迟渲染
610
+ // 2. 后台静默校对云端数据
611
+ // 3. 有更新时自动刷新 DOM
612
+ // 缓存键:ComfyCommunity_ProfileCache_{account}
613
+
614
+ const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
615
+ const _pendingProfileRequests = new Map(); // 每账号共享请求与全部订阅者
616
+ const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
617
+ let nextAvatarRenderId = 0;
618
+
619
+ // ==========================================
620
+ // 🔒 HTML 转义(底层公共工具)
621
+ // ==========================================
622
+ // 放在本文件(零依赖的底层模块)而非 components/互动工具函数.js,是因为
623
+ // UI交互提示组件.js 与 打赏等级工具.js 都需要它,而互动工具函数.js 反向依赖这两者
624
+ // (导入 showToast、re-export renderTipBoardHTML),从那里导入会形成循环依赖。
625
+ // 互动工具函数.js 仍以 re-export 方式对外暴露同名函数,既有调用方无需改动。
626
+
627
+ /**
628
+ * HTML转义(统一版:各组件局部副本已归一到此)
629
+ * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
630
+ * @returns {string} 转义后的字符串
631
+ */
632
+ export function escapeHtml(str) {
633
+ if (str === null || str === undefined || str === "") return "";
634
+ return String(str).replace(/&/g, "&amp;")
635
+ .replace(/</g, "&lt;")
636
+ .replace(/>/g, "&gt;")
637
+ .replace(/"/g, "&quot;")
638
+ .replace(/'/g, "&#039;");
639
+ }
640
+
641
+ /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
642
+ export function normalizeProfileImageUrl(value, unwrapProxy = false, serviceBaseUrl = API.BASE_URL) {
643
+ if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
644
+ let source = value;
645
+ let layers = 0;
646
+ while (source.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX)) {
647
+ if (++layers > 10) return '';
648
+ let encoded = source.slice(API.LOCAL_IMAGE_PROXY_PREFIX.length);
649
+ if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
650
+ try { source = decodeURIComponent(encoded); } catch (_) { return ''; }
651
+ if (!source || source.trim() !== source || /[\u0000-\u001f\u007f]/.test(source)) return '';
652
+ }
653
+ if (!layers && /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,.+$/i.test(source)) return value;
654
+ if (!layers && /^blob:https?:\/\//i.test(source)) {
655
+ try {
656
+ const blob = new URL(source);
657
+ if (typeof location !== 'undefined' && location.origin && blob.origin !== location.origin) return '';
658
+ return blob.origin === 'null' ? '' : value;
659
+ } catch (_) { return ''; }
660
+ }
661
+ if (!/^https?:\/\//i.test(source) || source.includes('\\')) return '';
662
+ try {
663
+ if (value.length > API.PROFILE_MEDIA_URL_MAX_BYTES
664
+ || encodeURIComponent(value).replace(/%[0-9a-f]{2}/gi, 'x').length > API.PROFILE_MEDIA_URL_MAX_BYTES) return '';
665
+ const url = new URL(source);
666
+ const authority = source.match(/^https?:\/\/([^/?#]*)/i)?.[1];
667
+ if (!authority || authority.includes('@') || authority.includes('%') || authority.endsWith(':') || url.port === '0') return '';
668
+ if (url.username || url.password) return '';
669
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
670
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
671
+ && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
672
+ const base = new URL(serviceBaseUrl);
673
+ const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
674
+ if (host === baseHost) {
675
+ const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
676
+ const sourcePath = source.slice(source.indexOf('://') + 3 + authority.length).split(/[?#]/, 1)[0] || '/';
677
+ if (url.protocol !== base.protocol || url.port !== base.port || sourcePath !== expectedPath) return '';
678
+ return unwrapProxy ? source : value;
679
+ }
680
+ if (!host.startsWith('[') && !/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
681
+ const labels = host.split('.');
682
+ if (host.length > 253 || labels.length < 2 || !labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
683
+ || /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(labels.at(-1))) return '';
684
+ }
685
+ if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return '';
686
+ if (host.startsWith('[')) {
687
+ if (!/^\[[23][0-9a-f]{0,3}:/i.test(host)) return '';
688
+ const pieces = host.slice(1, -1).split('::');
689
+ const left = pieces[0] ? pieces[0].split(':').map(part => parseInt(part, 16)) : [];
690
+ const right = pieces[1] ? pieces[1].split(':').map(part => parseInt(part, 16)) : [];
691
+ const groups = pieces.length === 1 ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right];
692
+ const [first, second, third] = groups;
693
+ if (first === 0x2002 || (first === 0x3fff && second < 0x1000) || (first === 0x2001 && second === 0xdb8)) return '';
694
+ if (first === 0x2001 && second < 0x200) {
695
+ const exception = (second === 1 && groups.slice(2, 7).every(part => part === 0) && [1, 2].includes(groups[7]))
696
+ || second === 3 || (second === 4 && third === 0x112) || (second >= 0x20 && second <= 0x3f);
697
+ if (!exception) return '';
698
+ }
699
+ } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
700
+ const rawHost = authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase();
701
+ if (rawHost !== host) return '';
702
+ const [a,b,c,last] = host.split('.').map(Number);
703
+ if (a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127)
704
+ || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
705
+ || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19))
706
+ || (a === 192 && b === 0 && ((c === 0 && ![9, 10].includes(last)) || c === 2))
707
+ || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)) return '';
708
+ }
709
+ return unwrapProxy ? source : value;
710
+ } catch (_) { return ''; }
711
+ }
712
+
713
+ /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
714
+ function _mapAvatarField(profile) {
715
+ if (profile && profile.avatarDataUrl && !profile.avatar) {
716
+ profile.avatar = profile.avatarDataUrl;
717
+ }
718
+ }
719
+
720
+ const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "intro", "gender", "age",
721
+ "country", "region", "created_at", "followers", "following", "privacy", "is_admin", "bannerUrl",
722
+ "receivedLikes", "receivedFavorites", "receivedUses"];
723
+ function _publicProfile(profile) {
724
+ return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
725
+ .map(key => [key, ['avatar', 'avatarDataUrl', 'bannerUrl'].includes(key)
726
+ ? normalizeProfileImageUrl(profile[key]) : profile[key]]));
727
+ }
728
+
729
+ function _validProfile(profile) {
730
+ return !!profile && typeof profile === 'object' && !Array.isArray(profile)
731
+ && ['name', 'avatar', 'avatarDataUrl'].every(field => profile[field] == null || typeof profile[field] === 'string');
732
+ }
733
+
734
+ /**
735
+ * 🚀 获取缓存的用户资料(同步)
736
+ * @param {string} account - 用户账号
737
+ * @returns {Object|null} 缓存的用户资料
738
+ */
739
+ export function getCachedProfile(account) {
740
+ if (!account) return null;
741
+ try {
742
+ const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
743
+ if (!cached) return null;
744
+ const profile = JSON.parse(cached);
745
+ if (!_validProfile(profile)) return null;
746
+ const publicProfile = _publicProfile(profile);
747
+ _mapAvatarField(publicProfile);
748
+ // 旧资料缓存可能含联系方式;读取返回过滤副本并尝试迁移,不因迁移失败隐藏公开内容。
749
+ if (JSON.stringify(publicProfile) !== cached) {
750
+ try { localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(publicProfile)); } catch (_) {}
751
+ }
752
+ return publicProfile;
753
+ } catch {
754
+ return null;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * 🚀 保存用户资料到缓存
760
+ * @param {string} account - 用户账号
761
+ * @param {Object} profile - 用户资料
762
+ */
763
+ export function setCachedProfile(account, profile) {
764
+ if (!account || !_validProfile(profile)) return;
765
+ try {
766
+ localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(_publicProfile(profile)));
767
+ } catch {}
768
+ }
769
+
770
+ /**
771
+ * 🚀 SWR 模式获取用户资料(先返回缓存,后台校对)
772
+ * @param {string} account - 用户账号
773
+ * @param {Function} apiFn - API 调用函数 (account) => Promise<{data: profile}>
774
+ * @param {Function} onUpdate - 数据更新时的回调 (profile) => void
775
+ * @returns {Object|null} 初始缓存数据
776
+ */
777
+ export function getProfileWithSWR(account, apiFn, onUpdate) {
778
+ if (!account) return null;
779
+
780
+ // 第一轨:同步返回缓存
781
+ const cached = getCachedProfile(account);
782
+
783
+ // 第二轨:同账号只取一次,但每个视图都订阅结果。
784
+ let entry = _pendingProfileRequests.get(account);
785
+ if (!entry && typeof apiFn === 'function') {
786
+ entry = { listeners: new Set(), timer: null };
787
+ _pendingProfileRequests.set(account, entry);
788
+ const release = () => {
789
+ if (_pendingProfileRequests.get(account) === entry) _pendingProfileRequests.delete(account);
790
+ clearTimeout(entry.timer);
791
+ entry.listeners.clear();
792
+ };
793
+ entry.timer = setTimeout(release, PENDING_TIMEOUT);
794
+ Promise.resolve().then(() => apiFn(account)).then(res => {
795
+ // 超时后的旧结果不能覆盖新请求,也不能重新写入旧资料。
796
+ if (_pendingProfileRequests.get(account) !== entry) return;
797
+ if (res?.status && res.status !== 'success') return;
798
+ const data = res && Object.hasOwn(res, 'data') ? res.data : res;
799
+ if (!_validProfile(data)) return;
800
+ const profile = _publicProfile(data);
801
+ _mapAvatarField(profile);
802
+ setCachedProfile(account, profile);
803
+ for (const listener of Array.from(entry.listeners)) {
804
+ try { listener(profile); } catch { /* 单个视图异常不阻断其它订阅者 */ }
805
+ }
806
+ }).catch(() => {}).finally(release);
807
+ }
808
+ if (entry && typeof onUpdate === 'function') entry.listeners.add(onUpdate);
809
+
810
+ return cached;
811
+ }
812
+
813
+ /**
814
+ * 🚀 渲染带 SWR 缓存的头像+名称 HTML
815
+ * @param {Object} options
816
+ * @param {string} options.account - 用户账号
817
+ * @param {string} options.fallbackAvatar - 备用头像 URL(从原始数据中获取)
818
+ * @param {string} options.fallbackName - 备用名称
819
+ * @param {number} options.avatarSize - 头像尺寸 (px)
820
+ * @param {Function} options.apiFn - API 调用函数
821
+ * @param {string} options.containerId - 唯一容器 ID(用于 DOM 更新)
822
+ * @returns {string} HTML 字符串
823
+ */
824
+ export function renderAvatarWithSWR(options) {
825
+ const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
826
+ const renderId = String(++nextAvatarRenderId);
827
+ const size = typeof avatarSize === 'number' || typeof avatarSize === 'string' ? Number(avatarSize) : NaN;
828
+ const safeSize = Number.isFinite(size) && size > 0 ? size : 24;
829
+
830
+ // 从缓存获取
831
+ const cached = getCachedProfile(account);
832
+ const avatar = cached?.avatar || cached?.avatarDataUrl || normalizeProfileImageUrl(fallbackAvatar);
833
+ const name = cached?.name || fallbackName || account || '';
834
+
835
+ // 后台静默校对并更新 DOM
836
+ if (apiFn && containerId) {
837
+ setTimeout(() => {
838
+ const container = document.getElementById(containerId);
839
+ if (!container || container.dataset.profileRender !== renderId) return;
840
+ getProfileWithSWR(account, apiFn, (profile) => {
841
+ if (!container.isConnected || document.getElementById(containerId) !== container
842
+ || container.dataset.profileRender !== renderId) return;
843
+
844
+ const avatarImg = container.querySelector('.swr-avatar');
845
+ const nameSpan = container.querySelector('.swr-name');
846
+
847
+ if (avatarImg && profile.avatar) {
848
+ if (avatarImg.tagName === 'IMG') avatarImg.src = profile.avatar;
849
+ else {
850
+ const image = document.createElement('img');
851
+ image.className = 'swr-avatar';
852
+ image.alt = '';
853
+ image.style.cssText = avatarImg.style.cssText;
854
+ image.style.objectFit = 'cover';
855
+ image.src = profile.avatar;
856
+ avatarImg.replaceWith(image);
857
+ }
858
+ } else if (avatarImg && avatarImg.tagName !== 'IMG' && profile.name) {
859
+ avatarImg.textContent = Array.from(profile.name)[0]?.toUpperCase() || 'U';
860
+ }
861
+ if (nameSpan && profile.name) {
862
+ nameSpan.textContent = profile.name;
863
+ }
864
+ });
865
+ }, 0);
866
+ }
867
+
868
+ // 生成初始 HTML(无头像时显示首字母背景)
869
+ // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
870
+ // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
871
+ const initial = Array.from(String(name || account || 'U'))[0].toUpperCase();
872
+ const avatarHtml = avatar
873
+ ? `<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;">`
874
+ : `<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>`;
875
+
876
+ 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>`;
877
+ }
878
+
879
+
880
+ // ==========================================
881
+ // 📦 导出默认配置对象
882
+ // ==========================================
883
+ export default {
884
+ IMAGE_PROCESS,
885
+ API,
886
+ CACHE,
887
+ PAGINATION,
888
+ TIP_LEVELS,
889
+ UI,
890
+ VALIDATION,
891
+ FEATURES,
892
+ IS_WEB_MODE,
893
+ ANALYTICS,
894
+ LINKS,
895
+ getApiUrl,
896
+ getCacheKey,
897
+ isFeatureEnabled,
898
+ getCurrentAccount,
899
+ getBackgroundKey,
900
+ getBannerCacheKey,
901
+ getCachedProfile,
902
+ setCachedProfile,
903
+ getProfileWithSWR,
904
+ renderAvatarWithSWR
905
+ };
906
+
907
+
908
+ // 提示词定价输入边界,与云端 PromptCreate/PromptUpdate 对应。
909
+ export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
910
+
911
+ // 任务定价边界,与云端 task_publication/task_terms 对应。
912
+ 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]) });
913
+
914
+
915
+ export const TYPE_RULES = {
916
+ tool: {
917
+ recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
918
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
919
+ },
920
+ skill: {
921
+ recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
922
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
923
+ },
924
+ app: {
925
+ recommendForm: false, resType: { fixed: 'json' },
926
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
927
+ },
928
+ recommend: {
929
+ recommendForm: true, resType: { lockedByForm: true },
930
+ privateRepoWhenLink: false, price: false, refund: false, originalRequired: false,
931
+ formToResType: { recommend_tool: 'link', recommend_skill: 'link', recommend_app: 'json', recommend_link: 'netdisk' }
932
+ }
933
+ };
934
+
935
+ // 🔧 设置项 localStorage Key
936
+ const SETTINGS_KEY = "ComfyCommunity_Settings";
937
+
938
+ // 🔧 默认设置值
939
+ const DEFAULT_SETTINGS = {
940
  enableKeyboardShortcuts: false,
941
+ enableLocalPerformanceStats: false,
942
+ showCreatorBanner: true, // 创作者卡片显示背景图
943
+ enableAnimations: true, // ✨ 榜单切换动画
944
+ enableSoundEffects: true, // 🔊 动画音效
945
+ language: 'zh-CN', // 🌐 界面语言
946
+ cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时
947
+ // 🚀 后续可扩展更多设置项
948
+ // compactMode: false, // 紧凑模式
949
+ // darkMode: true, // 深色模式
950
+ };
951
+
952
+ /**
953
+ * 📦 获取当前设置
954
+ */
955
+ export function getSettings(options = {}) {
956
+ try {
957
+ const saved = localStorage.getItem(SETTINGS_KEY);
958
+ if (saved) {
959
+ const value = JSON.parse(saved);
960
+ if (options.strict && (!value || typeof value !== 'object' || Array.isArray(value))) throw new Error('Invalid settings');
961
+ return { ...DEFAULT_SETTINGS, ...value };
962
+ }
963
+ } catch (e) { if (options.strict) throw e; }
964
+ return { ...DEFAULT_SETTINGS };
965
+ }
966
+
967
+ /**
968
+ * 💾 保存设置
969
+ */
970
+ export function areKeyboardShortcutsEnabled() {
971
+ return getSettings().enableKeyboardShortcuts === true;
972
+ }
973
+
974
+ export function saveSettings(settings) {
975
+ try {
976
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
977
+ // 🔔 广播设置变更事件,让其他组件响应
978
+ window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
979
+ return true;
980
+ } catch (e) { return false; }
981
+ }
前端页面/market/Skill安装流程.js CHANGED
@@ -1,214 +1,218 @@
1
- import { SKILL_INSTALL, escapeHtml } from '../core/全局配置.js';
2
- import { readLocalJSON } from '../core/本机请求读取.js';
3
- import { requestSSE } from '../core/网络请求_基础设施.js';
4
- import { t } from '../components/用户体验_国际化.js';
5
- import { globalModal } from '../components/全局弹窗管理器.js';
6
-
7
- const object = value => value && typeof value === 'object' && !Array.isArray(value);
8
- const pathText = value => typeof value === 'string' && value.length > 0
9
- && !/[\u0000-\u001f\u007f]/u.test(value)
10
- && new TextEncoder().encode(value).length <= SKILL_INSTALL.PATH_MAX_BYTES;
11
- const planId = value => typeof value === 'string' && /^[a-f0-9]{32}$/.test(value);
12
- const versionId = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
13
- const count = value => Number.isSafeInteger(value) && value >= 0;
14
-
15
- function failure(code) {
16
- const error = new Error('skill_operation_failed');
17
- error.code = code;
18
- return error;
19
- }
20
-
21
- export function skillErrorMessage(error) {
22
- const errorKeys = {
23
- local_update_required: 'skill.local_update_required',
24
- invalid_target: 'install.error_invalid_target',
25
- invalid_repository: 'install.error_invalid_repository',
26
- invalid_skill_package: 'skill.invalid_package',
27
- invalid_plan: 'skill.plan_invalid',
28
- plan_expired: 'skill.plan_invalid',
29
- plan_owner_mismatch: 'skill.identity_changed',
30
- auth_failed: 'skill.identity_changed',
31
- source_changed: 'skill.source_changed',
32
- target_changed: 'skill.target_changed',
33
- target_conflict: 'skill.target_changed',
34
- recovery_conflict: 'skill.target_changed',
35
- source_unavailable: 'skill.source_unavailable',
36
- invalid_metadata: 'skill.source_unavailable',
37
- directory_too_large: 'skill.directory_too_large',
38
- package_too_large: 'install.error_package_too_large',
39
- insufficient_disk_space: 'install.error_disk_space',
40
- permission_denied: 'install.error_permission_denied',
41
- install_queue_full: 'install.error_queue_full',
42
- install_queue_timeout: 'install.error_queue_timeout',
43
- };
44
- const errorKey = Object.hasOwn(errorKeys, error?.code) ? errorKeys[error.code] : 'install.error_generic';
45
- return t(errorKey);
46
- }
47
-
48
- async function control(action, body, signal, timeout = SKILL_INSTALL.CONTROL_TIMEOUT) {
49
- const controller = new AbortController();
50
- const abort = () => controller.abort();
51
- signal?.addEventListener('abort', abort, { once: true });
52
- if (signal?.aborted) controller.abort();
53
- const timer = setTimeout(abort, timeout);
54
- try {
55
- if (controller.signal.aborted) throw failure('operation_cancelled');
56
- const response = await fetch(SKILL_INSTALL.PATH, {
57
- method: 'POST', headers: { 'Content-Type': 'application/json' },
58
- body: JSON.stringify({ ...body, action }), cache: 'no-store', signal: controller.signal,
59
- });
60
- if (response.status === 404 || response.status === 405) throw failure('local_update_required');
61
- const value = await readLocalJSON(response, controller, SKILL_INSTALL.RESPONSE_MAX_BYTES);
62
- if (!response.ok || !object(value) || value.status !== 'success' || !(value.installed === false
63
- || action === 'status' && value.phase === 'complete' && value.installed === true)
64
- || Object.hasOwn(value, 'error') || Object.hasOwn(value, 'error_code')) {
65
- throw failure(object(value) && typeof value.error_code === 'string' ? value.error_code : 'invalid_metadata');
66
- }
67
- return value;
68
- } finally {
69
- clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.abort();
70
- }
71
- }
72
-
73
- function browseResult(value) {
74
- return value.phase === 'browse' && pathText(value.path) && pathText(value.parent)
75
- && typeof value.selectable === 'boolean' && typeof value.truncated === 'boolean'
76
- && Array.isArray(value.directories) && value.directories.length <= SKILL_INSTALL.MAX_DIRECTORIES
77
- && value.directories.every(row => object(row) && pathText(row.name) && pathText(row.path));
78
- }
79
-
80
- export function chooseSkillDirectory(scope) {
81
- if (!scope.isCurrent()) return Promise.resolve(null);
82
- return new Promise(resolve => {
83
- const content = document.createElement('div'); content.className = 'skill-directory';
84
- const hint = document.createElement('p'); hint.textContent = t('skill.directory_hint');
85
- const label = document.createElement('label'); label.className = 'skill-directory__label';
86
- const title = document.createElement('span'); title.textContent = t('skill.directory_path');
87
- const input = document.createElement('input'); input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false;
88
- label.append(title, input);
89
- const actions = document.createElement('div'); actions.className = 'skill-directory__actions';
90
- const button = (label, callback) => {
91
- const node = document.createElement('button'); node.type = 'button'; node.textContent = label;
92
- node.addEventListener('click', callback); return node;
93
- };
94
- const notice = document.createElement('p'); notice.setAttribute('role', 'status'); notice.setAttribute('aria-live', 'polite');
95
- const list = document.createElement('ul'); list.className = 'skill-directory__list'; list.setAttribute('aria-label', t('skill.directory_folders'));
96
- let closed = false, generation = 0, selected = null, controller, modal;
97
- let releaseScope = () => {};
98
- const current = () => !closed && scope.isCurrent();
99
- const finish = value => {
100
- if (closed) return;
101
- closed = true; generation++; controller?.abort(); releaseScope();
102
- resolve(value); if (modal) globalModal.closeModal(modal);
103
- };
104
- content._cleanup = () => finish(null);
105
- const open = button(t('skill.directory_open'), () => browse(input.value));
106
- const parent = button(t('skill.directory_parent'), () => selected && browse(selected.parent));
107
- const select = button(t('skill.directory_select'), () => {
108
- if (current() && selected?.selectable && input.value === selected.path) finish(selected.path);
109
- });
110
- select.disabled = parent.disabled = true;
111
- const cancel = button(t('common.cancel'), () => finish(null));
112
- actions.append(open, parent, select, cancel);
113
- input.addEventListener('input', () => {
114
- generation++; controller?.abort(); selected = null; select.disabled = parent.disabled = true;
115
- content.removeAttribute('aria-busy'); list.replaceChildren(); notice.textContent = t('skill.directory_verify');
116
- });
117
- async function browse(path) {
118
- if (!current()) return;
119
- const own = ++generation; controller?.abort(); controller = new AbortController();
120
- selected = null; select.disabled = parent.disabled = true;
121
- content.setAttribute('aria-busy', 'true'); notice.textContent = t('common.loading'); list.replaceChildren();
122
- try {
123
- const value = await control('browse', path === undefined ? {} : { path }, controller.signal);
124
- if (!current() || own !== generation) return;
125
- if (!browseResult(value)) throw failure('invalid_metadata');
126
- selected = value; input.value = value.path;
127
- select.disabled = !value.selectable; parent.disabled = value.parent === value.path;
128
- for (const row of value.directories) {
129
- const item = document.createElement('li');
130
- const folder = document.createElement('button'); folder.type = 'button'; folder.textContent = row.name;
131
- folder.addEventListener('click', () => { if (current() && own === generation) browse(row.path); });
132
- item.append(folder); list.append(item);
133
- }
134
- notice.textContent = t(value.truncated ? 'skill.directory_truncated' : 'skill.directory_ready');
135
- } catch (error) {
136
- if (current() && own === generation) notice.textContent = skillErrorMessage(error);
137
- } finally {
138
- if (current() && own === generation) content.removeAttribute('aria-busy');
139
- }
140
- }
141
- content.append(hint, label, actions, notice, list);
142
- modal = globalModal.openModal(t('skill.directory_title'), content, { width: SKILL_INSTALL.MODAL_WIDTH });
143
- releaseScope = scope.addCleanup(() => finish(null));
144
- if (!current()) { finish(null); return; }
145
- input.focus(); browse();
146
- });
147
- }
148
-
149
- function confirmed(result, version) {
150
- return object(result) && result.status === 'success' && result.phase === 'complete'
151
- && result.installed === true && (!Object.hasOwn(result, 'outcomeUnknown') || result.outcomeUnknown === false)
152
- && result.installed_version === version
153
- && !Object.hasOwn(result, 'error') && !Object.hasOwn(result, 'error_code');
154
- }
155
-
156
- function validReady(result, plan, target) {
157
- return object(result) && result.status === 'success' && result.phase === 'ready'
158
- && result.installed === false && (!Object.hasOwn(result, 'outcomeUnknown') || result.outcomeUnknown === false)
159
- && !Object.hasOwn(result, 'error') && !Object.hasOwn(result, 'error_code') && versionId(result.installed_version)
160
- && object(result.preview) && result.preview.id === plan && pathText(result.preview.target) && result.preview.target === target
161
- && Array.isArray(result.preview.items) && result.preview.items.length > 0
162
- && result.preview.items.length <= SKILL_INSTALL.MAX_ITEMS
163
- && result.preview.items.every(row => object(row) && pathText(row.name)
164
- && !/[\\/]/u.test(row.name) && typeof row.skill === 'boolean' && typeof row.exists === 'boolean'
165
- && count(row.files) && count(row.bytes));
166
- }
167
-
168
- export async function runSkillInstall({ target, credentials, scope, confirm, onProgress, signal = scope.confirmationSignal }) {
169
- let plan, committing = false;
170
- try {
171
- if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
172
- const selected = await control('select', { ...credentials, target }, signal);
173
- if (!planId(selected.plan_id) || selected.phase !== 'selected' || selected.target !== target
174
- || !count(selected.expires_at)) throw failure('invalid_metadata');
175
- plan = selected.plan_id;
176
- if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
177
- const parameters = { ...credentials, plan_id: plan };
178
- const stream = action => requestSSE(SKILL_INSTALL.PATH, { ...parameters, action },
179
- event => { if (scope.isCurrent()) onProgress?.({ ...event, skillAction: action }); },
180
- { signal, timeout: SKILL_INSTALL.STREAM_TIMEOUT });
181
- const ready = await stream('prepare');
182
- if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
183
- if (!validReady(ready, plan, target)) throw failure(ready?.error_code || 'invalid_metadata');
184
- const html = `<div class="skill-install-preview"><p>${escapeHtml(t('skill.preview_target', { path: ready.preview.target }))}</p>`
185
- + `<p>${escapeHtml(t('skill.preview_hint'))}</p><ul>`
186
- + ready.preview.items.map(row => `<li>${escapeHtml(row.name)} — ${escapeHtml(t(row.exists ? 'skill.preview_replace' : 'skill.preview_new'))}`
187
- + ` · ${escapeHtml(t(row.skill ? 'skill.preview_skill' : 'skill.preview_shared'))}</li>`).join('') + '</ul></div>';
188
- if (!await confirm(html, { html: true }) || !scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
189
- committing = true;
190
- let result;
191
- try { result = await stream('commit'); }
192
- catch (error) {
193
- if (error?.fallbackSafe) return { status: 'error', error_code: 'local_update_required', outcomeUnknown: false };
194
- }
195
- if (confirmed(result, ready.installed_version)) return result;
196
- if (object(result) && result.status === 'error' && result.outcomeUnknown === false
197
- && typeof result.error_code === 'string') return result;
198
- if (scope.isCurrent() && !signal.aborted) {
199
- try {
200
- onProgress?.({ stage: 'validate', progress: 99, skillAction: 'status' });
201
- const receipt = await control('status', parameters, signal, SKILL_INSTALL.RESULT_TIMEOUT);
202
- if (scope.isCurrent() && !signal.aborted && receipt.plan_id === plan && receipt.target === target
203
- && confirmed(receipt, ready.installed_version)) return receipt;
204
- } catch {}
205
- }
206
- return { status: 'error', outcomeUnknown: true };
207
- } catch (error) {
208
- throw failure(error?.fallbackSafe ? 'local_update_required' : error?.code || 'install_failed');
209
- } finally {
210
- if (plan && !committing) {
211
- try { await control('discard', { ...credentials, plan_id: plan }); } catch {}
212
- }
213
- }
214
- }
 
 
 
 
 
1
+ import { SKILL_INSTALL, escapeHtml } from '../core/全局配置.js';
2
+ import { describeInstallSource } from './安装来源.js';
3
+ import { readLocalJSON } from '../core/本机请求读取.js';
4
+ import { requestSSE } from '../core/网络请求_基础设施.js';
5
+ import { t } from '../components/用户体验_国际化.js';
6
+ import { globalModal } from '../components/全局弹窗管理器.js';
7
+
8
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
9
+ const pathText = value => typeof value === 'string' && value.length > 0
10
+ && !/[\u0000-\u001f\u007f]/u.test(value)
11
+ && new TextEncoder().encode(value).length <= SKILL_INSTALL.PATH_MAX_BYTES;
12
+ const planId = value => typeof value === 'string' && /^[a-f0-9]{32}$/.test(value);
13
+ const versionId = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
14
+ const count = value => Number.isSafeInteger(value) && value >= 0;
15
+
16
+ function failure(code) {
17
+ const error = new Error('skill_operation_failed');
18
+ error.code = code;
19
+ return error;
20
+ }
21
+
22
+ export function skillErrorMessage(error) {
23
+ const errorKeys = {
24
+ local_update_required: 'skill.local_update_required',
25
+ invalid_target: 'install.error_invalid_target',
26
+ invalid_repository: 'install.error_invalid_repository',
27
+ invalid_skill_package: 'skill.invalid_package',
28
+ invalid_plan: 'skill.plan_invalid',
29
+ plan_expired: 'skill.plan_invalid',
30
+ plan_owner_mismatch: 'skill.identity_changed',
31
+ auth_failed: 'skill.identity_changed',
32
+ source_changed: 'skill.source_changed',
33
+ target_changed: 'skill.target_changed',
34
+ target_conflict: 'skill.target_changed',
35
+ recovery_conflict: 'skill.target_changed',
36
+ source_unavailable: 'skill.source_unavailable',
37
+ invalid_metadata: 'skill.source_unavailable',
38
+ directory_too_large: 'skill.directory_too_large',
39
+ package_too_large: 'install.error_package_too_large',
40
+ insufficient_disk_space: 'install.error_disk_space',
41
+ permission_denied: 'install.error_permission_denied',
42
+ install_queue_full: 'install.error_queue_full',
43
+ install_queue_timeout: 'install.error_queue_timeout',
44
+ };
45
+ const errorKey = Object.hasOwn(errorKeys, error?.code) ? errorKeys[error.code] : 'install.error_generic';
46
+ return t(errorKey);
47
+ }
48
+
49
+ async function control(action, body, signal, timeout = SKILL_INSTALL.CONTROL_TIMEOUT) {
50
+ const controller = new AbortController();
51
+ const abort = () => controller.abort();
52
+ signal?.addEventListener('abort', abort, { once: true });
53
+ if (signal?.aborted) controller.abort();
54
+ const timer = setTimeout(abort, timeout);
55
+ try {
56
+ if (controller.signal.aborted) throw failure('operation_cancelled');
57
+ const response = await fetch(SKILL_INSTALL.PATH, {
58
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ ...body, action }), cache: 'no-store', signal: controller.signal,
60
+ });
61
+ if (response.status === 404 || response.status === 405) throw failure('local_update_required');
62
+ const value = await readLocalJSON(response, controller, SKILL_INSTALL.RESPONSE_MAX_BYTES);
63
+ if (!response.ok || !object(value) || value.status !== 'success' || !(value.installed === false
64
+ || action === 'status' && value.phase === 'complete' && value.installed === true)
65
+ || Object.hasOwn(value, 'error') || Object.hasOwn(value, 'error_code')) {
66
+ throw failure(object(value) && typeof value.error_code === 'string' ? value.error_code : 'invalid_metadata');
67
+ }
68
+ return value;
69
+ } finally {
70
+ clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.abort();
71
+ }
72
+ }
73
+
74
+ function browseResult(value) {
75
+ return value.phase === 'browse' && pathText(value.path) && pathText(value.parent)
76
+ && typeof value.selectable === 'boolean' && typeof value.truncated === 'boolean'
77
+ && Array.isArray(value.directories) && value.directories.length <= SKILL_INSTALL.MAX_DIRECTORIES
78
+ && value.directories.every(row => object(row) && pathText(row.name) && pathText(row.path));
79
+ }
80
+
81
+ export function chooseSkillDirectory(scope) {
82
+ if (!scope.isCurrent()) return Promise.resolve(null);
83
+ return new Promise(resolve => {
84
+ const content = document.createElement('div'); content.className = 'skill-directory';
85
+ const hint = document.createElement('p'); hint.textContent = t('skill.directory_hint');
86
+ const label = document.createElement('label'); label.className = 'skill-directory__label';
87
+ const title = document.createElement('span'); title.textContent = t('skill.directory_path');
88
+ const input = document.createElement('input'); input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false;
89
+ label.append(title, input);
90
+ const actions = document.createElement('div'); actions.className = 'skill-directory__actions';
91
+ const button = (label, callback) => {
92
+ const node = document.createElement('button'); node.type = 'button'; node.textContent = label;
93
+ node.addEventListener('click', callback); return node;
94
+ };
95
+ const notice = document.createElement('p'); notice.setAttribute('role', 'status'); notice.setAttribute('aria-live', 'polite');
96
+ const list = document.createElement('ul'); list.className = 'skill-directory__list'; list.setAttribute('aria-label', t('skill.directory_folders'));
97
+ let closed = false, generation = 0, selected = null, controller, modal;
98
+ let releaseScope = () => {};
99
+ const current = () => !closed && scope.isCurrent();
100
+ const finish = value => {
101
+ if (closed) return;
102
+ closed = true; generation++; controller?.abort(); releaseScope();
103
+ resolve(value); if (modal) globalModal.closeModal(modal);
104
+ };
105
+ content._cleanup = () => finish(null);
106
+ const open = button(t('skill.directory_open'), () => browse(input.value));
107
+ const parent = button(t('skill.directory_parent'), () => selected && browse(selected.parent));
108
+ const select = button(t('skill.directory_select'), () => {
109
+ if (current() && selected?.selectable && input.value === selected.path) finish(selected.path);
110
+ });
111
+ select.disabled = parent.disabled = true;
112
+ const cancel = button(t('common.cancel'), () => finish(null));
113
+ actions.append(open, parent, select, cancel);
114
+ input.addEventListener('input', () => {
115
+ generation++; controller?.abort(); selected = null; select.disabled = parent.disabled = true;
116
+ content.removeAttribute('aria-busy'); list.replaceChildren(); notice.textContent = t('skill.directory_verify');
117
+ });
118
+ async function browse(path) {
119
+ if (!current()) return;
120
+ const own = ++generation; controller?.abort(); controller = new AbortController();
121
+ selected = null; select.disabled = parent.disabled = true;
122
+ content.setAttribute('aria-busy', 'true'); notice.textContent = t('common.loading'); list.replaceChildren();
123
+ try {
124
+ const value = await control('browse', path === undefined ? {} : { path }, controller.signal);
125
+ if (!current() || own !== generation) return;
126
+ if (!browseResult(value)) throw failure('invalid_metadata');
127
+ selected = value; input.value = value.path;
128
+ select.disabled = !value.selectable; parent.disabled = value.parent === value.path;
129
+ for (const row of value.directories) {
130
+ const item = document.createElement('li');
131
+ const folder = document.createElement('button'); folder.type = 'button'; folder.textContent = row.name;
132
+ folder.addEventListener('click', () => { if (current() && own === generation) browse(row.path); });
133
+ item.append(folder); list.append(item);
134
+ }
135
+ notice.textContent = t(value.truncated ? 'skill.directory_truncated' : 'skill.directory_ready');
136
+ } catch (error) {
137
+ if (current() && own === generation) notice.textContent = skillErrorMessage(error);
138
+ } finally {
139
+ if (current() && own === generation) content.removeAttribute('aria-busy');
140
+ }
141
+ }
142
+ content.append(hint, label, actions, notice, list);
143
+ modal = globalModal.openModal(t('skill.directory_title'), content, { width: SKILL_INSTALL.MODAL_WIDTH });
144
+ releaseScope = scope.addCleanup(() => finish(null));
145
+ if (!current()) { finish(null); return; }
146
+ input.focus(); browse();
147
+ });
148
+ }
149
+
150
+ function confirmed(result, version) {
151
+ return object(result) && result.status === 'success' && result.phase === 'complete'
152
+ && result.installed === true && (!Object.hasOwn(result, 'outcomeUnknown') || result.outcomeUnknown === false)
153
+ && result.installed_version === version
154
+ && !Object.hasOwn(result, 'error') && !Object.hasOwn(result, 'error_code');
155
+ }
156
+
157
+ function validReady(result, plan, target) {
158
+ return object(result) && result.status === 'success' && result.phase === 'ready'
159
+ && result.installed === false && (!Object.hasOwn(result, 'outcomeUnknown') || result.outcomeUnknown === false)
160
+ && !Object.hasOwn(result, 'error') && !Object.hasOwn(result, 'error_code') && versionId(result.installed_version)
161
+ && describeInstallSource(result.repository) !== null
162
+ && object(result.preview) && result.preview.id === plan && pathText(result.preview.target) && result.preview.target === target
163
+ && Array.isArray(result.preview.items) && result.preview.items.length > 0
164
+ && result.preview.items.length <= SKILL_INSTALL.MAX_ITEMS
165
+ && result.preview.items.every(row => object(row) && pathText(row.name)
166
+ && !/[\\/]/u.test(row.name) && typeof row.skill === 'boolean' && typeof row.exists === 'boolean'
167
+ && count(row.files) && count(row.bytes));
168
+ }
169
+
170
+ export async function runSkillInstall({ target, credentials, scope, confirm, onProgress, signal = scope.confirmationSignal }) {
171
+ let plan, committing = false;
172
+ try {
173
+ if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
174
+ const selected = await control('select', { ...credentials, target }, signal);
175
+ if (!planId(selected.plan_id) || selected.phase !== 'selected' || selected.target !== target
176
+ || !count(selected.expires_at)) throw failure('invalid_metadata');
177
+ plan = selected.plan_id;
178
+ if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
179
+ const parameters = { ...credentials, plan_id: plan };
180
+ const stream = action => requestSSE(SKILL_INSTALL.PATH, { ...parameters, action },
181
+ event => { if (scope.isCurrent()) onProgress?.({ ...event, skillAction: action }); },
182
+ { signal, timeout: SKILL_INSTALL.STREAM_TIMEOUT });
183
+ const ready = await stream('prepare');
184
+ if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
185
+ if (!validReady(ready, plan, target)) throw failure(ready?.error_code || 'invalid_metadata');
186
+ const source = describeInstallSource(ready.repository);
187
+ const html = `<div class="skill-install-preview"><p>${escapeHtml(t('install.source', { source: source.label }))}</p>`
188
+ + `<p>${escapeHtml(t('skill.preview_target', { path: ready.preview.target }))}</p>`
189
+ + `<p>${escapeHtml(t('skill.preview_hint'))}</p><ul>`
190
+ + ready.preview.items.map(row => `<li>${escapeHtml(row.name)} — ${escapeHtml(t(row.exists ? 'skill.preview_replace' : 'skill.preview_new'))}`
191
+ + ` · ${escapeHtml(t(row.skill ? 'skill.preview_skill' : 'skill.preview_shared'))}</li>`).join('') + '</ul></div>';
192
+ if (!await confirm(html, { html: true }) || !scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
193
+ committing = true;
194
+ let result;
195
+ try { result = await stream('commit'); }
196
+ catch (error) {
197
+ if (error?.fallbackSafe) return { status: 'error', error_code: 'local_update_required', outcomeUnknown: false };
198
+ }
199
+ if (confirmed(result, ready.installed_version)) return result;
200
+ if (object(result) && result.status === 'error' && result.outcomeUnknown === false
201
+ && typeof result.error_code === 'string') return result;
202
+ if (scope.isCurrent() && !signal.aborted) {
203
+ try {
204
+ onProgress?.({ stage: 'validate', progress: 99, skillAction: 'status' });
205
+ const receipt = await control('status', parameters, signal, SKILL_INSTALL.RESULT_TIMEOUT);
206
+ if (scope.isCurrent() && !signal.aborted && receipt.plan_id === plan && receipt.target === target
207
+ && confirmed(receipt, ready.installed_version)) return receipt;
208
+ } catch {}
209
+ }
210
+ return { status: 'error', outcomeUnknown: true };
211
+ } catch (error) {
212
+ throw failure(error?.fallbackSafe ? 'local_update_required' : error?.code || 'install_failed');
213
+ } finally {
214
+ if (plan && !committing) {
215
+ try { await control('discard', { ...credentials, plan_id: plan }); } catch {}
216
+ }
217
+ }
218
+ }
前端页面/market/安装来源.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { UI } from '../core/全局配置.js';
2
+
3
+ export function describeInstallSource(value) {
4
+ const spec = UI.INSTALL_SOURCE;
5
+ if (typeof value !== 'string' || value.length > spec.MAX_URL_BYTES) return null;
6
+ const text = value.trim();
7
+ if (!text || /[\u0000-\u0020\u007f\\]/u.test(text)
8
+ || new TextEncoder().encode(text).length > spec.MAX_URL_BYTES) return null;
9
+ try {
10
+ const url = new URL(text);
11
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null;
12
+ let path = url.pathname.replace(/\/+$/u, '');
13
+ if (path.endsWith('.git')) path = path.slice(0, -4);
14
+ if (!path || path === '/') return null;
15
+ const identityPath = spec.CASE_INSENSITIVE_HOSTS.includes(url.hostname) ? path.toLowerCase() : path;
16
+ return { identity: url.origin + identityPath, label: url.host + path };
17
+ } catch { return null; }
18
+ }
前端页面/market/资源安装引擎.js CHANGED
The diff for this file is too large to render. See raw diff
 
前端页面/styles/主题基础.css CHANGED
The diff for this file is too large to render. See raw diff