ZHIWEI666 commited on
Commit
2205167
·
verified ·
1 Parent(s): c8e5eaa

Upload 95 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. 前端页面/auth/用户注册登录组件.js +41 -9
  2. 前端页面/components/UI交互提示组件.js +313 -145
  3. 前端页面/components/互动工具函数.js +38 -23
  4. 前端页面/components/全局弹窗管理器.js +72 -118
  5. 前端页面/components/关于插件组件.js +180 -352
  6. 前端页面/components/动画音效引擎.js +23 -7
  7. 前端页面/components/卡片导航生命周期.js +32 -0
  8. 前端页面/components/图片展示测试.js +64 -18
  9. 前端页面/components/图片沙盒组件.js +4 -4
  10. 前端页面/components/图片裁剪组件.js +39 -9
  11. 前端页面/components/彩蛋动画引擎.js +11 -9
  12. 前端页面/components/性能优化工具.js +167 -58
  13. 前端页面/components/打赏等级工具.js +27 -18
  14. 前端页面/components/用户体验_国际化.js +578 -51
  15. 前端页面/components/用户体验增强.js +62 -16
  16. 前端页面/components/顶部导航组件.js +121 -180
  17. 前端页面/components/顶部广告组件.js +178 -80
  18. 前端页面/core/侧边栏主程序.js +165 -149
  19. 前端页面/core/侧边栏入口注册.js +10 -1
  20. 前端页面/core/侧边栏数据引擎.js +191 -496
  21. 前端页面/core/全局配置.js +165 -36
  22. 前端页面/core/榜单分页控制.js +95 -0
  23. 前端页面/core/榜单分页状态.js +39 -0
  24. 前端页面/core/榜单数据来源.js +71 -0
  25. 前端页面/core/榜单本地查询.js +99 -0
  26. 前端页面/core/榜单视图切换.js +49 -0
  27. 前端页面/core/状态管理.js +66 -60
  28. 前端页面/core/用户会话存储.js +45 -0
  29. 前端页面/core/网络请求API.js +1 -1
  30. 前端页面/core/网络请求_业务API.js +56 -44
  31. 前端页面/core/网络请求_基础设施.js +274 -78
  32. 前端页面/core/网络请求_缓存管理.js +224 -39
  33. 前端页面/market/列表卡片组件.js +167 -214
  34. 前端页面/market/创作者卡片组件.js +4 -10
  35. 前端页面/market/发布内容_UI模板.js +84 -98
  36. 前端页面/market/发布内容_提交引擎.js +84 -162
  37. 前端页面/market/发布内容组件.js +64 -35
  38. 前端页面/market/发布字段错误.js +41 -0
  39. 前端页面/market/发布提交生命周期.js +55 -0
  40. 前端页面/market/发布文件选择可访问性.js +44 -0
  41. 前端页面/market/发布编辑锁.js +43 -0
  42. 前端页面/market/图片文件检查.js +77 -0
  43. 前端页面/market/支付二维码工具.js +22 -0
  44. 前端页面/market/有界图片处理.js +152 -0
  45. 前端页面/market/购买状态生命周期.js +37 -0
  46. 前端页面/market/资源删除组件.js +59 -0
  47. 前端页面/market/资源安装引擎.js +757 -473
  48. 前端页面/market/资源操作生命周期.js +52 -0
  49. 前端页面/market/资源获取记录.js +67 -0
  50. 前端页面/market/资源详情页面组件.js +96 -261
前端页面/auth/用户注册登录组件.js CHANGED
@@ -2,10 +2,13 @@ import { renderLoginForm } from "./登录表单组件.js";
2
  import { renderRegisterForm } from "./注册表单组件.js";
3
  import { renderResetForm } from "./重置密码表单组件.js";
4
  import { renderRecoverForm } from "./找回账号表单组件.js";
 
5
  import { t } from "../components/用户体验增强.js";
6
 
7
  export function createAuthView(onSuccessCallback) {
8
  const container = document.createElement("div");
 
 
9
  // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
10
  Object.assign(container.style, {
11
  display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
@@ -22,30 +25,59 @@ export function createAuthView(onSuccessCallback) {
22
  `;
23
 
24
  container.querySelector("#btn-back-auth").onclick = () => {
 
25
  window.dispatchEvent(new CustomEvent("comfy-route-back"));
26
  };
27
 
28
  const formContainer = container.querySelector("#auth-form-container");
29
 
 
 
 
 
 
 
 
 
 
 
30
  const switchView = (viewState, options = {}) => {
31
- // 🔧 定时器清理:切换视图前执行上一个表单注册的清理函数(如验证码倒计时),防止内存泄漏
32
- if (typeof formContainer._onCleanup === "function") {
33
- formContainer._onCleanup();
34
- formContainer._onCleanup = null;
35
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  formContainer.innerHTML = "";
37
  if (viewState === "login") {
38
- renderLoginForm(formContainer, switchView, onSuccessCallback);
39
  } else if (viewState === "register") {
40
- renderRegisterForm(formContainer, switchView, onSuccessCallback);
41
  } else if (viewState === "reset") {
42
- renderResetForm(formContainer, switchView, onSuccessCallback, options.prefill);
43
  } else if (viewState === "recover") {
44
  renderRecoverForm(formContainer, switchView);
45
  }
46
  };
47
 
48
- switchView("login");
 
 
 
 
 
49
 
50
  return container;
51
  }
 
2
  import { renderRegisterForm } from "./注册表单组件.js";
3
  import { renderResetForm } from "./重置密码表单组件.js";
4
  import { renderRecoverForm } from "./找回账号表单组件.js";
5
+ import { showToast } from "../components/UI交互提示组件.js";
6
  import { t } from "../components/用户体验增强.js";
7
 
8
  export function createAuthView(onSuccessCallback) {
9
  const container = document.createElement("div");
10
+ let disposed = false, generation = 0, mounted = false, busy = false;
11
+ let observer;
12
  // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
13
  Object.assign(container.style, {
14
  display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
 
25
  `;
26
 
27
  container.querySelector("#btn-back-auth").onclick = () => {
28
+ container._cleanup();
29
  window.dispatchEvent(new CustomEvent("comfy-route-back"));
30
  };
31
 
32
  const formContainer = container.querySelector("#auth-form-container");
33
 
34
+ const releaseForm = () => {
35
+ formContainer.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
36
+ const cleanup = formContainer._onCleanup; formContainer._onCleanup = null;
37
+ try { cleanup?.(); } catch (_) {}
38
+ };
39
+ container._cleanup = () => {
40
+ if (disposed) return;
41
+ disposed = true; generation++; busy = false;
42
+ observer?.disconnect(); releaseForm();
43
+ };
44
  const switchView = (viewState, options = {}) => {
45
+ if (disposed) return;
46
+ releaseForm(); generation++; busy = false;
47
+ const currentGeneration = generation;
48
+ const current = () => !disposed && generation === currentGeneration && container.isConnected;
49
+ const submit = async formData => {
50
+ if (!current() || busy) return;
51
+ busy = true;
52
+ const button = formContainer.querySelector('button[id^="btn-submit"]');
53
+ if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
54
+ try { await onSuccessCallback?.(formData, { isCurrent: current }); }
55
+ catch (_) { if (current()) showToast(t('common.error'), 'error'); }
56
+ finally {
57
+ if (current()) {
58
+ busy = false;
59
+ if (button) { button.disabled = false; button.setAttribute('aria-busy', 'false'); }
60
+ }
61
+ }
62
+ };
63
  formContainer.innerHTML = "";
64
  if (viewState === "login") {
65
+ renderLoginForm(formContainer, switchView, submit);
66
  } else if (viewState === "register") {
67
+ renderRegisterForm(formContainer, switchView, submit);
68
  } else if (viewState === "reset") {
69
+ renderResetForm(formContainer, switchView, submit, options.prefill);
70
  } else if (viewState === "recover") {
71
  renderRecoverForm(formContainer, switchView);
72
  }
73
  };
74
 
75
+ switchView("login");
76
+ observer = new MutationObserver(() => {
77
+ if (container.isConnected) mounted = true;
78
+ else if (mounted) container._cleanup();
79
+ });
80
+ observer.observe(document.body, { childList: true, subtree: true });
81
 
82
  return container;
83
  }
前端页面/components/UI交互提示组件.js CHANGED
@@ -25,6 +25,14 @@ import { escapeHtml } from "../core/全局配置.js";
25
  // Toast 队列管理(避免重叠)
26
  let toastQueue = [];
27
  let toastOffset = 50;
 
 
 
 
 
 
 
 
28
 
29
  /**
30
  * 显示 Toast 消息提示
@@ -34,63 +42,35 @@ let toastOffset = 50;
34
  */
35
  export function showToast(message, type = "info", duration = 3000) {
36
  const toast = document.createElement("div");
37
- const colors = {
38
- success: "#4CAF50",
39
- error: "#F44336",
40
- info: "#2196F3",
41
- warning: "#FF9800"
42
- };
43
- const icons = {
44
- success: "✅",
45
- error: "❌",
46
- info: "ℹ️",
47
- warning: "⚠️"
48
- };
49
-
50
- // 计算位置(避免重叠)
51
- const topPosition = toastOffset + toastQueue.length * 60;
52
-
53
- Object.assign(toast.style, {
54
- position: "fixed",
55
- top: `${topPosition - 30}px`,
56
- left: "50%",
57
- transform: "translateX(-50%)",
58
- background: colors[type] || colors.info,
59
- color: "#fff",
60
- padding: "12px 24px",
61
- borderRadius: "8px",
62
- fontSize: "14px",
63
- fontWeight: "500",
64
- zIndex: "10000",
65
- boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
66
- opacity: "0",
67
- transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)",
68
- pointerEvents: "auto",
69
- display: "flex",
70
- alignItems: "center",
71
- gap: "10px",
72
- maxWidth: "400px",
73
- wordBreak: "break-word"
74
- });
75
 
76
  // 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
77
  // 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
 
78
  toast.innerHTML = `
79
- <span style="font-size: 16px;">${icons[type] || icons.info}</span>
80
- <span style="flex: 1;">${escapeHtml(message)}</span>
81
- <span class="toast-close" style="cursor: pointer; opacity: 0.7; font-size: 18px; margin-left: 8px;">×</span>
82
  `;
83
 
84
  document.body.appendChild(toast);
85
  toastQueue.push(toast);
86
 
87
  // 点击关闭
88
- toast.querySelector(".toast-close").onclick = () => closeToast(toast);
89
 
90
  // 入场动画
91
  requestAnimationFrame(() => {
92
  toast.style.opacity = "1";
93
- toast.style.top = `${topPosition}px`;
94
  });
95
 
96
  // 自动关闭
@@ -99,16 +79,17 @@ export function showToast(message, type = "info", duration = 3000) {
99
  }
100
 
101
  function closeToast(toast) {
 
102
  if (toast._autoCloseTimer) clearTimeout(toast._autoCloseTimer);
103
 
104
- toast.style.opacity = "0";
105
- toast.style.transform = "translateX(-50%) translateY(-20px)";
106
 
107
  setTimeout(() => {
108
  toast.remove();
109
  const idx = toastQueue.indexOf(toast);
110
  if (idx > -1) toastQueue.splice(idx, 1);
111
- }, 300);
 
112
  }
113
 
114
 
@@ -124,12 +105,9 @@ function closeToast(toast) {
124
  */
125
  function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) {
126
  const overlay = document.createElement("div");
127
- Object.assign(overlay.style, {
128
- position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
129
- background: `rgba(0,0,0,${bgOpacity})`, zIndex: "10001", display: "flex",
130
- alignItems: "center", justifyContent: "center", backdropFilter: `blur(${blurPx}px)`,
131
- opacity: "0", transition: "opacity 0.2s"
132
- });
133
  return overlay;
134
  }
135
 
@@ -144,16 +122,16 @@ function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) {
144
  * @param {string} [transformOrigin] - 变换基点(影响初始 transform)
145
  * @returns {HTMLElement}
146
  */
147
- function _createDialogBox({ width = "360px", background = "#222", borderRadius = "12px", padding = "24px", boxShadow = "0 10px 40px rgba(0,0,0,0.5)", gap = "16px", hasYTranslate = true } = {}) {
148
  const box = document.createElement("div");
149
- const initTransform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)";
150
- Object.assign(box.style, {
151
- background, border: "1px solid #444", borderRadius,
152
- padding, width, maxWidth: "90vw", color: "#fff",
153
- boxShadow,
154
- display: "flex", flexDirection: "column", gap,
155
- transform: initTransform, transition: "transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)"
156
- });
157
  return box;
158
  }
159
 
@@ -164,6 +142,11 @@ function _createDialogBox({ width = "360px", background = "#222", borderRadius =
164
  * @param {boolean} hasYTranslate - 是否包含 Y 轴位移
165
  */
166
  function _showDialogAnimation(overlay, box, hasYTranslate = true) {
 
 
 
 
 
167
  requestAnimationFrame(() => {
168
  overlay.style.opacity = "1";
169
  box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)";
@@ -178,11 +161,75 @@ function _showDialogAnimation(overlay, box, hasYTranslate = true) {
178
  * @param {Function} [callback] - 关闭完成后的回调
179
  */
180
  function _closeDialog(overlay, box, hasYTranslate = true, callback) {
 
 
 
 
 
181
  overlay.style.opacity = "0";
182
  box.style.transform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)";
183
- setTimeout(() => { overlay.remove(); if (callback) callback(); }, 200);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  }
185
 
 
 
 
186
  // ==========================================
187
  // ⚠️ 确认对话框
188
  // ==========================================
@@ -196,37 +243,49 @@ function _closeDialog(overlay, box, hasYTranslate = true, callback) {
196
  * @param {string} options.cancelText - 取消按钮文字
197
  * @param {string} options.type - 类型: 'warning', 'danger', 'info'
198
  * @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
 
 
 
199
  * @returns {Promise<boolean>}
200
  */
201
  export function showConfirm(message, options = {}) {
202
  const {
203
- title = "操作确认",
204
- confirmText = "确认执行",
205
- cancelText = "取消",
206
  type = "warning",
207
- html = false
 
 
 
208
  } = options;
209
 
210
  const typeConfig = {
211
- warning: { icon: "⚠️", color: "#FF9800", btnColor: "#FF9800" },
212
- danger: { icon: "🚨", color: "#F44336", btnColor: "#F44336" },
213
- info: { icon: "ℹ️", color: "#2196F3", btnColor: "#2196F3" }
214
  };
215
- const config = typeConfig[type] || typeConfig.warning;
 
216
 
 
217
  return new Promise((resolve) => {
218
  const overlay = _createDialogOverlay(0.6, 3);
219
  const box = _createDialogBox({ width: "360px", hasYTranslate: true });
 
 
 
 
220
 
221
  box.innerHTML = `
222
- <div style="font-size: 18px; font-weight: bold; color: ${config.color}; display: flex; align-items: center; gap: 10px;">
223
- <span style="font-size: 24px;">${config.icon}</span>
224
  <span>${escapeHtml(title)}</span>
225
  </div>
226
- <div style="font-size: 14px; color: #ccc; line-height: 1.7; padding: 8px 0;">${html ? message : escapeHtml(message)}</div>
227
- <div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px;">
228
- <button id="ui-btn-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #aaa; border-radius: 6px; cursor: pointer; transition: 0.2s; font-size: 14px;">${escapeHtml(cancelText)}</button>
229
- <button id="ui-btn-confirm" style="padding: 10px 20px; background: ${config.btnColor}; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 8px ${config.btnColor}44; transition: 0.2s; font-size: 14px;">${escapeHtml(confirmText)}</button>
230
  </div>
231
  `;
232
 
@@ -236,26 +295,65 @@ export function showConfirm(message, options = {}) {
236
  // 入场动画
237
  _showDialogAnimation(overlay, box, true);
238
 
239
- const close = (result) => {
240
- _closeDialog(overlay, box, true, () => resolve(result));
 
 
 
 
 
 
 
241
  };
242
-
243
- // ESC 键关闭
244
- const handleKeydown = (e) => {
245
- if (e.key === "Escape") {
246
- document.removeEventListener("keydown", handleKeydown);
247
- close(false);
248
- }
 
 
 
249
  };
250
- document.addEventListener("keydown", handleKeydown);
 
 
 
 
 
 
 
 
251
 
252
  box.querySelector("#ui-btn-cancel").onclick = () => {
253
- document.removeEventListener("keydown", handleKeydown);
254
  close(false);
255
  };
256
- box.querySelector("#ui-btn-confirm").onclick = () => {
257
- document.removeEventListener("keydown", handleKeydown);
258
- close(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  };
260
  });
261
  }
@@ -275,14 +373,17 @@ export function showConfirm(message, options = {}) {
275
  */
276
  export function showRetryDialog(message, retryFn, options = {}) {
277
  const {
278
- title = "操作失败",
279
- retryText = "🔄 重试",
280
- cancelText = "关闭",
281
  html = false
282
  } = options;
283
 
284
  return new Promise((resolve) => {
285
  const overlay = _createDialogOverlay(0.7, 4);
 
 
 
286
  const box = _createDialogBox({
287
  width: "400px",
288
  background: "linear-gradient(145deg, #2a2a2a, #1f1f1f)",
@@ -292,20 +393,19 @@ export function showRetryDialog(message, retryFn, options = {}) {
292
  gap: "20px",
293
  hasYTranslate: false
294
  });
295
- // RetryDialog 的过渡时间略长
296
- box.style.transition = "transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)";
297
 
298
  box.innerHTML = `
299
- <div style="text-align: center;">
300
- <div style="font-size: 48px; margin-bottom: 16px;">😕</div>
301
- <div style="font-size: 20px; font-weight: bold; color: #F44336;">${escapeHtml(title)}</div>
302
  </div>
303
- <div style="font-size: 14px; color: #bbb; line-height: 1.7; text-align: center; padding: 0 10px; background: #1a1a1a; border-radius: 8px; padding: 16px;">
304
  ${html ? message : escapeHtml(message)}
305
  </div>
306
- <div style="display: flex; gap: 12px; justify-content: center;">
307
- <button id="ui-btn-cancel" style="flex: 1; padding: 12px 20px; background: #333; border: 1px solid #555; color: #aaa; border-radius: 8px; cursor: pointer; transition: 0.2s; font-size: 14px;">${escapeHtml(cancelText)}</button>
308
- <button id="ui-btn-retry" style="flex: 1; padding: 12px 20px; background: linear-gradient(135deg, #2196F3, #1976D2); border: none; color: #fff; border-radius: 8px; cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 14px;">${escapeHtml(retryText)}</button>
309
  </div>
310
  `;
311
 
@@ -315,35 +415,49 @@ export function showRetryDialog(message, retryFn, options = {}) {
315
  // 入场动画
316
  _showDialogAnimation(overlay, box, false);
317
 
318
- const close = () => {
319
- _closeDialog(overlay, box, false);
 
 
 
 
 
 
 
 
 
320
  };
 
 
 
 
 
321
 
322
  box.querySelector("#ui-btn-cancel").onclick = () => {
323
- close();
324
- resolve(false);
325
  };
326
 
327
  box.querySelector("#ui-btn-retry").onclick = async () => {
328
  const retryBtn = box.querySelector("#ui-btn-retry");
329
  retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
330
  retryBtn.disabled = true;
 
331
 
332
  try {
333
  await retryFn();
334
- close();
335
- resolve(true);
336
  } catch (error) {
337
- // 重试失败,更新错误信息(与初次渲染保持一致:默认纯文本,仅 html=true 时按 HTML 渲染)
338
  const errText = error.message || t('common.retry_failed');
339
- const errBox = box.querySelector("div[style*='background: #1a1a1a']");
340
  if (html) {
341
- errBox.innerHTML = errText;
342
  } else {
343
  errBox.textContent = errText;
344
  }
345
  retryBtn.innerHTML = escapeHtml(retryText);
346
  retryBtn.disabled = false;
 
347
  }
348
  };
349
  });
@@ -373,7 +487,7 @@ let loadingCount = 0;
373
  * 显示全局加载遮罩
374
  * @param {string} message - 加载提示文字
375
  */
376
- export function showLoading(message = "加载中...") {
377
  loadingCount++;
378
 
379
  if (loadingOverlay) {
@@ -461,7 +575,7 @@ export function hideLoading() {
461
  * @param {boolean} loading - 是否加载中
462
  * @param {string} loadingText - 加载时显示的文字
463
  */
464
- export function setButtonLoading(button, loading, loadingText = "处理中...") {
465
  if (!button) return;
466
 
467
  if (loading) {
@@ -494,7 +608,7 @@ export function setButtonLoading(button, loading, loadingText = "处理中...")
494
  * @param {string} text - 要复制的文本
495
  * @param {string} successMsg - 成功提示
496
  */
497
- export async function copyToClipboard(text, successMsg = "已复制到剪贴板") {
498
  try {
499
  if (navigator.clipboard && navigator.clipboard.writeText) {
500
  await navigator.clipboard.writeText(text);
@@ -510,7 +624,7 @@ export async function copyToClipboard(text, successMsg = "已复制到剪贴板"
510
  }
511
  showToast(successMsg, "success");
512
  } catch (error) {
513
- showToast("复制失败", "error");
514
  }
515
  }
516
 
@@ -527,9 +641,9 @@ export async function copyToClipboard(text, successMsg = "已复制到剪贴板"
527
  */
528
  export async function withFeedback(asyncFn, options = {}) {
529
  const {
530
- loadingMsg = "处理中...",
531
- successMsg = "操作成功",
532
- errorMsg = "操作失败",
533
  showLoadingOverlay = false,
534
  button = null
535
  } = options;
@@ -551,8 +665,7 @@ export async function withFeedback(asyncFn, options = {}) {
551
 
552
  return result;
553
  } catch (error) {
554
- const msg = error.message || errorMsg;
555
- showToast(msg, "error");
556
  throw error;
557
  } finally {
558
  if (showLoadingOverlay) {
@@ -572,58 +685,113 @@ export async function withFeedback(asyncFn, options = {}) {
572
  /**
573
  * 创建安装进度条
574
  * @param {HTMLElement} container - 进度条容器元素
575
- * @returns {{update: Function, complete: Function, error: Function, destroy: Function}}
576
  */
577
  export function createInstallProgress(container) {
578
  // 创建进度条DOM
 
579
  const wrapper = document.createElement("div");
580
  wrapper.className = "install-progress-wrapper";
581
  wrapper.innerHTML = `
582
- <div style="width: 100%; margin: 12px 0;">
583
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
584
- <span class="progress-stage" style="font-size: 12px; color: #aaa;">准备中...</span>
585
- <span class="progress-percent" style="font-size: 12px; color: #2196F3; font-weight: bold;">0%</span>
 
586
  </div>
587
- <div style="position: relative; width: 100%; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden;">
588
- <div class="progress-fill" style="
589
- width: 0%;
590
- height: 100%;
591
- background: linear-gradient(90deg, #2196F3, #4CAF50);
592
- border-radius: 3px;
593
- transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
594
- "></div>
595
  </div>
596
  </div>
597
  `;
598
  container.innerHTML = "";
599
  container.appendChild(wrapper);
600
 
601
- const fill = wrapper.querySelector(".progress-fill");
602
- const stage = wrapper.querySelector(".progress-stage");
603
- const percent = wrapper.querySelector(".progress-percent");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
 
605
  return {
606
  update(progress, message) {
607
  const p = Math.max(0, Math.min(100, progress));
608
  fill.style.width = `${p}%`;
609
  percent.textContent = `${p}%`;
610
- if (message) stage.textContent = message;
 
 
 
 
611
  },
612
  complete(message) {
 
 
613
  fill.style.width = "100%";
614
- fill.style.background = "linear-gradient(90deg, #4CAF50, #8BC34A)";
615
  percent.textContent = "100%";
616
- percent.style.color = "#4CAF50";
617
- stage.textContent = message || "✅ 安装完成!";
618
- stage.style.color = "#4CAF50";
 
619
  },
620
  error(message) {
621
- fill.style.background = "#F44336";
622
- percent.style.color = "#F44336";
623
- stage.textContent = message || "❌ 安装失败";
624
- stage.style.color = "#F44336";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  },
626
  destroy() {
 
627
  wrapper.remove();
628
  }
629
  };
 
25
  // Toast 队列管理(避免重叠)
26
  let toastQueue = [];
27
  let toastOffset = 50;
28
+ let dialogInstanceId = 0;
29
+ const dialogStack = [];
30
+
31
+ function repositionToasts() {
32
+ toastQueue.forEach((toast, index) => {
33
+ toast.style.top = `${toastOffset + index * 60}px`;
34
+ });
35
+ }
36
 
37
  /**
38
  * 显示 Toast 消息提示
 
42
  */
43
  export function showToast(message, type = "info", duration = 3000) {
44
  const toast = document.createElement("div");
45
+ const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info";
46
+ toast.className = `comfy-ranking-toast comfy-ranking-toast--${safeType}`;
47
+ toast.setAttribute("role", type === "error" ? "alert" : "status");
48
+ toast.setAttribute("aria-live", safeType === "error" ? "assertive" : "polite");
49
+ toast.setAttribute("aria-atomic", "true");
50
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
51
+ if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion");
52
+ // 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。
53
+ toast.style.top = `${toastOffset + toastQueue.length * 60 - 30}px`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  // 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
56
  // 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
57
+ const icons = { success: "✅", error: "❌", info: "ℹ️", warning: "⚠️" };
58
  toast.innerHTML = `
59
+ <span class="comfy-ranking-toast__icon" aria-hidden="true">${icons[safeType]}</span>
60
+ <span class="comfy-ranking-toast__message">${escapeHtml(message)}</span>
61
+ <button type="button" class="toast-close comfy-ranking-toast__close" aria-label="${escapeHtml(t('common.close'))}">×</button>
62
  `;
63
 
64
  document.body.appendChild(toast);
65
  toastQueue.push(toast);
66
 
67
  // 点击关闭
68
+ toast.querySelector(".toast-close").addEventListener("click", () => closeToast(toast));
69
 
70
  // 入场动画
71
  requestAnimationFrame(() => {
72
  toast.style.opacity = "1";
73
+ repositionToasts();
74
  });
75
 
76
  // 自动关闭
 
79
  }
80
 
81
  function closeToast(toast) {
82
+ if (!toast?.isConnected || toast.classList.contains("comfy-ranking-toast--closing")) return;
83
  if (toast._autoCloseTimer) clearTimeout(toast._autoCloseTimer);
84
 
85
+ toast.classList.add("comfy-ranking-toast--closing");
 
86
 
87
  setTimeout(() => {
88
  toast.remove();
89
  const idx = toastQueue.indexOf(toast);
90
  if (idx > -1) toastQueue.splice(idx, 1);
91
+ repositionToasts();
92
+ }, window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 300);
93
  }
94
 
95
 
 
105
  */
106
  function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) {
107
  const overlay = document.createElement("div");
108
+ overlay.className = "comfy-ranking-dialog-overlay";
109
+ overlay.style.setProperty("--dialog-overlay-opacity", String(bgOpacity));
110
+ overlay.style.setProperty("--dialog-blur", `${blurPx}px`);
 
 
 
111
  return overlay;
112
  }
113
 
 
122
  * @param {string} [transformOrigin] - 变换基点(影响初始 transform)
123
  * @returns {HTMLElement}
124
  */
125
+ function _createDialogBox({ width = "360px", background = "var(--comfy-menu-bg, #222)", borderRadius = "12px", padding = "24px", boxShadow = "0 10px 40px rgba(0,0,0,0.5)", gap = "16px", hasYTranslate = true } = {}) {
126
  const box = document.createElement("div");
127
+ box.className = "comfy-ranking-dialog";
128
+ box.dataset.motion = hasYTranslate ? "translate" : "scale";
129
+ box.style.setProperty("--dialog-width", width);
130
+ box.style.setProperty("--dialog-background", background);
131
+ box.style.setProperty("--dialog-radius", borderRadius);
132
+ box.style.setProperty("--dialog-padding", padding);
133
+ box.style.setProperty("--dialog-shadow", boxShadow);
134
+ box.style.setProperty("--dialog-gap", gap);
135
  return box;
136
  }
137
 
 
142
  * @param {boolean} hasYTranslate - 是否包含 Y 轴位移
143
  */
144
  function _showDialogAnimation(overlay, box, hasYTranslate = true) {
145
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
146
+ overlay.style.opacity = "1";
147
+ box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)";
148
+ return;
149
+ }
150
  requestAnimationFrame(() => {
151
  overlay.style.opacity = "1";
152
  box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)";
 
161
  * @param {Function} [callback] - 关闭完成后的回调
162
  */
163
  function _closeDialog(overlay, box, hasYTranslate = true, callback) {
164
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
165
+ if (reduceMotion) {
166
+ overlay.style.transition = "none";
167
+ box.style.transition = "none";
168
+ }
169
  overlay.style.opacity = "0";
170
  box.style.transform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)";
171
+ setTimeout(() => { overlay.remove(); if (callback) callback(); }, reduceMotion ? 0 : 200);
172
+ }
173
+
174
+ /** 为共用弹窗提供模态语义、焦点约束与关闭后的焦点恢复。 */
175
+ function _setupDialogAccessibility(box, titleId, descriptionId, initialFocusSelector, onEscape) {
176
+ const previousFocus = document.activeElement;
177
+ box.setAttribute("role", "dialog");
178
+ box.setAttribute("aria-modal", "true");
179
+ box.setAttribute("aria-labelledby", titleId);
180
+ if (descriptionId) box.setAttribute("aria-describedby", descriptionId);
181
+ else box.removeAttribute("aria-describedby");
182
+ box.setAttribute("tabindex", "-1");
183
+
184
+ const getFocusable = () => Array.from(box.querySelectorAll(
185
+ 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
186
+ )).filter(element => !element.closest('[inert], [hidden], [aria-hidden="true"]')
187
+ && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
188
+
189
+ const handleKeydown = event => {
190
+ if (dialogStack[dialogStack.length - 1] !== box) return;
191
+ if (event.key === "Escape") {
192
+ event.preventDefault();
193
+ onEscape();
194
+ return;
195
+ }
196
+ if (event.key !== "Tab") return;
197
+ const focusable = getFocusable();
198
+ if (!focusable.length) {
199
+ event.preventDefault();
200
+ box.focus();
201
+ return;
202
+ }
203
+ const first = focusable[0];
204
+ const last = focusable[focusable.length - 1];
205
+ if (event.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
206
+ event.preventDefault();
207
+ last.focus();
208
+ } else if (!event.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
209
+ event.preventDefault();
210
+ first.focus();
211
+ }
212
+ };
213
+ dialogStack.push(box);
214
+ document.addEventListener("keydown", handleKeydown);
215
+ const initialFocus = box.querySelector(initialFocusSelector) || getFocusable()[0] || box;
216
+ initialFocus.focus();
217
+
218
+ return {
219
+ cleanup() {
220
+ document.removeEventListener("keydown", handleKeydown);
221
+ const stackIndex = dialogStack.lastIndexOf(box);
222
+ if (stackIndex >= 0) dialogStack.splice(stackIndex, 1);
223
+ },
224
+ restoreFocus() {
225
+ if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
226
+ }
227
+ };
228
  }
229
 
230
+ // 业务弹窗复用相同的模态栈、焦点循环与恢复行为;调用方负责在关闭时 cleanup。
231
+ export { _setupDialogAccessibility as setupDialogAccessibility };
232
+
233
  // ==========================================
234
  // ⚠️ 确认对话框
235
  // ==========================================
 
243
  * @param {string} options.cancelText - 取消按钮文字
244
  * @param {string} options.type - 类型: 'warning', 'danger', 'info'
245
  * @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
246
+ * @param {Function} options.onConfirm - 可选异步操作,返回 false 时按未成功关闭;调用方负责业务错误提示
247
+ * @param {string} options.processingText - 异步操作期间的本地化状态文案
248
+ * @param {AbortSignal} [options.signal] - 强制释放原范围弹窗,不取消已提交业务操作
249
  * @returns {Promise<boolean>}
250
  */
251
  export function showConfirm(message, options = {}) {
252
  const {
253
+ title = t('common.confirm_action'),
254
+ confirmText = t('common.confirm_execute'),
255
+ cancelText = t('common.cancel'),
256
  type = "warning",
257
+ html = false,
258
+ onConfirm,
259
+ processingText = t('common.processing'),
260
+ signal
261
  } = options;
262
 
263
  const typeConfig = {
264
+ warning: { icon: "⚠️" },
265
+ danger: { icon: "🚨" },
266
+ info: { icon: "ℹ️" }
267
  };
268
+ const safeType = Object.hasOwn(typeConfig, type) ? type : "warning";
269
+ const config = typeConfig[safeType];
270
 
271
+ if (signal?.aborted) return Promise.resolve(false);
272
  return new Promise((resolve) => {
273
  const overlay = _createDialogOverlay(0.6, 3);
274
  const box = _createDialogBox({ width: "360px", hasYTranslate: true });
275
+ box.dataset.type = safeType;
276
+ const dialogId = ++dialogInstanceId;
277
+ const titleId = `ui-dialog-title-${dialogId}`;
278
+ const descriptionId = `ui-dialog-description-${dialogId}`;
279
 
280
  box.innerHTML = `
281
+ <div id="${titleId}" class="comfy-ranking-dialog__title" role="heading" aria-level="2">
282
+ <span class="comfy-ranking-dialog__icon" aria-hidden="true">${config.icon}</span>
283
  <span>${escapeHtml(title)}</span>
284
  </div>
285
+ <div id="${descriptionId}" class="comfy-ranking-dialog__message${html ? '' : ' comfy-ranking-dialog__message--plain'}">${html ? message : escapeHtml(message)}</div>
286
+ <div class="comfy-ranking-dialog__actions">
287
+ <button id="ui-btn-cancel" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--cancel" type="button">${escapeHtml(cancelText)}</button>
288
+ <button id="ui-btn-confirm" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--confirm" type="button">${escapeHtml(confirmText)}</button>
289
  </div>
290
  `;
291
 
 
295
  // 入场动画
296
  _showDialogAnimation(overlay, box, true);
297
 
298
+ let settled = false, aborted = false;
299
+ let pending = false;
300
+ let dialogAccessibility, observer;
301
+ const abort = () => {
302
+ if (aborted) return;
303
+ aborted = true; settled = true; pending = false;
304
+ observer?.disconnect(); dialogAccessibility?.cleanup(); overlay.remove();
305
+ signal?.removeEventListener("abort", abort);
306
+ resolve(false);
307
  };
308
+ const close = result => {
309
+ if (settled || pending) return;
310
+ settled = true;
311
+ observer?.disconnect();
312
+ dialogAccessibility?.cleanup();
313
+ _closeDialog(overlay, box, true, () => {
314
+ signal?.removeEventListener("abort", abort);
315
+ if (!aborted) dialogAccessibility?.restoreFocus();
316
+ resolve(aborted ? false : result);
317
+ });
318
  };
319
+
320
+ dialogAccessibility = _setupDialogAccessibility(
321
+ box, titleId, descriptionId, "#ui-btn-cancel", () => close(false)
322
+ );
323
+
324
+ observer = new MutationObserver(() => { if (!overlay.isConnected) abort(); });
325
+ observer.observe(document.body, { childList: true, subtree: true });
326
+ signal?.addEventListener("abort", abort, { once: true });
327
+ if (signal?.aborted) { abort(); return; }
328
 
329
  box.querySelector("#ui-btn-cancel").onclick = () => {
 
330
  close(false);
331
  };
332
+ box.querySelector("#ui-btn-confirm").onclick = async () => {
333
+ if (settled || pending) return;
334
+ if (typeof onConfirm !== "function") { close(true); return; }
335
+ pending = true;
336
+ box.setAttribute("aria-busy", "true");
337
+ box.querySelector("#ui-btn-cancel").disabled = true;
338
+ const confirmButton = box.querySelector("#ui-btn-confirm");
339
+ confirmButton.disabled = true;
340
+ confirmButton.textContent = processingText;
341
+ const progress = document.createElement("p");
342
+ progress.textContent = processingText;
343
+ progress.setAttribute("role", "status");
344
+ progress.setAttribute("aria-live", "polite");
345
+ box.querySelector(".comfy-ranking-dialog__message").appendChild(progress);
346
+ box.focus();
347
+ let succeeded = false;
348
+ try { succeeded = (await onConfirm()) !== false; }
349
+ catch {
350
+ if (!settled) showToast(t('common.operation_failed'), "error");
351
+ } finally {
352
+ if (settled) return;
353
+ pending = false;
354
+ box.removeAttribute("aria-busy");
355
+ close(succeeded);
356
+ }
357
  };
358
  });
359
  }
 
373
  */
374
  export function showRetryDialog(message, retryFn, options = {}) {
375
  const {
376
+ title = t('common.error'),
377
+ retryText = `🔄 ${t('common.retry')}`,
378
+ cancelText = t('common.close'),
379
  html = false
380
  } = options;
381
 
382
  return new Promise((resolve) => {
383
  const overlay = _createDialogOverlay(0.7, 4);
384
+ const dialogId = ++dialogInstanceId;
385
+ const titleId = `ui-dialog-title-${dialogId}`;
386
+ const descriptionId = `ui-dialog-description-${dialogId}`;
387
  const box = _createDialogBox({
388
  width: "400px",
389
  background: "linear-gradient(145deg, #2a2a2a, #1f1f1f)",
 
393
  gap: "20px",
394
  hasYTranslate: false
395
  });
396
+ box.dataset.type = "retry";
 
397
 
398
  box.innerHTML = `
399
+ <div class="comfy-ranking-dialog__header comfy-ranking-dialog__header--center">
400
+ <div class="comfy-ranking-dialog__illustration" aria-hidden="true">😕</div>
401
+ <div id="${titleId}" class="comfy-ranking-dialog__title comfy-ranking-dialog__title--center" role="heading" aria-level="2">${escapeHtml(title)}</div>
402
  </div>
403
+ <div id="${descriptionId}" class="comfy-ranking-dialog__message comfy-ranking-dialog__message--center">
404
  ${html ? message : escapeHtml(message)}
405
  </div>
406
+ <div class="comfy-ranking-dialog__actions comfy-ranking-dialog__actions--center">
407
+ <button id="ui-btn-cancel" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--cancel" type="button">${escapeHtml(cancelText)}</button>
408
+ <button id="ui-btn-retry" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--retry" type="button">${escapeHtml(retryText)}</button>
409
  </div>
410
  `;
411
 
 
415
  // 入场动画
416
  _showDialogAnimation(overlay, box, false);
417
 
418
+ let settled = false;
419
+ let retryInProgress = false;
420
+ let dialogAccessibility;
421
+ const settle = result => {
422
+ if (settled) return;
423
+ settled = true;
424
+ dialogAccessibility?.cleanup();
425
+ _closeDialog(overlay, box, false, () => {
426
+ dialogAccessibility?.restoreFocus();
427
+ resolve(result);
428
+ });
429
  };
430
+ dialogAccessibility = _setupDialogAccessibility(
431
+ box, titleId, descriptionId, "#ui-btn-cancel", () => {
432
+ if (!retryInProgress) settle(false);
433
+ }
434
+ );
435
 
436
  box.querySelector("#ui-btn-cancel").onclick = () => {
437
+ settle(false);
 
438
  };
439
 
440
  box.querySelector("#ui-btn-retry").onclick = async () => {
441
  const retryBtn = box.querySelector("#ui-btn-retry");
442
  retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
443
  retryBtn.disabled = true;
444
+ retryInProgress = true;
445
 
446
  try {
447
  await retryFn();
448
+ settle(true);
 
449
  } catch (error) {
450
+ // 重试错误可能来自远端或扩展,即使初始提示允许富文本也必须作为纯文本处理
451
  const errText = error.message || t('common.retry_failed');
452
+ const errBox = box.querySelector(".comfy-ranking-dialog__message");
453
  if (html) {
454
+ errBox.innerHTML = escapeHtml(errText);
455
  } else {
456
  errBox.textContent = errText;
457
  }
458
  retryBtn.innerHTML = escapeHtml(retryText);
459
  retryBtn.disabled = false;
460
+ retryInProgress = false;
461
  }
462
  };
463
  });
 
487
  * 显示全局加载遮罩
488
  * @param {string} message - 加载提示文字
489
  */
490
+ export function showLoading(message = t('feedback.processing')) {
491
  loadingCount++;
492
 
493
  if (loadingOverlay) {
 
575
  * @param {boolean} loading - 是否加载中
576
  * @param {string} loadingText - 加载时显示的文字
577
  */
578
+ export function setButtonLoading(button, loading, loadingText = t('feedback.processing')) {
579
  if (!button) return;
580
 
581
  if (loading) {
 
608
  * @param {string} text - 要复制的文本
609
  * @param {string} successMsg - 成功提示
610
  */
611
+ export async function copyToClipboard(text, successMsg = t('feedback.copied')) {
612
  try {
613
  if (navigator.clipboard && navigator.clipboard.writeText) {
614
  await navigator.clipboard.writeText(text);
 
624
  }
625
  showToast(successMsg, "success");
626
  } catch (error) {
627
+ showToast(t('feedback.copy_failed'), "error");
628
  }
629
  }
630
 
 
641
  */
642
  export async function withFeedback(asyncFn, options = {}) {
643
  const {
644
+ loadingMsg = t('feedback.processing'),
645
+ successMsg = t('feedback.operation_success'),
646
+ errorMsg = t('feedback.operation_failed'),
647
  showLoadingOverlay = false,
648
  button = null
649
  } = options;
 
665
 
666
  return result;
667
  } catch (error) {
668
+ showToast(errorMsg, "error");
 
669
  throw error;
670
  } finally {
671
  if (showLoadingOverlay) {
 
685
  /**
686
  * 创建安装进度条
687
  * @param {HTMLElement} container - 进度条容器元素
688
+ * @returns {{update: Function, complete: Function, error: Function, showDiagnostics: Function, destroy: Function}}
689
  */
690
  export function createInstallProgress(container) {
691
  // 创建进度条DOM
692
+ const preparingLabel = t('install.preparing');
693
  const wrapper = document.createElement("div");
694
  wrapper.className = "install-progress-wrapper";
695
  wrapper.innerHTML = `
696
+ <div class="install-progress">
697
+ <div class="install-progress__header">
698
+ <span class="install-progress__stage" role="status" aria-live="polite">${escapeHtml(preparingLabel)}</span>
699
+ <span class="install-progress__elapsed" role="timer" aria-live="off"></span>
700
+ <span class="install-progress__percent">0%</span>
701
  </div>
702
+ <div class="install-progress__track" role="progressbar" aria-label="${escapeHtml(t('install.progress_label'))}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="${escapeHtml(preparingLabel)}">
703
+ <div class="install-progress__fill"></div>
 
 
 
 
 
 
704
  </div>
705
  </div>
706
  `;
707
  container.innerHTML = "";
708
  container.appendChild(wrapper);
709
 
710
+ const fill = wrapper.querySelector(".install-progress__fill");
711
+ const stage = wrapper.querySelector(".install-progress__stage");
712
+ const elapsed = wrapper.querySelector(".install-progress__elapsed");
713
+ const percent = wrapper.querySelector(".install-progress__percent");
714
+ const progressTrack = wrapper.querySelector(".install-progress__track");
715
+ const startedAt = Date.now();
716
+ let cancelButton = null;
717
+ const updateElapsed = () => {
718
+ const totalSeconds = Math.floor((Date.now() - startedAt) / 1000);
719
+ const time = `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`;
720
+ const elapsedLabel = t('install.elapsed', { time });
721
+ elapsed.textContent = elapsedLabel;
722
+ elapsed.setAttribute("aria-label", elapsedLabel);
723
+ };
724
+ const elapsedTimer = setInterval(updateElapsed, 1000);
725
+ const stopElapsedTimer = () => clearInterval(elapsedTimer);
726
+ updateElapsed();
727
 
728
  return {
729
  update(progress, message) {
730
  const p = Math.max(0, Math.min(100, progress));
731
  fill.style.width = `${p}%`;
732
  percent.textContent = `${p}%`;
733
+ progressTrack.setAttribute("aria-valuenow", String(p));
734
+ if (message) {
735
+ stage.textContent = message;
736
+ progressTrack.setAttribute("aria-valuetext", message);
737
+ }
738
  },
739
  complete(message) {
740
+ stopElapsedTimer();
741
+ cancelButton?.remove();
742
  fill.style.width = "100%";
 
743
  percent.textContent = "100%";
744
+ progressTrack.setAttribute("aria-valuenow", "100");
745
+ wrapper.classList.add("is-complete");
746
+ stage.textContent = message || `✅ ${t('install.complete')}`;
747
+ progressTrack.setAttribute("aria-valuetext", stage.textContent);
748
  },
749
  error(message) {
750
+ stopElapsedTimer();
751
+ cancelButton?.remove();
752
+ wrapper.classList.add("is-error");
753
+ stage.textContent = message || `❌ ${t('install.failed')}`;
754
+ progressTrack.setAttribute("aria-valuetext", stage.textContent);
755
+ },
756
+ showDiagnostics(entries) {
757
+ if (!Array.isArray(entries) || entries.length === 0) return;
758
+ const details = document.createElement("details");
759
+ details.className = "install-progress__diagnostics";
760
+ const summary = document.createElement("summary");
761
+ summary.textContent = t('install.diagnostics');
762
+ details.appendChild(summary);
763
+
764
+ const list = document.createElement("ol");
765
+ for (const entry of entries.slice(-50)) {
766
+ const item = document.createElement("li");
767
+ const stageKey = entry.stageKey;
768
+ const stageLabel = stageKey ? t(stageKey) : (entry.stage || t('install.stage_unknown'));
769
+ item.textContent = t('install.diagnostic_entry', {
770
+ time: entry.time,
771
+ stage: stageLabel,
772
+ progress: Number.isFinite(entry.progress) ? `${entry.progress}%` : "—"
773
+ });
774
+ list.appendChild(item);
775
+ }
776
+ details.appendChild(list);
777
+ wrapper.appendChild(details);
778
+ },
779
+ addCancelButton(label, onCancel) {
780
+ if (cancelButton || typeof onCancel !== "function") return;
781
+ cancelButton = document.createElement("button");
782
+ cancelButton.type = "button";
783
+ cancelButton.className = "install-progress__cancel";
784
+ cancelButton.textContent = label;
785
+ cancelButton.addEventListener("click", () => {
786
+ if (cancelButton.disabled) return;
787
+ cancelButton.disabled = true;
788
+ cancelButton.textContent = t('install.cancelling');
789
+ onCancel();
790
+ });
791
+ wrapper.appendChild(cancelButton);
792
  },
793
  destroy() {
794
+ stopElapsedTimer();
795
  wrapper.remove();
796
  }
797
  };
前端页面/components/互动工具函数.js CHANGED
@@ -8,6 +8,8 @@
8
 
9
  import { showToast } from "./UI交互提示组件.js";
10
  import { t } from "./用户体验增强.js";
 
 
11
  // 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import
12
  import { escapeHtml } from "../core/全局配置.js";
13
 
@@ -23,7 +25,7 @@ const VIEW_DEBOUNCE_MS = 300000; // 5分钟成功冷却
23
  const VIEW_ERROR_COOLDOWN_MS = 30000; // 30秒错误冷却
24
 
25
  // 防重复提交状态跟踪
26
- const _pendingActions = new Set();
27
 
28
  /**
29
  * 记录内容浏览量(带5分钟防抖)
@@ -131,7 +133,8 @@ export function renderInteractionButtonsHTML(data, currentAccount, options = {})
131
  * @param {string} options.activeToastKey - 激活时 toast 的 i18n key
132
  * @param {string} [options.activeTextColor] - 激活时的按钮文字色(可选)
133
  * @param {string} [options.inactiveTextColor] - 取消激活时的按钮文字色(可选)
134
- * @param {string} [options.warnLabel] - 日志警告用标签
 
135
  * @returns {Promise<Object|null>}
136
  */
137
  async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, options) {
@@ -143,29 +146,37 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr
143
  activeToastKey,
144
  activeTextColor = null,
145
  inactiveTextColor = null,
146
- warnLabel = 'toggle'
 
147
  } = options;
148
 
149
- if (!currentUser) {
 
 
 
 
 
 
 
150
  showToast(t('auth.login_required'), "warning");
151
  return null;
152
  }
153
 
154
- const actionKey = `${actionPrefix}_${contentId}`;
155
- if (_pendingActions.has(actionKey)) return null;
156
- _pendingActions.add(actionKey);
 
 
 
157
 
158
  if (buttonEl) buttonEl.disabled = true;
159
 
160
  try {
161
  const res = await apiMethod(contentId);
162
-
163
- if (!res || res.status !== "success") {
164
- console.warn(`${warnLabel}响应异常:`, res);
165
- return null;
166
- }
167
-
168
- const count = typeof res[countField] === 'number' ? res[countField] : 0;
169
  if (countEl) countEl.textContent = count;
170
 
171
  if (res.action === activeAction) {
@@ -178,14 +189,14 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr
178
  if (buttonEl) buttonEl.style.borderColor = "#555";
179
  if (buttonEl && inactiveTextColor) buttonEl.style.color = inactiveTextColor;
180
  }
 
181
  return res;
182
- } catch (err) {
183
- console.error(`${warnLabel}操作失败:`, err);
184
- showToast(t('task.operation_failed'), "error");
185
  return null;
186
  } finally {
187
- _pendingActions.delete(actionKey);
188
- if (buttonEl) buttonEl.disabled = false;
189
  }
190
  }
191
 
@@ -196,16 +207,18 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr
196
  * @param {HTMLElement} buttonEl - 按钮元素
197
  * @param {HTMLElement} countEl - 计数元素
198
  * @param {Object} currentUser - 当前用户
 
199
  * @returns {Promise<Object|null>}
200
  */
201
- export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser) {
202
  return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
203
  actionPrefix: 'like',
204
  countField: 'likes',
205
  activeAction: 'liked',
206
  activeColor: '#FF5722',
207
  activeToastKey: 'post.liked',
208
- warnLabel: '点赞'
 
209
  });
210
  }
211
 
@@ -216,9 +229,10 @@ export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl,
216
  * @param {HTMLElement} buttonEl - 按钮元素
217
  * @param {HTMLElement} countEl - 计数元素
218
  * @param {Object} currentUser - 当前用户
 
219
  * @returns {Promise<Object|null>}
220
  */
221
- export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser) {
222
  return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
223
  actionPrefix: 'favorite',
224
  countField: 'favorites',
@@ -227,7 +241,8 @@ export async function handleToggleFavorite(apiMethod, contentId, buttonEl, count
227
  activeToastKey: 'post.favorited',
228
  activeTextColor: '#000',
229
  inactiveTextColor: '#fff',
230
- warnLabel: '收藏'
 
231
  });
232
  }
233
 
 
8
 
9
  import { showToast } from "./UI交互提示组件.js";
10
  import { t } from "./用户体验增强.js";
11
+ import { captureRequestSession } from "../core/网络请求_基础设施.js";
12
+ import { getUser } from "../core/状态管理.js";
13
  // 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import
14
  import { escapeHtml } from "../core/全局配置.js";
15
 
 
25
  const VIEW_ERROR_COOLDOWN_MS = 30000; // 30秒错误冷却
26
 
27
  // 防重复提交状态跟踪
28
+ const _pendingActions = new WeakMap();
29
 
30
  /**
31
  * 记录内容浏览量(带5分钟防抖)
 
133
  * @param {string} options.activeToastKey - 激活时 toast 的 i18n key
134
  * @param {string} [options.activeTextColor] - 激活时的按钮文字色(可选)
135
  * @param {string} [options.inactiveTextColor] - 取消激活时的按钮文字色(可选)
136
+ * @param {string} options.inactiveAction - 服务端取消动作值
137
+ * @param {Function} [options.isCurrent] - 原视图仍有效的检查
138
  * @returns {Promise<Object|null>}
139
  */
140
  async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, options) {
 
146
  activeToastKey,
147
  activeTextColor = null,
148
  inactiveTextColor = null,
149
+ inactiveAction,
150
+ isCurrent: viewCurrent = () => true
151
  } = options;
152
 
153
+ const sessionMatches = captureRequestSession();
154
+ const originalHandler = buttonEl?.onclick;
155
+ const current = () => sessionMatches() && viewCurrent()
156
+ && (!buttonEl || (buttonEl.isConnected && buttonEl.onclick === originalHandler))
157
+ && (!countEl || countEl.isConnected)
158
+ && (getUser()?.account || null) === (currentUser?.account || null);
159
+ if (!current()) return null;
160
+ if (!currentUser?.account) {
161
  showToast(t('auth.login_required'), "warning");
162
  return null;
163
  }
164
 
165
+ // 同类内容ID可在不同业务存在;按实际API方法和账号隔离在途动作。
166
+ const actionKey = JSON.stringify([currentUser.account, actionPrefix, contentId]);
167
+ let pending = _pendingActions.get(apiMethod);
168
+ if (!pending) { pending = new Set(); _pendingActions.set(apiMethod, pending); }
169
+ if (pending.has(actionKey)) return null;
170
+ pending.add(actionKey);
171
 
172
  if (buttonEl) buttonEl.disabled = true;
173
 
174
  try {
175
  const res = await apiMethod(contentId);
176
+ if (!current()) return null;
177
+ if (res?.status !== "success" || !Number.isSafeInteger(res[countField]) || res[countField] < 0
178
+ || (res.action !== activeAction && res.action !== inactiveAction)) throw new Error();
179
+ const count = res[countField];
 
 
 
180
  if (countEl) countEl.textContent = count;
181
 
182
  if (res.action === activeAction) {
 
189
  if (buttonEl) buttonEl.style.borderColor = "#555";
190
  if (buttonEl && inactiveTextColor) buttonEl.style.color = inactiveTextColor;
191
  }
192
+ if (buttonEl) buttonEl.setAttribute("aria-pressed", String(res.action === activeAction));
193
  return res;
194
+ } catch (_) {
195
+ if (current()) showToast(t('task.operation_failed'), "error");
 
196
  return null;
197
  } finally {
198
+ pending.delete(actionKey);
199
+ if (current() && buttonEl) buttonEl.disabled = false;
200
  }
201
  }
202
 
 
207
  * @param {HTMLElement} buttonEl - 按钮元素
208
  * @param {HTMLElement} countEl - 计数元素
209
  * @param {Object} currentUser - 当前用户
210
+ * @param {Object} [options] - 可选视图检查,原五参数调用保持
211
  * @returns {Promise<Object|null>}
212
  */
213
+ export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser, options = {}) {
214
  return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
215
  actionPrefix: 'like',
216
  countField: 'likes',
217
  activeAction: 'liked',
218
  activeColor: '#FF5722',
219
  activeToastKey: 'post.liked',
220
+ inactiveAction: 'unliked',
221
+ isCurrent: options.isCurrent
222
  });
223
  }
224
 
 
229
  * @param {HTMLElement} buttonEl - 按钮元素
230
  * @param {HTMLElement} countEl - 计数元素
231
  * @param {Object} currentUser - 当前用户
232
+ * @param {Object} [options] - 可选视图检查,原五参数调用保持
233
  * @returns {Promise<Object|null>}
234
  */
235
+ export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser, options = {}) {
236
  return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
237
  actionPrefix: 'favorite',
238
  countField: 'favorites',
 
241
  activeToastKey: 'post.favorited',
242
  activeTextColor: '#000',
243
  inactiveTextColor: '#fff',
244
+ inactiveAction: 'unfavorited',
245
+ isCurrent: options.isCurrent
246
  });
247
  }
248
 
前端页面/components/全局弹窗管理器.js CHANGED
@@ -1,138 +1,92 @@
1
- // 前端页面/components/全局弹窗管理器.js
 
 
 
2
 
3
  export class ModalManager {
4
  constructor() {
5
- this.activeModals = []; // 用于存储当前打开的所有弹窗,实现层级管理
 
6
  this.overlay = null;
 
7
  this.initOverlay();
8
  }
9
 
10
- // 初始化全局半透明遮罩层
11
  initOverlay() {
12
- if (document.getElementById("community-hub-overlay")) return;
13
-
14
- this.overlay = document.createElement("div");
15
- this.overlay.id = "community-hub-overlay";
16
- Object.assign(this.overlay.style, {
17
- position: "fixed",
18
- top: "0",
19
- left: "0",
20
- width: "100vw",
21
- height: "100vh",
22
- backgroundColor: "rgba(0, 0, 0, 0.7)",
23
- zIndex: "9998", // 位于 ComfyUI 画布之上,但低于侧边栏和弹窗
24
- display: "none",
25
- backdropFilter: "blur(3px)" // 添加背景模糊效果
26
- });
27
-
28
- // 点击遮罩层时,关闭最顶层的一个弹窗
29
- this.overlay.onclick = () => {
30
- try { this.closeTopModal(); } catch(e) { console.warn('[ModalManager] closeTopModal error:', e); }
31
- };
32
-
33
- document.body.appendChild(this.overlay);
34
  }
35
 
36
- /**
37
- * 打开一个新的弹窗
38
- * @param {string} title 弹窗标题
39
- * @param {HTMLElement} contentElement 包含实际内容的 DOM 元素
40
- * @param {object} options 配置项 (如宽度)
41
- */
42
- openModal(title, contentElement, options = { width: "500px" }) {
43
- const modalContainer = document.createElement("div");
44
- Object.assign(modalContainer.style, {
45
- position: "fixed",
46
- top: "50%",
47
- left: "50%",
48
- transform: "translate(-50%, -50%)",
49
- width: options.width,
50
- backgroundColor: "var(--comfy-menu-bg)",
51
- border: "1px solid #555",
52
- borderRadius: "12px",
53
- boxShadow: "0 10px 30px rgba(0,0,0,0.8)",
54
- zIndex: (9999 + this.activeModals.length).toString(), // 动态计算 z-index,实现层级覆盖
55
- display: "flex",
56
- flexDirection: "column",
57
- overflow: "hidden"
58
- });
59
-
60
- // 弹窗头部 (包含标题和关闭按钮)
61
- const header = document.createElement("div");
62
- Object.assign(header.style, {
63
- display: "flex",
64
- justifyContent: "space-between",
65
- alignItems: "center",
66
- padding: "15px 20px",
67
- borderBottom: "1px solid #444",
68
- backgroundColor: "var(--comfy-input-bg)",
69
- });
70
-
71
- const titleSpan = document.createElement("span");
72
- titleSpan.innerText = title;
73
- Object.assign(titleSpan.style, {
74
- fontWeight: "bold",
75
- fontSize: "16px",
76
- color: "#fff"
77
- });
78
-
79
- const closeBtn = document.createElement("button");
80
- closeBtn.innerHTML = "✖";
81
- Object.assign(closeBtn.style, {
82
- background: "transparent",
83
- border: "none",
84
- color: "#aaa",
85
- cursor: "pointer",
86
- fontSize: "14px"
87
  });
88
- // 绑定关闭当前弹窗事件,防止事件冒泡触发底层点击
89
- closeBtn.onclick = (e) => {
90
- e.stopPropagation();
91
- this.closeModal(modalContainer);
92
- };
93
-
94
- header.appendChild(titleSpan);
95
- header.appendChild(closeBtn);
96
-
97
- // 弹窗内容区
98
- const contentWrapper = document.createElement("div");
99
- Object.assign(contentWrapper.style, {
100
- padding: "20px",
101
- maxHeight: "70vh", // 防止弹窗过高,限制最大高度
102
- overflowY: "auto",
103
- color: "#eee"
104
- });
105
- contentWrapper.appendChild(contentElement);
106
-
107
- modalContainer.appendChild(header);
108
- modalContainer.appendChild(contentWrapper);
109
- document.body.appendChild(modalContainer);
110
-
111
- // 记录状态并显示遮罩
112
- this.activeModals.push(modalContainer);
113
- this.overlay.style.display = "block";
114
  }
115
 
116
- // 关闭指定的弹窗
117
- closeModal(modalElement) {
118
- if (!modalElement) return;
119
- modalElement.remove();
120
- this.activeModals = this.activeModals.filter(m => m !== modalElement);
121
-
122
- // 如果没有打开的弹窗了,隐藏遮罩层
123
- if (this.activeModals.length === 0) {
124
- this.overlay.style.display = "none";
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
 
126
  }
127
 
128
- // 关闭位于最顶层的弹窗 (通常用于点击遮罩层或按 ESC 键时触发)
129
- closeTopModal() {
130
- if (this.activeModals.length > 0) {
131
- const topModal = this.activeModals[this.activeModals.length - 1];
132
- this.closeModal(topModal);
 
 
 
 
 
 
 
 
 
 
 
133
  }
134
  }
 
 
135
  }
136
 
137
- // 导出一个全局单例,确保整个应用只实例化一次
138
- export const globalModal = new ModalManager();
 
1
+ // 共用业务弹窗:与确认/重试提示复用同一个可访问性栈。
2
+ import { setupDialogAccessibility } from "./UI交互提示组件.js";
3
+ import { t } from "./用户体验增强.js";
4
+ let modalSequence = 0;
5
 
6
  export class ModalManager {
7
  constructor() {
8
+ this.activeModals = [];
9
+ this.entries = new Map();
10
  this.overlay = null;
11
+ this.observer = null;
12
  this.initOverlay();
13
  }
14
 
 
15
  initOverlay() {
16
+ if (!document.getElementById('hub-theme-style')) {
17
+ const link = document.createElement('link');
18
+ link.id = 'hub-theme-style'; link.rel = 'stylesheet';
19
+ link.href = new URL('../styles/主题基础.css', import.meta.url).href;
20
+ document.head.appendChild(link);
21
+ }
22
+ this.overlay = document.getElementById('community-hub-overlay');
23
+ if (!this.overlay) {
24
+ this.overlay = document.createElement('div');
25
+ this.overlay.id = 'community-hub-overlay';
26
+ this.overlay.className = 'ranking-modal-overlay';
27
+ this.overlay.hidden = true;
28
+ document.body.appendChild(this.overlay);
29
+ }
30
+ this.overlay.onclick = () => this.closeTopModal();
 
 
 
 
 
 
 
31
  }
32
 
33
+ syncLayers() {
34
+ const top = this.activeModals.at(-1);
35
+ this.activeModals.forEach((modal, index) => {
36
+ modal.style.zIndex = String(9999 + index);
37
+ modal.inert = modal !== top;
38
+ if (modal === top) { modal.removeAttribute('aria-hidden'); modal.setAttribute('aria-modal', 'true'); }
39
+ else { modal.setAttribute('aria-hidden', 'true'); modal.removeAttribute('aria-modal'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  });
41
+ this.overlay.hidden = !top;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
43
 
44
+ openModal(title, contentElement, options = { width: '500px' }) {
45
+ const modal = document.createElement('div');
46
+ modal.className = 'ranking-modal';
47
+ modal.style.setProperty('--ranking-modal-width', options.width || '500px');
48
+ const header = document.createElement('div'); header.className = 'ranking-modal__header';
49
+ const titleNode = document.createElement('span'); titleNode.className = 'ranking-modal__title';
50
+ titleNode.id = `ranking-modal-title-${++modalSequence}`; titleNode.textContent = title;
51
+ const close = document.createElement('button'); close.type = 'button';
52
+ close.className = 'ranking-modal__close'; close.textContent = '✖';
53
+ close.setAttribute('aria-label', t('common.close'));
54
+ close.onclick = event => { event.stopPropagation(); this.closeModal(modal); };
55
+ header.append(titleNode, close);
56
+ const body = document.createElement('div'); body.className = 'ranking-modal__body'; body.appendChild(contentElement);
57
+ modal.append(header, body); document.body.appendChild(modal);
58
+ this.activeModals.push(modal); this.syncLayers();
59
+ const accessibility = setupDialogAccessibility(modal, titleNode.id, null, '.ranking-modal__close', () => this.closeModal(modal));
60
+ this.entries.set(modal, { content: contentElement, accessibility });
61
+ if (!this.observer) {
62
+ this.observer = new MutationObserver(() => {
63
+ for (const current of [...this.activeModals]) if (!current.isConnected) this.closeModal(current);
64
+ });
65
+ this.observer.observe(document.body, { childList: true, subtree: true });
66
  }
67
+ return modal;
68
  }
69
 
70
+ closeModal(modal) {
71
+ const entry = this.entries.get(modal);
72
+ if (!entry) return;
73
+ const wasTop = this.activeModals.at(-1) === modal;
74
+ const restore = wasTop && (modal.contains(document.activeElement) || document.activeElement === document.body);
75
+ this.entries.delete(modal);
76
+ entry.accessibility.cleanup();
77
+ try { entry.content._cleanup?.(); } catch (_) {}
78
+ modal.remove();
79
+ this.activeModals = this.activeModals.filter(current => current !== modal);
80
+ this.syncLayers();
81
+ if (!this.activeModals.length) { this.observer?.disconnect(); this.observer = null; }
82
+ if (restore) {
83
+ entry.accessibility.restoreFocus();
84
+ const top = this.activeModals.at(-1);
85
+ if (top && !top.contains(document.activeElement)) top.querySelector('.ranking-modal__close').focus();
86
  }
87
  }
88
+
89
+ closeTopModal() { this.closeModal(this.activeModals.at(-1)); }
90
  }
91
 
92
+ export const globalModal = new ModalManager();
 
前端页面/components/关于插件组件.js CHANGED
@@ -1,352 +1,180 @@
1
- // 前端页面/components/关于插件组件.js
2
- import { api, request } from "../core/网络请求API.js";
3
- import { t } from "./用户体验增强.js";
4
-
5
- // 🏷️ 版本配置缓存
6
- let cachedVersionConfig = null;
7
-
8
- // 清除版本配置缓存(管理员修改版本后调用)
9
- export function clearVersionCache() {
10
- cachedVersionConfig = null;
11
- }
12
-
13
- // 🎨 卡片基础样式常量(authorSection 与 createCardSection 共享)
14
- const CARD_BASE_STYLE = {
15
- background: "rgba(255,255,255,0.03)",
16
- border: "1px solid rgba(255,255,255,0.06)",
17
- borderRadius: "10px",
18
- padding: "16px"
19
- };
20
-
21
- // 获取版本配置
22
- async function getVersionConfig() {
23
- if (cachedVersionConfig) return cachedVersionConfig;
24
-
25
- try {
26
- const res = await request('/api/public/project-version');
27
- if (res?.data) {
28
- cachedVersionConfig = res.data;
29
- return cachedVersionConfig;
30
- }
31
- } catch (e) {
32
- console.warn('获取版本配置失败:', e);
33
- }
34
-
35
- // 默认值
36
- return { stage: 'beta', major: 2, minor: 0, patch: 0 };
37
- }
38
-
39
- // 生成版本字符串
40
- export function formatVersionString(config) {
41
- const stageLabels = {
42
- 'alpha': t('about.stage_alpha'),
43
- 'beta': t('about.stage_beta'),
44
- 'rc': t('about.stage_rc'),
45
- 'stable': t('about.stage_stable')
46
- };
47
- const version = `V${config.major}.${config.minor}.${config.patch}`;
48
- const stageLabel = stageLabels[config.stage] || config.stage;
49
- return `${version} ${stageLabel}`;
50
- }
51
-
52
- // 获取阶段本地化名称
53
- export function getStageLabel(stage) {
54
- const stageLabels = {
55
- 'alpha': t('about.stage_alpha'),
56
- 'beta': t('about.stage_beta'),
57
- 'rc': t('about.stage_rc'),
58
- 'stable': t('about.stage_stable')
59
- };
60
- return stageLabels[stage] || stage;
61
- }
62
-
63
- // ==========================================
64
- // 🎨 创建关于页面独立视图
65
- // ==========================================
66
- export function createAboutView(versionString, stageLabel) {
67
- const container = document.createElement("div");
68
- Object.assign(container.style, {
69
- display: "flex",
70
- flexDirection: "column",
71
- flex: "1",
72
- overflowY: "auto",
73
- color: "#ccc",
74
- fontSize: "14px",
75
- padding: "16px",
76
- boxSizing: "border-box",
77
- gap: "16px",
78
- background: "transparent"
79
- });
80
-
81
- // ===== 1. Hero 品牌区 =====
82
- const heroSection = document.createElement("div");
83
- Object.assign(heroSection.style, {
84
- background: "linear-gradient(180deg, rgba(0, 212, 170, 0.15) 0%, transparent 100%)",
85
- borderRadius: "10px",
86
- padding: "28px 20px",
87
- textAlign: "center",
88
- border: "1px solid rgba(255,255,255,0.06)"
89
- });
90
- heroSection.innerHTML = `
91
- <div style="font-size: 22px; font-weight: bold; color: #00d4aa; margin-bottom: 6px;">${t('about.title')}</div>
92
- <div style="font-size: 14px; color: #888; margin-bottom: 12px; letter-spacing: 2px;">Community Hub</div>
93
- <div style="display: inline-block; background: rgba(0, 212, 170, 0.12); color: #00d4aa; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-bottom: 12px; border: 1px solid rgba(0, 212, 170, 0.2);">${versionString}</div>
94
- <div style="font-size: 13px; color: #aaa; line-height: 1.6; max-width: 400px; margin: 0 auto;">${t('about.subtitle')}</div>
95
- `;
96
-
97
- // ===== 2. 核心优势区 =====
98
- const advantageSection = createCardSection("✨ " + t('about.advantages_title'), `
99
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
100
- <div class="about-adv-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 14px;">
101
- <div style="font-size: 20px; margin-bottom: 6px;">🔗</div>
102
- <div style="font-weight: bold; color: #00d4aa; font-size: 13px; margin-bottom: 4px;">${t('about.adv_native_title')}</div>
103
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.adv_native_desc')}</div>
104
- </div>
105
- <div class="about-adv-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 14px;">
106
- <div style="font-size: 20px; margin-bottom: 6px;">⚡</div>
107
- <div style="font-weight: bold; color: #00d4aa; font-size: 13px; margin-bottom: 4px;">${t('about.adv_speed_title')}</div>
108
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.adv_speed_desc')}</div>
109
- </div>
110
- <div class="about-adv-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 14px;">
111
- <div style="font-size: 20px; margin-bottom: 6px;">💬</div>
112
- <div style="font-weight: bold; color: #00d4aa; font-size: 13px; margin-bottom: 4px;">${t('about.adv_im_title')}</div>
113
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.adv_im_desc')}</div>
114
- </div>
115
- <div class="about-adv-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 14px;">
116
- <div style="font-size: 20px; margin-bottom: 6px;">🛡️</div>
117
- <div style="font-weight: bold; color: #00d4aa; font-size: 13px; margin-bottom: 4px;">${t('about.adv_sandbox_title')}</div>
118
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.adv_sandbox_desc')}</div>
119
- </div>
120
- </div>
121
- `);
122
-
123
- // ===== 3. 主要功能区 =====
124
- const featureSection = createCardSection("🎯 " + t('about.features_title'), `
125
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
126
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
127
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">1.</span>${t('about.feat_publish_title')}</div>
128
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_publish_desc')}</div>
129
- </div>
130
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
131
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">2.</span>${t('about.feat_social_title')}</div>
132
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_social_desc')}</div>
133
- </div>
134
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
135
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">3.</span>${t('about.feat_install_title')}</div>
136
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_install_desc')}</div>
137
- </div>
138
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
139
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">4.</span>${t('about.feat_download_title')}</div>
140
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_download_desc')}</div>
141
- </div>
142
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
143
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">5.</span>${t('about.feat_chat_title')}</div>
144
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_chat_desc')}</div>
145
- </div>
146
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
147
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">6.</span>${t('about.feat_account_title')}</div>
148
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_account_desc')}</div>
149
- </div>
150
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
151
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">7.</span>${t('about.feat_payment_title')}</div>
152
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_payment_desc')}</div>
153
- </div>
154
- <div class="about-feat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px;">
155
- <div style="font-weight: bold; color: #ccc; font-size: 13px; margin-bottom: 4px;"><span style="color: #00d4aa; margin-right: 4px;">8.</span>${t('about.feat_protection_title')}</div>
156
- <div style="font-size: 12px; color: #888; line-height: 1.5;">${t('about.feat_protection_desc')}</div>
157
- </div>
158
- </div>
159
- `);
160
-
161
- // ===== 4. 使用指南区 =====
162
- const guideSection = createCardSection("📖 " + t('about.guide_title'), `
163
- <div style="display: flex; flex-direction: column; gap: 12px; padding-left: 4px;">
164
- <div style="display: flex; align-items: flex-start; gap: 8px;">
165
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">1.</span>
166
- <div>
167
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_install_title')}</div>
168
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_install_desc')}</div>
169
- </div>
170
- </div>
171
- <div style="display: flex; align-items: flex-start; gap: 8px;">
172
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">2.</span>
173
- <div>
174
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_path_title')}</div>
175
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_path_desc')}</div>
176
- </div>
177
- </div>
178
- <div style="display: flex; align-items: flex-start; gap: 8px;">
179
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">3.</span>
180
- <div>
181
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_update_title')}</div>
182
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_update_desc')}</div>
183
- </div>
184
- </div>
185
- <div style="display: flex; align-items: flex-start; gap: 8px;">
186
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">4.</span>
187
- <div>
188
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_publish_title')}</div>
189
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_publish_desc')}</div>
190
- </div>
191
- </div>
192
- <div style="display: flex; align-items: flex-start; gap: 8px;">
193
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">5.</span>
194
- <div>
195
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_buy_title')}</div>
196
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_buy_desc')}</div>
197
- </div>
198
- </div>
199
- <div style="display: flex; align-items: flex-start; gap: 8px;">
200
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">6.</span>
201
- <div>
202
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_refund_title')}</div>
203
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_refund_desc')}</div>
204
- </div>
205
- </div>
206
- <div style="display: flex; align-items: flex-start; gap: 8px;">
207
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">7.</span>
208
- <div>
209
- <div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_withdraw_title')}</div>
210
- <div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_withdraw_desc')}</div>
211
- </div>
212
- </div>
213
- </div>
214
- `);
215
-
216
- // ===== 5. 用户价值区 =====
217
- const userValueSection = createCardSection("👤 " + t('about.benefits_title'), `
218
- <div style="display: flex; flex-direction: column; gap: 10px; padding-left: 4px;">
219
- <div style="display: flex; align-items: flex-start; gap: 8px;">
220
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">1.</span>
221
- <span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_1')}</span>
222
- </div>
223
- <div style="display: flex; align-items: flex-start; gap: 8px;">
224
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">2.</span>
225
- <span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_2')}</span>
226
- </div>
227
- <div style="display: flex; align-items: flex-start; gap: 8px;">
228
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">3.</span>
229
- <span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_3')}</span>
230
- </div>
231
- <div style="display: flex; align-items: flex-start; gap: 8px;">
232
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">4.</span>
233
- <span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_4')}</span>
234
- </div>
235
- <div style="display: flex; align-items: flex-start; gap: 8px;">
236
- <span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">5.</span>
237
- <span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_5')}</span>
238
- </div>
239
- </div>
240
- `);
241
-
242
- // ===== 6. 注意事项区 =====
243
- const warningSection = document.createElement("div");
244
- Object.assign(warningSection.style, {
245
- background: "rgba(240, 160, 48, 0.05)",
246
- borderRadius: "10px",
247
- padding: "16px",
248
- borderTop: "1px solid rgba(240, 160, 48, 0.15)",
249
- borderRight: "1px solid rgba(240, 160, 48, 0.15)",
250
- borderBottom: "1px solid rgba(240, 160, 48, 0.15)",
251
- borderLeft: "3px solid #f0a030"
252
- });
253
- warningSection.innerHTML = `
254
- <div style="font-size: 15px; font-weight: bold; color: #f0a030; margin-bottom: 10px; display: flex; align-items: center; gap: 6px;">
255
- <span>⚠️</span> ${t('about.warning_title')}
256
- </div>
257
- <div style="display: flex; flex-direction: column; gap: 8px;">
258
- <div style="font-size: 13px; color: #ccc; line-height: 1.6;">
259
- <strong style="color: #f0a030;">${t('about.warning_login_title')}</strong>${t('about.warning_login_desc')}
260
- </div>
261
- <div style="font-size: 13px; color: #ccc; line-height: 1.6;">
262
- <strong style="color: #f0a030;">${t('about.warning_account_title')}</strong>${t('about.warning_account_desc')}
263
- </div>
264
- </div>
265
- `;
266
-
267
- // ===== 7. 关于作者区 =====
268
- const authorSection = document.createElement("div");
269
- Object.assign(authorSection.style, { ...CARD_BASE_STYLE, textAlign: "center" });
270
- authorSection.innerHTML = `
271
- <div style="font-size: 15px; font-weight: bold; color: #00d4aa; margin-bottom: 12px;">${t('about.author_title')}</div>
272
- <a href="https://space.bilibili.com/2114638644" target="_blank" style="text-decoration: none; display: inline-block;">
273
- <img src="https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white" alt="Bilibili" style="border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
274
- </a>
275
- <div style="margin-top: 12px; font-size: 12px; color: #666;">
276
- ${t('about.author_thanks')}
277
- </div>
278
- `;
279
-
280
- // ===== 返回按钮 =====
281
- const backBtn = document.createElement("button");
282
- Object.assign(backBtn.style, {
283
- background: "rgba(51,51,51,0.8)",
284
- border: "1px solid rgba(85,85,85,0.8)",
285
- color: "#fff",
286
- padding: "6px 14px",
287
- borderRadius: "6px",
288
- cursor: "pointer",
289
- fontSize: "13px",
290
- fontWeight: "bold",
291
- display: "flex",
292
- alignItems: "center",
293
- gap: "6px",
294
- boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
295
- width: "fit-content",
296
- transition: "0.2s",
297
- marginLeft: "14px",
298
- marginTop: "14px",
299
- marginBottom: "4px"
300
- });
301
- backBtn.innerHTML = "⬅ " + t('common.back');
302
- backBtn.onmouseover = () => {
303
- backBtn.style.background = "#4CAF50";
304
- backBtn.style.borderColor = "#4CAF50";
305
- };
306
- backBtn.onmouseout = () => {
307
- backBtn.style.background = "rgba(51,51,51,0.8)";
308
- backBtn.style.borderColor = "rgba(85,85,85,0.8)";
309
- };
310
- backBtn.onclick = () => {
311
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
312
- };
313
-
314
- // 组装
315
- container.appendChild(backBtn);
316
- container.appendChild(heroSection);
317
- container.appendChild(advantageSection);
318
- container.appendChild(featureSection);
319
- container.appendChild(guideSection);
320
- container.appendChild(userValueSection);
321
- container.appendChild(warningSection);
322
- container.appendChild(authorSection);
323
-
324
- return container;
325
- }
326
-
327
- // 辅助函数:创建带标题的卡片分区(titleHtml 支持纯文本或 HTML)
328
- function createCardSection(titleHtml, contentHtml) {
329
- const section = document.createElement("div");
330
- Object.assign(section.style, CARD_BASE_STYLE);
331
- section.innerHTML = `
332
- <div style="font-size: 15px; font-weight: bold; color: #00d4aa; margin-bottom: 12px;">${titleHtml}</div>
333
- ${contentHtml}
334
- `;
335
- return section;
336
- }
337
-
338
- // ==========================================
339
- // 🚀 显示关于信息(通过路由系统)
340
- // ==========================================
341
- export async function showAboutInfo(currentUser) {
342
- // 动态获取版本配置
343
- const versionConfig = await getVersionConfig();
344
- const versionString = formatVersionString(versionConfig);
345
- const stageLabel = getStageLabel(versionConfig.stage);
346
-
347
- const view = createAboutView(versionString, stageLabel);
348
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
349
- }
350
-
351
- // 导出版本获取函数供其他组件使用
352
- export { getVersionConfig };
 
1
+ // 关于页面:纯文本内容、共享主题与受控的公开版本刷新。
2
+ import { request } from "../core/网络请求API.js";
3
+ import { UI } from "../core/全局配置.js";
4
+ import { removeCacheByPrefix } from "../core/网络请求_缓存管理.js";
5
+ import { t } from "./用户体验增强.js";
6
+
7
+ let cachedVersionConfig = null;
8
+ let pendingVersionRequest = null;
9
+ let versionGeneration = 0;
10
+ let nextViewId = 0;
11
+
12
+ function normalizeVersion(config) {
13
+ if (!config || typeof config !== "object" || Array.isArray(config)) return null;
14
+ if (!["alpha", "beta", "rc", "stable"].includes(config.stage)) return null;
15
+ if (![config.major, config.minor, config.patch].every(value => Number.isSafeInteger(value) && value >= 0)) return null;
16
+ return { stage: config.stage, major: config.major, minor: config.minor, patch: config.patch };
17
+ }
18
+
19
+ export function clearVersionCache() {
20
+ versionGeneration++;
21
+ cachedVersionConfig = null;
22
+ pendingVersionRequest = null;
23
+ removeCacheByPrefix('api_/api/public/project-version');
24
+ }
25
+
26
+ export async function getVersionConfig() {
27
+ if (cachedVersionConfig) return { ...cachedVersionConfig };
28
+ if (!pendingVersionRequest) {
29
+ const generation = versionGeneration;
30
+ const pending = Promise.resolve().then(() => request('/api/public/project-version',
31
+ generation > 0 ? { noCache: true } : undefined)).then(res => {
32
+ const config = res?.status === "success" ? normalizeVersion(res.data) : null;
33
+ if (!config) throw new Error("Invalid project version");
34
+ if (generation !== versionGeneration) return cachedVersionConfig || UI.DEFAULT_PROJECT_VERSION;
35
+ cachedVersionConfig = Object.freeze(config);
36
+ return config;
37
+ }).catch(() => {
38
+ console.warn('公开版本信息无法刷新');
39
+ return { ...UI.DEFAULT_PROJECT_VERSION };
40
+ }).finally(() => {
41
+ if (pendingVersionRequest === pending) pendingVersionRequest = null;
42
+ });
43
+ pendingVersionRequest = pending;
44
+ }
45
+ return { ...await pendingVersionRequest };
46
+ }
47
+
48
+ export function getStageLabel(stage) {
49
+ switch (stage) {
50
+ case "alpha": return t('about.stage_alpha');
51
+ case "beta": return t('about.stage_beta');
52
+ case "rc": return t('about.stage_rc');
53
+ case "stable": return t('about.stage_stable');
54
+ default: return t('about.stage_unknown');
55
+ }
56
+ }
57
+
58
+ export function formatVersionString(config) {
59
+ const version = normalizeVersion(config);
60
+ if (!version) return t('about.version_unknown');
61
+ return `V${version.major}.${version.minor}.${version.patch} ${getStageLabel(version.stage)}`;
62
+ }
63
+
64
+ function element(tag, className, text) {
65
+ const node = document.createElement(tag);
66
+ if (className) node.className = className;
67
+ if (text !== undefined) node.textContent = text;
68
+ return node;
69
+ }
70
+
71
+ function section(title, children) {
72
+ const node = element('section', 'about-section');
73
+ const heading = element('h2', 'about-section__title', title);
74
+ heading.id = `ranking-about-heading-${++nextViewId}`;
75
+ node.setAttribute('aria-labelledby', heading.id);
76
+ node.append(heading, ...children);
77
+ return node;
78
+ }
79
+
80
+ function grid(entries, ordered = false) {
81
+ const node = element(ordered ? 'ol' : 'ul', 'about-grid');
82
+ for (const [title, description, icon] of entries) {
83
+ const item = element('li', 'about-grid__item');
84
+ if (icon) {
85
+ const decoration = element('span', 'about-grid__icon', icon);
86
+ decoration.setAttribute('aria-hidden', 'true');
87
+ item.appendChild(decoration);
88
+ }
89
+ item.append(element('h3', 'about-item__title', title), element('p', 'about-description', description));
90
+ node.appendChild(item);
91
+ }
92
+ return node;
93
+ }
94
+
95
+ export function createAboutView(versionString, stageLabel) {
96
+ const container = element('div', 'about-view');
97
+ const back = element('button', 'about-back', '⬅ ' + t('common.back'));
98
+ back.type = 'button';
99
+ back.addEventListener('click', () => window.dispatchEvent(new CustomEvent('comfy-route-back')));
100
+ const hero = element('header', 'about-hero');
101
+ const title = element('h1', 'about-hero__title', t('about.title'));
102
+ title.id = `ranking-about-title-${++nextViewId}`;
103
+ container.setAttribute('aria-labelledby', title.id);
104
+ hero.append(title, element('p', 'about-hero__brand', t('about.brand')),
105
+ element('p', 'about-version', typeof versionString === 'string' ? versionString : t('about.version_unknown')),
106
+ element('p', 'about-description', t('about.subtitle')));
107
+ const advantages = section(t('about.advantages_title'), [grid([
108
+ [t('about.adv_native_title'), t('about.adv_native_desc'), '🔗'],
109
+ [t('about.adv_speed_title'), t('about.adv_speed_desc'), '⚡'],
110
+ [t('about.adv_im_title'), t('about.adv_im_desc'), '💬'],
111
+ [t('about.adv_sandbox_title'), t('about.adv_sandbox_desc'), '🛡️']
112
+ ])]);
113
+ const features = section(t('about.features_title'), [grid([
114
+ [t('about.feat_publish_title'), t('about.feat_publish_desc')],
115
+ [t('about.feat_social_title'), t('about.feat_social_desc')],
116
+ [t('about.feat_install_title'), t('about.feat_install_desc')],
117
+ [t('about.feat_download_title'), t('about.feat_download_desc')],
118
+ [t('about.feat_chat_title'), t('about.feat_chat_desc')],
119
+ [t('about.feat_account_title'), t('about.feat_account_desc')],
120
+ [t('about.feat_payment_title'), t('about.feat_payment_desc')],
121
+ [t('about.feat_protection_title'), t('about.feat_protection_desc')]
122
+ ], true)]);
123
+ const guideList = element('ol', 'about-list');
124
+ for (const [heading, description] of [
125
+ [t('about.guide_install_title'), t('about.guide_install_desc')],
126
+ [t('about.guide_path_title'), t('about.guide_path_desc')],
127
+ [t('about.guide_update_title'), t('about.guide_update_desc')],
128
+ [t('about.guide_publish_title'), t('about.guide_publish_desc')],
129
+ [t('about.guide_buy_title'), t('about.guide_buy_desc')],
130
+ [t('about.guide_refund_title'), t('about.guide_refund_desc')],
131
+ [t('about.guide_withdraw_title'), t('about.guide_withdraw_desc')]
132
+ ]) {
133
+ const item = element('li');
134
+ item.append(element('h3', 'about-item__title', heading), element('p', 'about-description', description));
135
+ guideList.appendChild(item);
136
+ }
137
+ const benefits = element('ol', 'about-list');
138
+ for (const text of [t('about.benefit_1'), t('about.benefit_2'), t('about.benefit_3'), t('about.benefit_4'), t('about.benefit_5')]) {
139
+ benefits.appendChild(element('li', 'about-benefit', text));
140
+ }
141
+ const warningCopy = [];
142
+ for (const [heading, description] of [
143
+ [t('about.warning_login_title'), t('about.warning_login_desc')],
144
+ [t('about.warning_account_title'), t('about.warning_account_desc')]
145
+ ]) {
146
+ const paragraph = element('p', 'about-warning__copy');
147
+ paragraph.append(element('strong', '', heading), document.createTextNode(description));
148
+ warningCopy.push(paragraph);
149
+ }
150
+ const warning = section(t('about.warning_title'), warningCopy);
151
+ warning.classList.add('about-section--warning');
152
+ const authorLink = element('a', 'about-author__link');
153
+ authorLink.href = UI.ABOUT.AUTHOR_URL;
154
+ authorLink.target = '_blank';
155
+ authorLink.rel = 'noopener noreferrer';
156
+ const badge = element('img', 'about-author__badge');
157
+ badge.src = UI.ABOUT.AUTHOR_BADGE_URL;
158
+ badge.alt = '';
159
+ badge.loading = 'lazy';
160
+ authorLink.appendChild(badge);
161
+ // 保留文字入口,即使远端徽章图片无法加载也可操作。
162
+ authorLink.appendChild(element('span', '', t('about.author_link')));
163
+ const author = section(t('about.author_title'), [authorLink, element('p', 'about-description', t('about.author_thanks'))]);
164
+ author.classList.add('about-section--author');
165
+ container.append(back, hero, advantages, features, section(t('about.guide_title'), [guideList]),
166
+ section(t('about.benefits_title'), [benefits]), warning, author);
167
+ return container;
168
+ }
169
+
170
+ export async function showAboutInfo(currentUser) {
171
+ const generation = versionGeneration;
172
+ const view = createAboutView(formatVersionString(cachedVersionConfig || UI.DEFAULT_PROJECT_VERSION));
173
+ window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view } }));
174
+ // 先显示完整页面,网络只补新原节点;不在请求完成后重新导航。
175
+ getVersionConfig().then(config => {
176
+ if (view.isConnected && generation === versionGeneration) {
177
+ view.querySelector('.about-version').textContent = formatVersionString(config);
178
+ }
179
+ });
180
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
前端页面/components/动画音效引擎.js CHANGED
@@ -13,6 +13,16 @@
13
 
14
  import { getSettings } from "./全局设置组件.js";
15
 
 
 
 
 
 
 
 
 
 
 
16
  // ==========================================
17
  // 🔊 音效管理
18
  // ==========================================
@@ -360,7 +370,7 @@ function injectAnimationStyles() {
360
  */
361
  export function applyCardAnimation(card, animationType, index, totalVisible = 10, silent = false) {
362
  const settings = getSettings();
363
- if (!settings.enableAnimations) return;
364
 
365
  // 确保样式已注入
366
  injectAnimationStyles();
@@ -436,6 +446,12 @@ export function applyCardAnimation(card, animationType, index, totalVisible = 10
436
  // 容器 -> 观察器映射(同一视图的首次渲染与分页追加复用同一观察器)
437
  const _viewportObservers = new WeakMap();
438
 
 
 
 
 
 
 
439
  /**
440
  * ✨ 视口感知卡片动画:
441
  * - 已在视口内的卡片立即依次错开播放载入动画
@@ -448,23 +464,23 @@ const _viewportObservers = new WeakMap();
448
  */
449
  export function applyViewportAnimations(container, cards, animationType, isFirstBatch = false) {
450
  const settings = getSettings();
451
- if (!settings.enableAnimations) return;
 
 
 
452
 
453
  // 确保样式已注入
454
  injectAnimationStyles();
455
 
456
  // 视图重建:停止上一批残留卡片的补播观察
457
  if (isFirstBatch) {
458
- const oldObserver = _viewportObservers.get(container);
459
- if (oldObserver) {
460
- oldObserver.disconnect();
461
- _viewportObservers.delete(container);
462
- }
463
  }
464
 
465
  let observer = _viewportObservers.get(container);
466
  if (!observer) {
467
  observer = new IntersectionObserver((entries) => {
 
468
  // 本批进入视口的卡片,按文档顺序(从上到下)排列后错开播放
469
  const entering = entries
470
  .filter(e => e.isIntersecting)
 
13
 
14
  import { getSettings } from "./全局设置组件.js";
15
 
16
+ function prefersReducedMotion() {
17
+ try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; }
18
+ catch { return false; }
19
+ }
20
+
21
+ export function scrollCardIntoView(card) {
22
+ const instant = !getSettings().enableAnimations || prefersReducedMotion();
23
+ card.scrollIntoView({ behavior: instant ? 'instant' : 'smooth', block: 'center' });
24
+ }
25
+
26
  // ==========================================
27
  // 🔊 音效管理
28
  // ==========================================
 
370
  */
371
  export function applyCardAnimation(card, animationType, index, totalVisible = 10, silent = false) {
372
  const settings = getSettings();
373
+ if (!settings.enableAnimations || prefersReducedMotion()) return;
374
 
375
  // 确保样式已注入
376
  injectAnimationStyles();
 
446
  // 容器 -> 观察器映射(同一视图的首次渲染与分页追加复用同一观察器)
447
  const _viewportObservers = new WeakMap();
448
 
449
+ export function stopViewportAnimations(container) {
450
+ const observer = _viewportObservers.get(container);
451
+ _viewportObservers.delete(container);
452
+ observer?.disconnect();
453
+ }
454
+
455
  /**
456
  * ✨ 视口感知卡片动画:
457
  * - 已在视口内的卡片立即依次错开播放载入动画
 
464
  */
465
  export function applyViewportAnimations(container, cards, animationType, isFirstBatch = false) {
466
  const settings = getSettings();
467
+ if (!settings.enableAnimations || prefersReducedMotion()) {
468
+ stopViewportAnimations(container);
469
+ return;
470
+ }
471
 
472
  // 确保样式已注入
473
  injectAnimationStyles();
474
 
475
  // 视图重建:停止上一批残留卡片的补播观察
476
  if (isFirstBatch) {
477
+ stopViewportAnimations(container);
 
 
 
 
478
  }
479
 
480
  let observer = _viewportObservers.get(container);
481
  if (!observer) {
482
  observer = new IntersectionObserver((entries) => {
483
+ if (_viewportObservers.get(container) !== observer) return;
484
  // 本批进入视口的卡片,按文档顺序(从上到下)排列后错开播放
485
  const entering = entries
486
  .filter(e => e.isIntersecting)
前端页面/components/卡片导航生命周期.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 按视图管理导航代次,不清理榜单数据或滚动位置。
2
+ export function createCardNavigationScope(target, isCurrent, { captureSession = () => () => true, storageKeys = [] } = {}) {
3
+ let generation = 0;
4
+ let disposed = false;
5
+ let identityRevoked = false;
6
+ const isViewSessionCurrent = captureSession();
7
+ const events = ['comfy-route-view', 'comfy-route-back'];
8
+ const identityEvents = ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired'];
9
+ const invalidate = () => { generation++; };
10
+ const revokeIdentity = () => { identityRevoked = true; invalidate(); };
11
+ const onStorage = event => {
12
+ if (event.key === null || storageKeys.includes(event.key)) revokeIdentity();
13
+ };
14
+ for (const event of events) target.addEventListener(event, invalidate);
15
+ for (const event of identityEvents) target.addEventListener(event, revokeIdentity);
16
+ target.addEventListener('storage', onStorage);
17
+ return {
18
+ capture() {
19
+ const captured = generation;
20
+ const isSessionCurrent = captureSession();
21
+ return () => !disposed && !identityRevoked && captured === generation && isCurrent() && isViewSessionCurrent() && isSessionCurrent();
22
+ },
23
+ dispose() {
24
+ if (disposed) return;
25
+ disposed = true;
26
+ generation++;
27
+ for (const event of events) target.removeEventListener(event, invalidate);
28
+ for (const event of identityEvents) target.removeEventListener(event, revokeIdentity);
29
+ target.removeEventListener('storage', onStorage);
30
+ },
31
+ };
32
+ }
前端页面/components/图片展示测试.js CHANGED
@@ -7,6 +7,7 @@
7
  // ==========================================
8
 
9
  import { escapeHtml } from "../core/全局配置.js";
 
10
 
11
  // ==========================================
12
  // 🎨 CSS样式定义(CSS-in-JS模式)
@@ -176,12 +177,24 @@ const VIEWER_STYLES = `
176
  opacity: 0;
177
  transition: opacity 0.3s ease;
178
  z-index: 15;
 
 
 
 
179
  }
180
 
181
  .cyber-nav-zone:hover {
182
  opacity: 1;
183
  }
184
 
 
 
 
 
 
 
 
 
185
  .cyber-nav-zone.left {
186
  left: 0;
187
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%);
@@ -256,6 +269,9 @@ const VIEWER_STYLES = `
256
  border-radius: 4px;
257
  overflow: hidden;
258
  cursor: pointer;
 
 
 
259
  border: 2px solid transparent;
260
  transition: all 0.3s ease;
261
  opacity: 0.5;
@@ -277,6 +293,11 @@ const VIEWER_STYLES = `
277
  opacity: 1;
278
  }
279
 
 
 
 
 
 
280
  /* 键盘提示 */
281
  .cyber-keyboard-hint {
282
  font-size: 12px;
@@ -393,6 +414,7 @@ class CyberImageViewer {
393
  this._autoPlayTimerId = null;
394
  this._autoPlayInterval = 1000; // 默认1秒展示时间
395
  this._autoPlayStateChangeCallbacks = []; // 状态变化回调
 
396
  }
397
 
398
  /**
@@ -436,11 +458,15 @@ class CyberImageViewer {
436
  // 创建主容器
437
  this.container = document.createElement('div');
438
  this.container.className = 'cyber-image-viewer';
 
 
 
 
439
  this.container.style.background = background;
440
 
441
  this.container.innerHTML = `
442
  <!-- 关闭按钮 -->
443
- <button class="cyber-close-btn" title="关闭 (ESC)">×</button>
444
 
445
  <!-- 图片主容器 -->
446
  <div class="cyber-image-container">
@@ -451,23 +477,23 @@ class CyberImageViewer {
451
  </div>
452
 
453
  <!-- 左右导航区域 -->
454
- <div class="cyber-nav-zone left" title="上一张 (←)">
455
  <div class="cyber-nav-arrow">❮</div>
456
- </div>
457
- <div class="cyber-nav-zone right" title="下一张 (→)">
458
  <div class="cyber-nav-arrow">❯</div>
459
- </div>
460
 
461
  <!-- 底部信息栏 -->
462
  <div class="cyber-info-bar">
463
- <div class="cyber-counter">
464
  <span class="current">1</span>
465
  <span class="separator"> / </span>
466
  <span class="total">${this.imageUrls.length}</span>
467
  </div>
468
  <div class="cyber-thumbnails"></div>
469
  <div style="display: flex; align-items: center; gap: 15px;">
470
- <button class="cyber-autoplay-btn" title="自动播放 (空格)">
471
  <svg viewBox="0 0 24 24" class="autoplay-icon-play">
472
  <path d="M8 5v14l11-7z"/>
473
  </svg>
@@ -476,9 +502,9 @@ class CyberImageViewer {
476
  </svg>
477
  </button>
478
  <div class="cyber-keyboard-hint">
479
- <span><span class="cyber-key">←</span> 上一张</span>
480
- <span><span class="cyber-key">→</span> 下一张</span>
481
- <span><span class="cyber-key">ESC</span> 关闭</span>
482
  </div>
483
  </div>
484
  </div>
@@ -587,12 +613,12 @@ class CyberImageViewer {
587
 
588
  if (this._autoPlayEnabled) {
589
  btn.classList.add('active');
590
- btn.title = '暂停自动播放 (空格)';
591
  playIcon.style.display = 'none';
592
  pauseIcon.style.display = 'block';
593
  } else {
594
  btn.classList.remove('active');
595
- btn.title = '自动播放 (空格)';
596
  playIcon.style.display = 'block';
597
  pauseIcon.style.display = 'none';
598
  }
@@ -736,6 +762,18 @@ class CyberImageViewer {
736
  _bindEvents() {
737
  // 键盘导航
738
  this._keydownHandler = (e) => {
 
 
 
 
 
 
 
 
 
 
 
 
739
  if (e.key === 'Escape') {
740
  this.close();
741
  } else if (e.key === 'ArrowLeft') {
@@ -785,8 +823,11 @@ class CyberImageViewer {
785
  const maxThumbs = Math.min(this.imageUrls.length, 7);
786
 
787
  for (let i = 0; i < maxThumbs; i++) {
788
- const thumb = document.createElement('div');
 
789
  thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
 
 
790
  // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
791
  thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
792
  thumb.addEventListener('click', (e) => {
@@ -812,6 +853,7 @@ class CyberImageViewer {
812
  const thumbs = this.elements.thumbnails.querySelectorAll('.cyber-thumb');
813
  thumbs.forEach((thumb, index) => {
814
  thumb.classList.toggle('active', index === this.currentIndex);
 
815
  });
816
  }
817
 
@@ -851,6 +893,7 @@ class CyberImageViewer {
851
 
852
  // 更新计数器
853
  this.elements.counterCurrent.textContent = index + 1;
 
854
  this._updateThumbnails();
855
 
856
  // 预加载相邻图片
@@ -863,13 +906,13 @@ class CyberImageViewer {
863
  _loadImageAsync(url) {
864
  return new Promise((resolve, reject) => {
865
  const img = new Image();
866
- console.log('📥 开始加载图片:', url?.substring(0, 100) + '...');
867
  img.onload = () => {
868
- console.log('✅ 图片加载成功:', url?.substring(0, 100) + '...');
869
  resolve(img);
870
  };
871
  img.onerror = (err) => {
872
- console.error('❌ 图片加载失败:', url?.substring(0, 100) + '...', err);
873
  reject(new Error('图片加载失败'));
874
  };
875
  img.src = url;
@@ -911,7 +954,7 @@ class CyberImageViewer {
911
  const imgSrc = img.src;
912
 
913
  // 调试日志
914
- console.log('🎬 方块聚合动画开始 - 图片URL:', imgSrc?.substring(0, 100) + '...');
915
 
916
  // 获取wrapper的实际尺寸
917
  const rect = wrapper.getBoundingClientRect();
@@ -1530,6 +1573,8 @@ class CyberImageViewer {
1530
  * 关闭查看器
1531
  */
1532
  close() {
 
 
1533
  // 停止自动播放并清除定时器
1534
  this._stopAutoPlay();
1535
 
@@ -1546,6 +1591,7 @@ class CyberImageViewer {
1546
  if (this.container.parentNode) {
1547
  this.container.parentNode.removeChild(this.container);
1548
  }
 
1549
  }, 300);
1550
  });
1551
  }
@@ -1571,13 +1617,13 @@ export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
1571
  }
1572
 
1573
  console.log('🎬 openImageViewer 被调用:', 'URL数量:', imageUrls.length, '起始索引:', startIndex);
1574
- console.log('🖼️ 图片URLs:', imageUrls.map(u => u?.substring(0, 80) + '...'));
1575
 
1576
  const viewer = new CyberImageViewer(imageUrls, startIndex);
1577
  const container = viewer.create(options);
1578
 
1579
  // 添加到body
1580
  document.body.appendChild(container);
 
1581
 
1582
  // 添加入场动画
1583
  requestAnimationFrame(() => {
 
7
  // ==========================================
8
 
9
  import { escapeHtml } from "../core/全局配置.js";
10
+ import { t } from "./用户体验增强.js";
11
 
12
  // ==========================================
13
  // 🎨 CSS样式定义(CSS-in-JS模式)
 
177
  opacity: 0;
178
  transition: opacity 0.3s ease;
179
  z-index: 15;
180
+ border: 0;
181
+ padding: 0;
182
+ color: inherit;
183
+ font: inherit;
184
  }
185
 
186
  .cyber-nav-zone:hover {
187
  opacity: 1;
188
  }
189
 
190
+ .cyber-nav-zone:focus-visible,
191
+ .cyber-close-btn:focus-visible,
192
+ .cyber-autoplay-btn:focus-visible {
193
+ opacity: 1;
194
+ outline: 3px solid #00ff96;
195
+ outline-offset: -4px;
196
+ }
197
+
198
  .cyber-nav-zone.left {
199
  left: 0;
200
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%);
 
269
  border-radius: 4px;
270
  overflow: hidden;
271
  cursor: pointer;
272
+ border: 0;
273
+ padding: 0;
274
+ background: transparent;
275
  border: 2px solid transparent;
276
  transition: all 0.3s ease;
277
  opacity: 0.5;
 
293
  opacity: 1;
294
  }
295
 
296
+ .cyber-thumb:focus-visible {
297
+ outline: 3px solid #00ff96;
298
+ outline-offset: 2px;
299
+ }
300
+
301
  /* 键盘提示 */
302
  .cyber-keyboard-hint {
303
  font-size: 12px;
 
414
  this._autoPlayTimerId = null;
415
  this._autoPlayInterval = 1000; // 默认1秒展示时间
416
  this._autoPlayStateChangeCallbacks = []; // 状态变化回调
417
+ this._returnFocus = document.activeElement;
418
  }
419
 
420
  /**
 
458
  // 创建主容器
459
  this.container = document.createElement('div');
460
  this.container.className = 'cyber-image-viewer';
461
+ this.container.setAttribute('role', 'dialog');
462
+ this.container.setAttribute('aria-modal', 'true');
463
+ this.container.setAttribute('aria-label', t('image.viewer.dialog'));
464
+ this.container.tabIndex = -1;
465
  this.container.style.background = background;
466
 
467
  this.container.innerHTML = `
468
  <!-- 关闭按钮 -->
469
+ <button type="button" class="cyber-close-btn" title="${escapeHtml(t('image.viewer.close'))}" aria-label="${escapeHtml(t('image.viewer.close'))}">×</button>
470
 
471
  <!-- 图片主容器 -->
472
  <div class="cyber-image-container">
 
477
  </div>
478
 
479
  <!-- 左右导航区域 -->
480
+ <button type="button" class="cyber-nav-zone left" title="${escapeHtml(t('image.viewer.previous'))}" aria-label="${escapeHtml(t('image.viewer.previous'))}">
481
  <div class="cyber-nav-arrow">❮</div>
482
+ </button>
483
+ <button type="button" class="cyber-nav-zone right" title="${escapeHtml(t('image.viewer.next'))}" aria-label="${escapeHtml(t('image.viewer.next'))}">
484
  <div class="cyber-nav-arrow">❯</div>
485
+ </button>
486
 
487
  <!-- 底部信息栏 -->
488
  <div class="cyber-info-bar">
489
+ <div class="cyber-counter" role="status" aria-live="polite" aria-atomic="true">
490
  <span class="current">1</span>
491
  <span class="separator"> / </span>
492
  <span class="total">${this.imageUrls.length}</span>
493
  </div>
494
  <div class="cyber-thumbnails"></div>
495
  <div style="display: flex; align-items: center; gap: 15px;">
496
+ <button type="button" class="cyber-autoplay-btn" title="${escapeHtml(t('image.viewer.autoplay'))}" aria-label="${escapeHtml(t('image.viewer.autoplay'))}">
497
  <svg viewBox="0 0 24 24" class="autoplay-icon-play">
498
  <path d="M8 5v14l11-7z"/>
499
  </svg>
 
502
  </svg>
503
  </button>
504
  <div class="cyber-keyboard-hint">
505
+ <span><span class="cyber-key">←</span> ${escapeHtml(t('image.viewer.previous_short'))}</span>
506
+ <span><span class="cyber-key">→</span> ${escapeHtml(t('image.viewer.next_short'))}</span>
507
+ <span><span class="cyber-key">ESC</span> ${escapeHtml(t('image.viewer.close_short'))}</span>
508
  </div>
509
  </div>
510
  </div>
 
613
 
614
  if (this._autoPlayEnabled) {
615
  btn.classList.add('active');
616
+ btn.title = t('image.viewer.pause');
617
  playIcon.style.display = 'none';
618
  pauseIcon.style.display = 'block';
619
  } else {
620
  btn.classList.remove('active');
621
+ btn.title = t('image.viewer.autoplay');
622
  playIcon.style.display = 'block';
623
  pauseIcon.style.display = 'none';
624
  }
 
762
  _bindEvents() {
763
  // 键盘导航
764
  this._keydownHandler = (e) => {
765
+ if (e.key === 'Tab') {
766
+ const controls = [...this.container.querySelectorAll('button:not([disabled]), [tabindex="0"]')];
767
+ if (!controls.length) { e.preventDefault(); this.container.focus(); return; }
768
+ const first = controls[0];
769
+ const last = controls[controls.length - 1];
770
+ if (e.shiftKey && (document.activeElement === first || !this.container.contains(document.activeElement))) {
771
+ e.preventDefault(); last.focus();
772
+ } else if (!e.shiftKey && (document.activeElement === last || !this.container.contains(document.activeElement))) {
773
+ e.preventDefault(); first.focus();
774
+ }
775
+ return;
776
+ }
777
  if (e.key === 'Escape') {
778
  this.close();
779
  } else if (e.key === 'ArrowLeft') {
 
823
  const maxThumbs = Math.min(this.imageUrls.length, 7);
824
 
825
  for (let i = 0; i < maxThumbs; i++) {
826
+ const thumb = document.createElement('button');
827
+ thumb.type = 'button';
828
  thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
829
+ thumb.setAttribute('aria-label', t('image.viewer.image_position', { current: i + 1, total: this.imageUrls.length }));
830
+ thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false');
831
  // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
832
  thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
833
  thumb.addEventListener('click', (e) => {
 
853
  const thumbs = this.elements.thumbnails.querySelectorAll('.cyber-thumb');
854
  thumbs.forEach((thumb, index) => {
855
  thumb.classList.toggle('active', index === this.currentIndex);
856
+ thumb.setAttribute('aria-current', index === this.currentIndex ? 'true' : 'false');
857
  });
858
  }
859
 
 
893
 
894
  // 更新计数器
895
  this.elements.counterCurrent.textContent = index + 1;
896
+ this.elements.mainImage.alt = t('image.viewer.image_position', { current: index + 1, total: this.imageUrls.length });
897
  this._updateThumbnails();
898
 
899
  // 预加载相邻图片
 
906
  _loadImageAsync(url) {
907
  return new Promise((resolve, reject) => {
908
  const img = new Image();
909
+ console.log('📥 开始加载图片');
910
  img.onload = () => {
911
+ console.log('✅ 图片加载成功');
912
  resolve(img);
913
  };
914
  img.onerror = (err) => {
915
+ console.error('❌ 图片加载失败:', err?.name || 'Error');
916
  reject(new Error('图片加载失败'));
917
  };
918
  img.src = url;
 
954
  const imgSrc = img.src;
955
 
956
  // 调试日志
957
+ console.log('🎬 方块聚合动画开始');
958
 
959
  // 获取wrapper的实际尺寸
960
  const rect = wrapper.getBoundingClientRect();
 
1573
  * 关闭查看器
1574
  */
1575
  close() {
1576
+ if (this._isClosing) return;
1577
+ this._isClosing = true;
1578
  // 停止自动播放并清除定时器
1579
  this._stopAutoPlay();
1580
 
 
1591
  if (this.container.parentNode) {
1592
  this.container.parentNode.removeChild(this.container);
1593
  }
1594
+ if (this._returnFocus?.isConnected && typeof this._returnFocus.focus === 'function') this._returnFocus.focus();
1595
  }, 300);
1596
  });
1597
  }
 
1617
  }
1618
 
1619
  console.log('🎬 openImageViewer 被调用:', 'URL数量:', imageUrls.length, '起始索引:', startIndex);
 
1620
 
1621
  const viewer = new CyberImageViewer(imageUrls, startIndex);
1622
  const container = viewer.create(options);
1623
 
1624
  // 添加到body
1625
  document.body.appendChild(container);
1626
+ viewer.elements.closeBtn.focus();
1627
 
1628
  // 添加入场动画
1629
  requestAnimationFrame(() => {
前端页面/components/图片沙盒组件.js CHANGED
@@ -111,7 +111,7 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
111
  <button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
112
  <button class="btn-zoom-reset" style="background: #333; color: #fff; border: 1px solid #555; height: 28px; padding: 0 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">${t('common.reset')}</button>
113
  <button class="btn-zoom-in" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">+</button>
114
- <button class="btn-fullscreen" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; font-size: 14px; margin-left: 5px;" title="全屏播放">⛶</button>
115
  </div>
116
  <div class="sliders-wrapper" style="opacity: 0.2; transition: 0.3s; position: absolute; width: 100%; height: 100%; top:0; left:0; pointer-events: none;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=0.2">
117
  <input type="range" class="slider-x" min="-400" max="400" value="0" style="position: absolute; bottom: 0px; left: 50%; transform: translateX(-50%); width: 80%; pointer-events: auto; z-index: 10;">
@@ -262,7 +262,7 @@ export function setupImageSandboxEvents(detailView) {
262
  const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10);
263
 
264
  // 🔍 调试日志:输出收集到的图片URL
265
- console.log('🖼️ 全屏播放图片:', imageUrls, '当前索引:', currentIndex);
266
 
267
  if (imageUrls.length > 0) {
268
  // 打开全屏图像播放器(使用静态import,避免动态import路径问题)
@@ -272,11 +272,11 @@ export function setupImageSandboxEvents(detailView) {
272
  console.error('无法打开图像播放器:', err);
273
  // 显示用户可见的错误提示
274
  import('./UI交互提示组件.js').then(module => {
275
- module.showToast('图像播放器加载失败,请刷新页面重试', 'error');
276
  }).catch(() => {
277
  // 动态import也失败时,创建简单的内联提示
278
  const fallback = document.createElement('div');
279
- fallback.textContent = '图像播放器加载失败,请刷新页面重试';
280
  Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
281
  document.body.appendChild(fallback);
282
  setTimeout(() => fallback.remove(), 3000);
 
111
  <button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
112
  <button class="btn-zoom-reset" style="background: #333; color: #fff; border: 1px solid #555; height: 28px; padding: 0 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">${t('common.reset')}</button>
113
  <button class="btn-zoom-in" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">+</button>
114
+ <button class="btn-fullscreen" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; font-size: 14px; margin-left: 5px;" title="${escapeHtml(t('image.viewer.fullscreen'))}">⛶</button>
115
  </div>
116
  <div class="sliders-wrapper" style="opacity: 0.2; transition: 0.3s; position: absolute; width: 100%; height: 100%; top:0; left:0; pointer-events: none;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=0.2">
117
  <input type="range" class="slider-x" min="-400" max="400" value="0" style="position: absolute; bottom: 0px; left: 50%; transform: translateX(-50%); width: 80%; pointer-events: auto; z-index: 10;">
 
262
  const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10);
263
 
264
  // 🔍 调试日志:输出收集到的图片URL
265
+ console.log('🖼️ 全屏播放图片:', '图片数量:', imageUrls.length, '当前索引:', currentIndex);
266
 
267
  if (imageUrls.length > 0) {
268
  // 打开全屏图像播放器(使用静态import,避免动态import路径问题)
 
272
  console.error('无法打开图像播放器:', err);
273
  // 显示用户可见的错误提示
274
  import('./UI交互提示组件.js').then(module => {
275
+ module.showToast(t('image.viewer.load_failed'), 'error');
276
  }).catch(() => {
277
  // 动态import也失败时,创建简单的内联提示
278
  const fallback = document.createElement('div');
279
+ fallback.textContent = t('image.viewer.load_failed');
280
  Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
281
  document.body.appendChild(fallback);
282
  setTimeout(() => fallback.remove(), 3000);
前端页面/components/图片裁剪组件.js CHANGED
@@ -35,8 +35,10 @@ function _isTouchDevice() {
35
  * @param {number} maxSizeMB - 最大输出大小(MB)
36
  * @returns {Promise<File|null>} - 裁剪后的文件或 null(取消)
37
  */
38
- export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片", maxSizeMB = 3) {
39
  return new Promise((resolve) => {
 
 
40
  // 创建遮罩层
41
  const overlay = document.createElement("div");
42
  Object.assign(overlay.style, {
@@ -47,6 +49,11 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
47
 
48
  // 创建弹窗容器
49
  const modal = document.createElement("div");
 
 
 
 
 
50
  Object.assign(modal.style, {
51
  background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px",
52
  maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column",
@@ -66,8 +73,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
66
  // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
67
  const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
68
  header.innerHTML = `
69
- <span style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
70
- <span style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
71
  `;
72
 
73
  // 裁剪区域容器
@@ -166,8 +173,28 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
166
  // 🔧 P3优化:ESC 键关闭
167
  function handleKeyDown(e) {
168
  if (e.key === 'Escape') {
 
169
  cleanup();
170
  resolve(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  }
172
  }
173
  document.addEventListener("keydown", handleKeyDown);
@@ -308,8 +335,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
308
  display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px"
309
  });
310
  sliderContainer.innerHTML = `
311
- <span style="color: #888; font-size: 12px;">🔍 缩放</span>
312
- <input type="range" id="crop-scale-slider" min="100" max="500" value="100"
313
  style="flex: 1; accent-color: #4CAF50;">
314
  <span id="crop-scale-value" style="color: #4CAF50; font-size: 12px; min-width: 40px;">100%</span>
315
  `;
@@ -320,8 +347,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
320
  display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end"
321
  });
322
  buttonContainer.innerHTML = `
323
- <button id="btn-crop-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">取消</button>
324
- <button id="btn-crop-confirm" style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️ 确认裁剪并上传</button>
325
  `;
326
 
327
  // 组装DOM
@@ -332,6 +359,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
332
  modal.appendChild(buttonContainer);
333
  overlay.appendChild(modal);
334
  document.body.appendChild(overlay);
 
335
 
336
  // 绑定缩放滑块事件
337
  setTimeout(() => {
@@ -345,6 +373,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
345
 
346
  scale = minScale * (parseInt(slider.value) / 100);
347
  scaleValue.textContent = `${slider.value}%`;
 
348
  updateImagePosition();
349
  };
350
 
@@ -357,7 +386,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
357
  // 确认裁剪按钮
358
  modal.querySelector("#btn-crop-confirm").onclick = async () => {
359
  const btn = modal.querySelector("#btn-crop-confirm");
360
- btn.textContent = "⏳ 处理中...";
361
  btn.disabled = true;
362
 
363
  try {
@@ -365,7 +394,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
365
  cleanup();
366
  resolve(croppedFile);
367
  } catch (err) {
368
- btn.textContent = "❌ 处理失败,重试";
369
  btn.disabled = false;
370
  console.error("裁剪失败:", err);
371
  }
@@ -385,6 +414,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
385
  cropContainer.removeEventListener("touchend", handleTouchEnd);
386
  cropContainer.removeEventListener("touchcancel", handleTouchEnd);
387
  overlay.remove();
 
388
  }
389
 
390
  // 点击遮罩关闭
 
35
  * @param {number} maxSizeMB - 最大输出大小(MB)
36
  * @returns {Promise<File|null>} - 裁剪后的文件或 null(取消)
37
  */
38
+ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title'), maxSizeMB = 3) {
39
  return new Promise((resolve) => {
40
+ const previousFocus = document.activeElement;
41
+ const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
  // 创建遮罩层
43
  const overlay = document.createElement("div");
44
  Object.assign(overlay.style, {
 
49
 
50
  // 创建弹窗容器
51
  const modal = document.createElement("div");
52
+ modal.setAttribute("role", "dialog");
53
+ modal.setAttribute("aria-modal", "true");
54
+ modal.setAttribute("aria-labelledby", `${dialogId}-title`);
55
+ modal.setAttribute("aria-describedby", `${dialogId}-hint`);
56
+ modal.setAttribute("tabindex", "-1");
57
  Object.assign(modal.style, {
58
  background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px",
59
  maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column",
 
73
  // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
74
  const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
75
  header.innerHTML = `
76
+ <span id="${dialogId}-title" role="heading" aria-level="2" style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
77
+ <span id="${dialogId}-hint" style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
78
  `;
79
 
80
  // 裁剪区域容器
 
173
  // 🔧 P3优化:ESC 键关闭
174
  function handleKeyDown(e) {
175
  if (e.key === 'Escape') {
176
+ e.preventDefault();
177
  cleanup();
178
  resolve(null);
179
+ return;
180
+ }
181
+ if (e.key !== 'Tab') return;
182
+ const focusable = Array.from(modal.querySelectorAll(
183
+ 'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])'
184
+ ));
185
+ if (!focusable.length) {
186
+ e.preventDefault();
187
+ modal.focus();
188
+ return;
189
+ }
190
+ const first = focusable[0];
191
+ const last = focusable[focusable.length - 1];
192
+ if (e.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) {
193
+ e.preventDefault();
194
+ last.focus();
195
+ } else if (!e.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) {
196
+ e.preventDefault();
197
+ first.focus();
198
  }
199
  }
200
  document.addEventListener("keydown", handleKeyDown);
 
335
  display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px"
336
  });
337
  sliderContainer.innerHTML = `
338
+ <span style="color: #888; font-size: 12px;">🔍 ${escapeHtml(t('crop.zoom_label'))}</span>
339
+ <input type="range" id="crop-scale-slider" min="100" max="500" value="100" aria-label="${escapeHtml(t('crop.zoom_label'))}" aria-valuetext="100%"
340
  style="flex: 1; accent-color: #4CAF50;">
341
  <span id="crop-scale-value" style="color: #4CAF50; font-size: 12px; min-width: 40px;">100%</span>
342
  `;
 
347
  display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end"
348
  });
349
  buttonContainer.innerHTML = `
350
+ <button id="btn-crop-cancel" type="button" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">${escapeHtml(t('common.cancel'))}</button>
351
+ <button id="btn-crop-confirm" type="button" style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️ ${escapeHtml(t('crop.confirm'))}</button>
352
  `;
353
 
354
  // 组装DOM
 
359
  modal.appendChild(buttonContainer);
360
  overlay.appendChild(modal);
361
  document.body.appendChild(overlay);
362
+ modal.querySelector("#btn-crop-cancel").focus();
363
 
364
  // 绑定缩放滑块事件
365
  setTimeout(() => {
 
373
 
374
  scale = minScale * (parseInt(slider.value) / 100);
375
  scaleValue.textContent = `${slider.value}%`;
376
+ slider.setAttribute("aria-valuetext", `${slider.value}%`);
377
  updateImagePosition();
378
  };
379
 
 
386
  // 确认裁剪按钮
387
  modal.querySelector("#btn-crop-confirm").onclick = async () => {
388
  const btn = modal.querySelector("#btn-crop-confirm");
389
+ btn.textContent = `⏳ ${t('crop.processing')}`;
390
  btn.disabled = true;
391
 
392
  try {
 
394
  cleanup();
395
  resolve(croppedFile);
396
  } catch (err) {
397
+ btn.textContent = t('crop.process_failed');
398
  btn.disabled = false;
399
  console.error("裁剪失败:", err);
400
  }
 
414
  cropContainer.removeEventListener("touchend", handleTouchEnd);
415
  cropContainer.removeEventListener("touchcancel", handleTouchEnd);
416
  overlay.remove();
417
+ if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
418
  }
419
 
420
  // 点击遮罩关闭
前端页面/components/彩蛋动画引擎.js CHANGED
@@ -8,9 +8,8 @@
8
  /**
9
  * 在新标签页打开彩蛋动画页面
10
  */
11
- export function openEasterEggPage() {
12
- const html = generateFullHTML();
13
- const newWindow = window.open('', '_blank');
14
  if (newWindow) {
15
  newWindow.document.write(html);
16
  newWindow.document.close();
@@ -20,13 +19,15 @@ export function openEasterEggPage() {
20
  /**
21
  * 生成完整的彩蛋动画HTML页面
22
  */
23
- function generateFullHTML() {
 
 
24
  return `<!DOCTYPE html>
25
- <html lang="zh-CN">
26
  <head>
27
  <meta charset="UTF-8">
28
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
29
- <title>🐷 猪的飞行梦</title>
30
  <style>
31
  * {
32
  margin: 0;
@@ -70,9 +71,10 @@ function generateFullHTML() {
70
  </head>
71
  <body>
72
  <canvas id="canvas"></canvas>
73
- <div id="hint">鼠标移动改变视角 | PgUp加速 PgDn减速 | ESC关闭</div>
74
- <div id="speed-indicator">速度: 1.0x</div>
75
  <script>
 
76
  // ==================== 配置参数 ====================
77
  // 千字文(用于星空星星)
78
  const QIANZIWEN = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔龙师火帝鸟官人皇始制文字乃服衣裳推位让国有虞陶唐吊民伐罪周发殷汤坐朝问道垂拱平章爱育黎首臣伏戎羌遐迩一体率宾归王鸣凤在竹白驹食场化被草木赖及万方盖此身发四大五常恭惟鞠养岂敢毁伤女慕贞洁男效才良知过必改得能莫忘罔谈彼短靡恃己长信使可覆器欲难量墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映容止若思言辞安定笃初诚美慎终宜令荣业所基籍甚无竟学优登仕摄职从政存以甘棠去而益咏乐殊贵贱礼别尊卑上和下睦夫唱妇随外受傅训入奉母仪诸姑伯叔犹子比儿孔怀兄弟同气连枝交友投分切磨箴规仁慈隐恻造次弗离节义廉退颠沛匪亏性静情逸心动神疲守真志满逐物意移坚持雅操好爵自縻都邑华夏东西二京背邙面洛浮渭据泾宫殿盘郁楼观飞惊图写禽兽画彩仙灵丙舍旁启甲帐对楹肆筵设席鼓瑟吹笙升阶纳陛弁转疑星右通广内左达承明既集坟典亦聚群英杜稿钟隶漆书壁经府罗将相路侠槐卿户封八县家给千兵高冠陪辇驱毂振缨世禄侈富车驾肥轻策功茂实勒碑刻铭盘溪伊尹佐时阿衡奄宅曲阜微旦孰营桓公匡合济弱扶倾绮回汉惠说感武丁俊乂密勿多士寔宁晋楚更霸赵魏困横假途灭虢践土会盟何遵约法韩弊烦刑起翦颇牧用军最精宣威沙漠驰誉丹青九州禹迹百郡秦并岳宗泰岱禅主云亭雁门紫塞鸡田赤城昆池碣石巨野洞庭旷远绵邈岩岫杳冥治本于农务资稼穑俶载南亩我艺黍稷税熟贡新劝赏黜陟孟轲敦素史鱼秉直庶几中庸劳谦谨敕聆音察理鉴貌辨色贻厥嘉猷勉其祗植省躬讥诫宠增抗极殆辱近耻林皋幸即两疏见机解组谁逼索居闲处沉默寂寥求古寻论散虑逍遥欣奏累遣戚谢欢招渠荷的历园莽抽条枇杷晚翠梧桐早凋陈根委翳落叶飘摇游鹍独运凌摩绛霄耽读玩市寓目囊箱易輶攸畏属耳垣墙具膳餐饭适口充肠饱饫烹宰饥厌糟糠亲戚故旧老少异粮妾御绩纺侍巾帷房纨扇圆洁银烛炜煌昼眠夕寐蓝笋象床弦歌酒宴接杯举觞矫手顿足悦豫且康嫡后嗣续祭祀烝尝稽颡再拜悚惧恐惶笺牒简要顾答审详骸垢想浴执热愿凉驴骡犊特骇跃超骧诛斩贼盗捕获叛亡布射僚丸嵇琴阮啸恬笔伦纸钧巧任钓释纷利俗并皆佳妙毛施淑姿工颦妍笑年矢每催曦晖朗曜璇玑悬斡晦魄环照指薪修祜永绥吉劭矩步引领俯仰廊庙束带矜庄徘徊瞻眺孤陋寡闻愚蒙等诮谓语助者焉哉乎也';
@@ -763,7 +765,7 @@ function generateFullHTML() {
763
  cx = lerp(cx, targetCx, ease1);
764
  cy = lerp(cy, targetCy, ease1);
765
  currentSpeed = lerp(currentSpeed, targetSpeed, 0.05);
766
- speedIndicator.textContent = \`速度: \${currentSpeed.toFixed(1)}x\`;
767
  }
768
  }
769
 
 
8
  /**
9
  * 在新标签页打开彩蛋动画页面
10
  */
11
+ export function openEasterEggPage(newWindow, { language, title, hint, speedLabel } = {}) {
12
+ const html = generateFullHTML({ language, title, hint, speedLabel });
 
13
  if (newWindow) {
14
  newWindow.document.write(html);
15
  newWindow.document.close();
 
19
  /**
20
  * 生成完整的彩蛋动画HTML页面
21
  */
22
+ function generateFullHTML({ language = 'zh-CN', title = '猪的飞行梦', hint = '鼠标移动改变视角 | PgUp加速 PgDn减速 | ESC关闭', speedLabel = '速度' } = {}) {
23
+ const safeLanguage = language === 'en-US' ? 'en-US' : 'zh-CN';
24
+ const speedLabelLiteral = JSON.stringify(speedLabel);
25
  return `<!DOCTYPE html>
26
+ <html lang="${safeLanguage}">
27
  <head>
28
  <meta charset="UTF-8">
29
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
30
+ <title>🐷 ${title}</title>
31
  <style>
32
  * {
33
  margin: 0;
 
71
  </head>
72
  <body>
73
  <canvas id="canvas"></canvas>
74
+ <div id="hint">${hint}</div>
75
+ <div id="speed-indicator">${speedLabel}: 1.0x</div>
76
  <script>
77
+ const SPEED_LABEL = ${speedLabelLiteral};
78
  // ==================== 配置参数 ====================
79
  // 千字文(用于星空星星)
80
  const QIANZIWEN = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔龙师火帝鸟官人皇始制文字乃服衣裳推位让国有虞陶唐吊民伐罪周发殷汤坐朝问道垂拱平章爱育黎首臣伏戎羌遐迩一体率宾归王鸣凤在竹白驹食场化被草木赖及万方盖此身发四大五常恭惟鞠养岂敢毁伤女慕贞洁男效才良知过必改得能莫忘罔谈彼短靡恃己长信使可覆器欲难量墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映容止若思言辞安定笃初诚美慎终宜令荣业所基籍甚无竟学优登仕摄职从政存以甘棠去而益咏乐殊贵贱礼别尊卑上和下睦夫唱妇随外受傅训入奉母仪诸姑伯叔犹子比儿孔怀兄弟同气连枝交友投分切磨箴规仁慈隐恻造次弗离节义廉退颠沛匪亏性静情逸心动神疲守真志满逐物意移坚持雅操好爵自縻都邑华夏东西二京背邙面洛浮渭据泾宫殿盘郁楼观飞惊图写禽兽画彩仙灵丙舍旁启甲帐对楹肆筵设席鼓瑟吹笙升阶纳陛弁转疑星右通广内左达承明既集坟典亦聚群英杜稿钟隶漆书壁经府罗将相路侠槐卿户封八县家给千兵高冠陪辇驱毂振缨世禄侈富车驾肥轻策功茂实勒碑刻铭盘溪伊尹佐时阿衡奄宅曲阜微旦孰营桓公匡合济弱扶倾绮回汉惠说感武丁俊乂密勿多士寔宁晋楚更霸赵魏困横假途灭虢践土会盟何遵约法韩弊烦刑起翦颇牧用军最精宣威沙漠驰誉丹青九州禹迹百郡秦并岳宗泰岱禅主云亭雁门紫塞鸡田赤城昆池碣石巨野洞庭旷远绵邈岩岫杳冥治本于农务资稼穑俶载南亩我艺黍稷税熟贡新劝赏黜陟孟轲敦素史鱼秉直庶几中庸劳谦谨敕聆音察理鉴貌辨色贻厥嘉猷勉其祗植省躬讥诫宠增抗极殆辱近耻林皋幸即两疏见机解组谁逼索居闲处沉默寂寥求古寻论散虑逍遥欣奏累遣戚谢欢招渠荷的历园莽抽条枇杷晚翠梧桐早凋陈根委翳落叶飘摇游鹍独运凌摩绛霄耽读玩市寓目囊箱易輶攸畏属耳垣墙具膳餐饭适口充肠饱饫烹宰饥厌糟糠亲戚故旧老少异粮妾御绩纺侍巾帷房纨扇圆洁银烛炜煌昼眠夕寐蓝笋象床弦歌酒宴接杯举觞矫手顿足悦豫且康嫡后嗣续祭祀烝尝稽颡再拜悚惧恐惶笺牒简要顾答审详骸垢想浴执热愿凉驴骡犊特骇跃超骧诛斩贼盗捕获叛亡布射僚丸嵇琴阮啸恬笔伦纸钧巧任钓释纷利俗并皆佳妙毛施淑姿工颦妍笑年矢每催曦晖朗曜璇玑悬斡晦魄环照指薪修祜永绥吉劭矩步引领俯仰廊庙束带矜庄徘徊瞻眺孤陋寡闻愚蒙等诮谓语助者焉哉乎也';
 
765
  cx = lerp(cx, targetCx, ease1);
766
  cy = lerp(cy, targetCy, ease1);
767
  currentSpeed = lerp(currentSpeed, targetSpeed, 0.05);
768
+ speedIndicator.textContent = SPEED_LABEL + ': ' + currentSpeed.toFixed(1) + 'x';
769
  }
770
  }
771
 
前端页面/components/性能优化工具.js CHANGED
@@ -18,7 +18,7 @@
18
  import { PAGINATION } from "../core/全局配置.js";
19
  // 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
20
  // 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
21
- import { setCache, getCacheWithMeta } from "../core/网络请求_缓存管理.js";
22
 
23
  // ==========================================
24
  // 🔒 P0安全优化:敏感数据脱敏工具
@@ -216,8 +216,10 @@ export { setCache, getCache, getCacheWithMeta, removeCache, removeCacheByPrefix,
216
  * @param {Object} options - 配置选项
217
  * @param {HTMLElement} options.container - 滚动容器
218
  * @param {Function} options.loadMore - 加载更多数据的函数,返回 Promise
 
219
  * @param {number} options.pageSize - 每页数量,默认 20
220
  * @param {number} options.threshold - 触发阈值(距底部像素),默认 200
 
221
  * @returns {Object} 分页加载器实例
222
  */
223
  export function createPaginationLoader(options) {
@@ -227,42 +229,58 @@ export function createPaginationLoader(options) {
227
  pageSize = 20,
228
  threshold = 200
229
  } = options;
 
 
230
 
231
  let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载
232
  let isLoading = false;
233
  let hasMore = true;
234
  let debounceTimer = null;
235
- let consecutiveErrors = 0;
236
- let retryTimer = null;
237
  let generation = 0; // 🔄 轻量级取消令牌,reset 时递增以丢弃飞行中请求
 
238
 
239
  // 加载指示器
240
  const loadingIndicator = document.createElement("div");
241
  loadingIndicator.className = "pagination-loading";
242
- loadingIndicator.innerHTML = `
243
- <div style="text-align: center; padding: 20px; color: #888;">
244
- <span class="loading-spinner"></span>
245
- <span style="margin-left: 8px;">加载中...</span>
246
- </div>
247
- `;
 
 
 
248
  loadingIndicator.style.display = "none";
249
 
250
  // "没有更多"提示
251
  const endIndicator = document.createElement("div");
252
  endIndicator.className = "pagination-end";
253
- endIndicator.innerHTML = `
254
- <div style="text-align: center; padding: 20px; color: #666; font-size: 13px;">
255
- — 已经到底了 —
256
- </div>
257
- `;
258
  endIndicator.style.display = "none";
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  // 滚动事件处理(带防抖)
261
  const handleScroll = () => {
 
262
  if (debounceTimer) clearTimeout(debounceTimer);
263
 
264
  debounceTimer = setTimeout(() => {
265
- if (isLoading || !hasMore) return;
266
 
267
  const { scrollTop, scrollHeight, clientHeight } = container;
268
  const distanceToBottom = scrollHeight - scrollTop - clientHeight;
@@ -275,67 +293,85 @@ export function createPaginationLoader(options) {
275
 
276
  // 加载下一页
277
  const _loadNextPage = async () => {
278
- if (isLoading || !hasMore) return;
279
 
280
  isLoading = true;
281
  loadingIndicator.style.display = "block";
 
282
 
283
  try {
284
  currentPage++; // 先递增,再加载(首屏已渲染第1页,从第2页开始)
285
- const savedGen = generation;
286
- const result = await loadMore(currentPage, pageSize);
 
287
  if (savedGen !== generation) return; // reset 被调用了,丢弃结果
288
 
289
  // 加载失败,允许重试
290
  if (result === null) {
291
- consecutiveErrors++;
292
- if (consecutiveErrors >= 3) {
293
- hasMore = false;
294
- loadingIndicator.style.display = "none";
295
- if (retryTimer) clearTimeout(retryTimer);
296
- retryTimer = setTimeout(() => {
297
- consecutiveErrors = 0;
298
- hasMore = true;
299
- }, 5000);
300
- }
301
  return;
302
  }
303
 
304
- consecutiveErrors = 0;
305
- if (retryTimer) {
306
- clearTimeout(retryTimer);
307
- retryTimer = null;
308
- }
309
-
310
  // 判断是否还有更多 - 返回空数组则停止
311
  if (!result || result.length === 0) {
312
  hasMore = false;
313
  loadingIndicator.style.display = "none";
 
 
 
314
  }
315
  } catch (error) {
316
- console.error("分页加载失败:", error);
 
 
 
 
 
317
  } finally {
318
- isLoading = false;
319
- if (hasMore) {
320
  loadingIndicator.style.display = "none";
 
 
 
 
 
 
321
  }
322
  }
323
  };
 
 
 
 
 
 
 
324
 
325
  // 启动监听
326
  const start = () => {
 
327
  container.addEventListener("scroll", handleScroll);
328
  // 添加指示器到容器末尾
329
  const scrollContent = container.querySelector(".scroll-content") || container;
330
  scrollContent.appendChild(loadingIndicator);
331
  scrollContent.appendChild(endIndicator);
 
332
  };
333
 
334
  // 停止监听
335
  const stop = () => {
 
 
 
 
336
  container.removeEventListener("scroll", handleScroll);
337
  if (debounceTimer) clearTimeout(debounceTimer);
338
- if (retryTimer) clearTimeout(retryTimer);
 
339
  };
340
 
341
  // 重置状态
@@ -344,13 +380,9 @@ export function createPaginationLoader(options) {
344
  currentPage = 1; // 首屏已渲染第1页,分页器从第2页开始
345
  hasMore = true;
346
  isLoading = false;
347
- consecutiveErrors = 0;
348
- if (retryTimer) {
349
- clearTimeout(retryTimer);
350
- retryTimer = null;
351
- }
352
  loadingIndicator.style.display = "none";
353
  endIndicator.style.display = "none";
 
354
  };
355
 
356
  // 手动触发加载
@@ -367,6 +399,24 @@ export function createPaginationLoader(options) {
367
  };
368
  }
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  // ==========================================
372
  // 🖼️ 图片懒加载
@@ -1259,11 +1309,23 @@ export { eventManager };
1259
  * @returns {Array|null} 命中返回数组,未命中/空列表返回 null
1260
  */
1261
  export function readListCache(cacheKey) {
1262
- const { value } = getCacheWithMeta(cacheKey, true); // true = 忽略过期
1263
- if (!Array.isArray(value) || value.length === 0) return null;
 
 
 
 
 
 
 
 
1264
  return value;
1265
  }
1266
 
 
 
 
 
1267
  /**
1268
  * 💾 回写列表首屏缓存(持久化到 localStorage,保证重启后离线仍可展示)
1269
  * @param {string} cacheKey - 完整缓存 Key
@@ -1271,10 +1333,63 @@ export function readListCache(cacheKey) {
1271
  * @param {number} ttl - 有效期(毫秒),由调用方传 getCacheTTL()
1272
  */
1273
  export function writeListCache(cacheKey, items, ttl) {
1274
- if (!Array.isArray(items)) return;
1275
  setCache(cacheKey, items, ttl, true); // true = 持久化 localStorage
1276
  }
1277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1278
  /**
1279
  * 🔍 从列表缓存中查找指定项(详情页离线回退用)
1280
  * @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
@@ -1283,20 +1398,14 @@ export function writeListCache(cacheKey, items, ttl) {
1283
  */
1284
  export function findInListCache(prefix, id) {
1285
  try {
1286
- const fullPrefix = "ComfyRanking_" + prefix;
1287
- for (let i = 0; i < localStorage.length; i++) {
1288
- const key = localStorage.key(i);
1289
- if (key && key.startsWith(fullPrefix)) {
1290
- const raw = JSON.parse(localStorage.getItem(key));
1291
- const items = raw?.value;
1292
- if (Array.isArray(items)) {
1293
- const found = items.find(item => item.id === id);
1294
- if (found) return found;
1295
- }
1296
- }
1297
  }
1298
  } catch (e) {
1299
- console.warn("列表缓存查找失败:", e);
1300
  }
1301
  return null;
1302
  }
 
18
  import { PAGINATION } from "../core/全局配置.js";
19
  // 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
20
  // 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
21
+ import { setCache, getCacheWithMeta, removeCache, getCacheKeysByPrefix } from "../core/网络请求_缓存管理.js";
22
 
23
  // ==========================================
24
  // 🔒 P0安全优化:敏感数据脱敏工具
 
216
  * @param {Object} options - 配置选项
217
  * @param {HTMLElement} options.container - 滚动容器
218
  * @param {Function} options.loadMore - 加载更多数据的函数,返回 Promise
219
+ * @param {Function} options.t - 本地化函数(分页状态和失败重试文案)
220
  * @param {number} options.pageSize - 每页数量,默认 20
221
  * @param {number} options.threshold - 触发阈值(距底部像素),默认 200
222
+ * @param {Function} [options.autoFill] - 可选;返回 true 时继续搜索分页,无滚动空间时也会填充到可滚动
223
  * @returns {Object} 分页加载器实例
224
  */
225
  export function createPaginationLoader(options) {
 
229
  pageSize = 20,
230
  threshold = 200
231
  } = options;
232
+ const translate = options.t;
233
+ const autoFill = options.autoFill;
234
 
235
  let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载
236
  let isLoading = false;
237
  let hasMore = true;
238
  let debounceTimer = null;
 
 
239
  let generation = 0; // 🔄 轻量级取消令牌,reset 时递增以丢弃飞行中请求
240
+ let stopped = false; // 保持创建后可手动加载;stop 后仅 start 能重新启用。
241
 
242
  // 加载指示器
243
  const loadingIndicator = document.createElement("div");
244
  loadingIndicator.className = "pagination-loading";
245
+ loadingIndicator.setAttribute("role", "status");
246
+ loadingIndicator.setAttribute("aria-live", "polite");
247
+ loadingIndicator.setAttribute("aria-atomic", "true");
248
+ const loadingSpinner = document.createElement("span");
249
+ loadingSpinner.className = "pagination-loading__spinner";
250
+ loadingSpinner.setAttribute("aria-hidden", "true");
251
+ const loadingText = document.createElement("span");
252
+ loadingText.textContent = translate("pagination.loading");
253
+ loadingIndicator.append(loadingSpinner, loadingText);
254
  loadingIndicator.style.display = "none";
255
 
256
  // "没有更多"提示
257
  const endIndicator = document.createElement("div");
258
  endIndicator.className = "pagination-end";
259
+ endIndicator.setAttribute("role", "status");
260
+ endIndicator.setAttribute("aria-live", "polite");
261
+ endIndicator.textContent = translate("pagination.end");
 
 
262
  endIndicator.style.display = "none";
263
+
264
+ const retryIndicator = document.createElement("div");
265
+ retryIndicator.className = "pagination-error";
266
+ retryIndicator.setAttribute("role", "alert");
267
+ retryIndicator.setAttribute("aria-live", "assertive");
268
+ const retryMessage = document.createElement("span");
269
+ retryMessage.textContent = translate("pagination.failed");
270
+ const retryButton = document.createElement("button");
271
+ retryButton.type = "button";
272
+ retryButton.className = "pagination-error__retry";
273
+ retryButton.textContent = translate("common.retry");
274
+ retryIndicator.append(retryMessage, retryButton);
275
+ retryIndicator.style.display = "none";
276
 
277
  // 滚动事件处理(带防抖)
278
  const handleScroll = () => {
279
+ if (stopped) return;
280
  if (debounceTimer) clearTimeout(debounceTimer);
281
 
282
  debounceTimer = setTimeout(() => {
283
+ if (stopped || isLoading || !hasMore) return;
284
 
285
  const { scrollTop, scrollHeight, clientHeight } = container;
286
  const distanceToBottom = scrollHeight - scrollTop - clientHeight;
 
293
 
294
  // 加载下一页
295
  const _loadNextPage = async () => {
296
+ if (stopped || isLoading || !hasMore) return;
297
 
298
  isLoading = true;
299
  loadingIndicator.style.display = "block";
300
+ const savedGen = generation;
301
 
302
  try {
303
  currentPage++; // 先递增,再加载(首屏已渲染第1页,从第2页开始)
304
+ const result = await loadMore(currentPage, pageSize, {
305
+ isCurrent: () => savedGen === generation
306
+ });
307
  if (savedGen !== generation) return; // reset 被调用了,丢弃结果
308
 
309
  // 加载失败,允许重试
310
  if (result === null) {
311
+ currentPage = Math.max(1, currentPage - 1);
312
+ hasMore = false;
313
+ loadingIndicator.style.display = "none";
314
+ retryIndicator.style.display = "flex";
 
 
 
 
 
 
315
  return;
316
  }
317
 
 
 
 
 
 
 
318
  // 判断是否还有更多 - 返回空数组则停止
319
  if (!result || result.length === 0) {
320
  hasMore = false;
321
  loadingIndicator.style.display = "none";
322
+ endIndicator.style.display = "block";
323
+ } else {
324
+ endIndicator.style.display = "none";
325
  }
326
  } catch (error) {
327
+ if (savedGen === generation) {
328
+ currentPage = Math.max(1, currentPage - 1);
329
+ hasMore = false;
330
+ retryIndicator.style.display = "flex";
331
+ console.error("分页加载失败");
332
+ }
333
  } finally {
334
+ if (savedGen === generation) {
335
+ isLoading = false;
336
  loadingIndicator.style.display = "none";
337
+ // Search callers can scan pages with no local matches without
338
+ // changing pagination behavior for other lists.
339
+ if (hasMore && typeof autoFill === "function" &&
340
+ (autoFill() || container.scrollHeight <= container.clientHeight + threshold)) {
341
+ _loadNextPage();
342
+ }
343
  }
344
  }
345
  };
346
+
347
+ retryButton.addEventListener("click", () => {
348
+ if (stopped || isLoading) return;
349
+ retryIndicator.style.display = "none";
350
+ hasMore = true;
351
+ _loadNextPage();
352
+ });
353
 
354
  // 启动监听
355
  const start = () => {
356
+ stopped = false;
357
  container.addEventListener("scroll", handleScroll);
358
  // 添加指示器到容器末尾
359
  const scrollContent = container.querySelector(".scroll-content") || container;
360
  scrollContent.appendChild(loadingIndicator);
361
  scrollContent.appendChild(endIndicator);
362
+ scrollContent.appendChild(retryIndicator);
363
  };
364
 
365
  // 停止监听
366
  const stop = () => {
367
+ stopped = true;
368
+ generation++;
369
+ if (isLoading) currentPage = Math.max(1, currentPage - 1);
370
+ isLoading = false;
371
  container.removeEventListener("scroll", handleScroll);
372
  if (debounceTimer) clearTimeout(debounceTimer);
373
+ loadingIndicator.style.display = "none";
374
+ retryIndicator.style.display = "none";
375
  };
376
 
377
  // 重置状态
 
380
  currentPage = 1; // 首屏已渲染第1页,分页器从第2页开始
381
  hasMore = true;
382
  isLoading = false;
 
 
 
 
 
383
  loadingIndicator.style.display = "none";
384
  endIndicator.style.display = "none";
385
+ retryIndicator.style.display = "none";
386
  };
387
 
388
  // 手动触发加载
 
399
  };
400
  }
401
 
402
+ /** Render an accessible, localized search state inside a list grid. */
403
+ export function renderListSearchMessage(container, message) {
404
+ container.querySelector('.list-search-message')?.remove();
405
+ const status = document.createElement("div");
406
+ status.className = "list-search-message";
407
+ status.setAttribute("role", "status");
408
+ status.setAttribute("aria-live", "polite");
409
+ status.textContent = message;
410
+ Object.assign(status.style, {
411
+ gridColumn: "1 / -1",
412
+ textAlign: "center",
413
+ padding: "24px 16px",
414
+ color: "var(--descrip-text, #999)"
415
+ });
416
+ container.appendChild(status);
417
+ return status;
418
+ }
419
+
420
 
421
  // ==========================================
422
  // 🖼️ 图片懒加载
 
1309
  * @returns {Array|null} 命中返回数组,未命中/空列表返回 null
1310
  */
1311
  export function readListCache(cacheKey) {
1312
+ const { value, found } = getCacheWithMeta(cacheKey, true); // true = 忽略过期
1313
+ if (!Array.isArray(value)) {
1314
+ if (found) removeCache(cacheKey);
1315
+ return null;
1316
+ }
1317
+ if (value.length === 0) return null;
1318
+ if (_hasInvalidListItem(value)) {
1319
+ removeCache(cacheKey);
1320
+ return null;
1321
+ }
1322
  return value;
1323
  }
1324
 
1325
+ function _hasInvalidListItem(items) {
1326
+ return items.some(item => !item || typeof item !== "object" || Array.isArray(item));
1327
+ }
1328
+
1329
  /**
1330
  * 💾 回写列表首屏缓存(持久化到 localStorage,保证重启后离线仍可展示)
1331
  * @param {string} cacheKey - 完整缓存 Key
 
1333
  * @param {number} ttl - 有效期(毫秒),由调用方传 getCacheTTL()
1334
  */
1335
  export function writeListCache(cacheKey, items, ttl) {
1336
+ if (!Array.isArray(items) || _hasInvalidListItem(items)) return;
1337
  setCache(cacheKey, items, ttl, true); // true = 持久化 localStorage
1338
  }
1339
 
1340
+ /**
1341
+ * Validate a successful list endpoint envelope before data reaches UI state or cache.
1342
+ * @param {Object} response - API response envelope
1343
+ * @returns {Array} validated list data
1344
+ */
1345
+ export function requireListResponseData(response) {
1346
+ if (response?.status !== "success" || !Array.isArray(response.data)
1347
+ || _hasInvalidListItem(response.data)) {
1348
+ const error = new Error("Invalid list response");
1349
+ error.name = "InvalidListResponseError";
1350
+ throw error;
1351
+ }
1352
+ return response.data;
1353
+ }
1354
+
1355
+ /**
1356
+ * 判断后台成功响应的空数组是否会覆盖当前榜单已有内容。
1357
+ * 列表服务在冷启动/数据同步窗口可能暂时返回空数组;已有结果应继续显示,
1358
+ * 直到非空新数据到达。无缓存时的真实空列表仍按正常结果处理。
1359
+ * @param {Array} nextItems 本次请求得到的列表
1360
+ * @param {Array} currentItems 当前榜单已显示的数据
1361
+ * @returns {boolean} 是否应跳过此次缓存回写和列表替换
1362
+ */
1363
+ export function isEmptyListRefreshWithExistingData(nextItems, currentItems) {
1364
+ return Array.isArray(nextItems) && nextItems.length === 0
1365
+ && Array.isArray(currentItems) && currentItems.length > 0;
1366
+ }
1367
+
1368
+ /**
1369
+ * 渲染可访问的列表首屏失败状态,并提供显式重试入口。
1370
+ * 使用 DOM/textContent,避免错误文案进入 HTML sink。
1371
+ */
1372
+ export function renderListLoadError(container, message, onRetry, t) {
1373
+ if (!container) return;
1374
+ const status = document.createElement("div");
1375
+ status.className = "comfy-ranking-list-error";
1376
+ status.setAttribute("role", "alert");
1377
+ status.setAttribute("aria-live", "assertive");
1378
+
1379
+ const text = document.createElement("p");
1380
+ text.className = "comfy-ranking-list-error__message";
1381
+ text.textContent = message;
1382
+
1383
+ const retry = document.createElement("button");
1384
+ retry.type = "button";
1385
+ retry.className = "comfy-ranking-list-error__retry";
1386
+ retry.textContent = t("common.retry");
1387
+ retry.addEventListener("click", () => onRetry?.());
1388
+
1389
+ status.append(text, retry);
1390
+ container.replaceChildren(status);
1391
+ }
1392
+
1393
  /**
1394
  * 🔍 从列表缓存中查找指定项(详情页离线回退用)
1395
  * @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
 
1398
  */
1399
  export function findInListCache(prefix, id) {
1400
  try {
1401
+ for (const cacheKey of getCacheKeysByPrefix(prefix)) {
1402
+ const items = readListCache(cacheKey);
1403
+ if (!items) continue;
1404
+ const item = items.find(entry => entry.id === id);
1405
+ if (item) return item;
 
 
 
 
 
 
1406
  }
1407
  } catch (e) {
1408
+ console.warn("列表缓存查找失败");
1409
  }
1410
  return null;
1411
  }
前端页面/components/打赏等级工具.js CHANGED
@@ -14,6 +14,8 @@ import { t } from "./用户体验增强.js";
14
  import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
 
 
 
17
  /**
18
  * 等级规则(参考QQ等级系统):
19
  * - 1-100 积分 = 1 颗星星 ⭐,101-200 积分 = 2 颗星星,以此类推
@@ -134,17 +136,17 @@ export function isMaxTipLevel(amount) {
134
  * @returns {string} - 等级描述
135
  */
136
  export function getTipLevelDescription(amount) {
137
- if (!amount || amount <= 0) return "新粉丝";
138
 
139
  const level = calculateTipLevel(amount);
140
 
141
- if (level.isMaxLevel) return "至尊赞助者 (已满级)";
142
- if (level.suns >= 5) return "超级赞助者";
143
- if (level.suns >= 1) return "黄金赞助者";
144
- if (level.moons >= 3) return "白银赞助者";
145
- if (level.moons >= 1) return "青铜赞助者";
146
- if (level.stars >= 3) return "热心粉丝";
147
- return "支持者";
148
  }
149
 
150
  /**
@@ -155,7 +157,11 @@ export function getTipLevelDescription(amount) {
155
  * @returns {string} - 榜单项HTML
156
  */
157
  export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
158
- const { account, amount, is_anon } = tipData;
 
 
 
 
159
 
160
  // 排名徽章颜色
161
  const badgeColors = ['#FFD700', '#C0C0C0', '#CD7F32']; // 金银铜
@@ -182,7 +188,7 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
182
  <div style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
183
  <span style="display:flex; align-items:center; gap:6px;">
184
  ${badgeHtml}
185
- <span style="color:#888;">🔒 匿名</span>
186
  </span>
187
  <span style="display:flex; align-items:center; gap:6px;">
188
  ${levelHtml}
@@ -193,18 +199,19 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
193
  }
194
 
195
  // 🚀 SWR 模式:非匿名用户显示头像+用户名
196
- const containerId = `tip-board-item-${account}-${rank}-${Date.now()}`;
197
 
198
  // 从缓存获取初始数据(0延迟渲染)
199
  const cached = getCachedProfile(account);
200
  const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL;
201
- const userName = cached?.name || account;
202
 
203
  // 后台静默校对并更新 DOM
204
  setTimeout(() => {
 
 
205
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
206
- const container = document.getElementById(containerId);
207
- if (!container) return;
208
 
209
  const avatarImg = container.querySelector('.tip-board-avatar');
210
  const nameSpan = container.querySelector('.tip-board-name');
@@ -225,7 +232,7 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
225
  <span style="display:flex; align-items:center; gap:6px;">
226
  ${badgeHtml}
227
  <span style="display:flex; align-items:center; gap:6px;">
228
- <img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
229
  <span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
230
  </span>
231
  </span>
@@ -245,12 +252,14 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
245
  * @param {string} size - 尺寸
246
  * @returns {string} - 完整榜单HTML
247
  */
248
- export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = "暂无打赏,快来成为首个赞赏人吧!", size = 'normal') {
249
- if (!boardData || boardData.length === 0) {
 
 
250
  return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
251
  }
252
 
253
- return boardData.slice(0, maxShow).map((t, i) => renderTipBoardItemHTML(t, i, size)).join('');
254
  }
255
 
256
  /**
 
14
  import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
 
17
+ let nextTipBoardItemId = 0;
18
+
19
  /**
20
  * 等级规则(参考QQ等级系统):
21
  * - 1-100 积分 = 1 颗星星 ⭐,101-200 积分 = 2 颗星星,以此类推
 
136
  * @returns {string} - 等级描述
137
  */
138
  export function getTipLevelDescription(amount) {
139
+ if (!amount || amount <= 0) return t('tip.new_fan');
140
 
141
  const level = calculateTipLevel(amount);
142
 
143
+ if (level.isMaxLevel) return t('tip.level_max');
144
+ if (level.suns >= 5) return t('tip.level_super');
145
+ if (level.suns >= 1) return t('tip.level_gold');
146
+ if (level.moons >= 3) return t('tip.level_silver');
147
+ if (level.moons >= 1) return t('tip.level_bronze');
148
+ if (level.stars >= 3) return t('tip.level_fan');
149
+ return t('tip.level_supporter');
150
  }
151
 
152
  /**
 
157
  * @returns {string} - 榜单项HTML
158
  */
159
  export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
160
+ if (!tipData || typeof tipData !== 'object' || Array.isArray(tipData)) return '';
161
+ const { account, is_anon } = tipData;
162
+ const amount = Number(tipData.amount);
163
+ if (!Number.isFinite(amount) || amount < 0) return '';
164
+ rank = Number.isSafeInteger(rank) && rank >= 0 ? rank : 0;
165
 
166
  // 排名徽章颜色
167
  const badgeColors = ['#FFD700', '#C0C0C0', '#CD7F32']; // 金银铜
 
188
  <div style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
189
  <span style="display:flex; align-items:center; gap:6px;">
190
  ${badgeHtml}
191
+ <span style="color:#888;">🔒 ${t('creator.anonymous')}</span>
192
  </span>
193
  <span style="display:flex; align-items:center; gap:6px;">
194
  ${levelHtml}
 
199
  }
200
 
201
  // 🚀 SWR 模式:非匿名用户显示头像+用户名
202
+ const containerId = `ranking-tip-board-${++nextTipBoardItemId}`;
203
 
204
  // 从缓存获取初始数据(0延迟渲染)
205
  const cached = getCachedProfile(account);
206
  const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL;
207
+ const userName = cached?.name || account || t('common.unknown_user');
208
 
209
  // 后台静默校对并更新 DOM
210
  setTimeout(() => {
211
+ const container = document.getElementById(containerId);
212
+ if (!container) return;
213
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
214
+ if (!container.isConnected || document.getElementById(containerId) !== container) return;
 
215
 
216
  const avatarImg = container.querySelector('.tip-board-avatar');
217
  const nameSpan = container.querySelector('.tip-board-name');
 
232
  <span style="display:flex; align-items:center; gap:6px;">
233
  ${badgeHtml}
234
  <span style="display:flex; align-items:center; gap:6px;">
235
+ <img class="tip-board-avatar" alt="" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
236
  <span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
237
  </span>
238
  </span>
 
252
  * @param {string} size - 尺寸
253
  * @returns {string} - 完整榜单HTML
254
  */
255
+ export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = t('tip.no_tips_yet'), size = 'normal') {
256
+ const entries = Array.isArray(boardData) ? boardData.filter(entry => entry && typeof entry === 'object'
257
+ && !Array.isArray(entry) && Number.isFinite(Number(entry.amount)) && Number(entry.amount) >= 0) : [];
258
+ if (entries.length === 0) {
259
  return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
260
  }
261
 
262
+ return entries.slice(0, maxShow).map((entry, i) => renderTipBoardItemHTML(entry, i, size)).join('');
263
  }
264
 
265
  /**
前端页面/components/用户体验_国际化.js CHANGED
@@ -14,14 +14,17 @@ const translations = {
14
  'zh-CN': {
15
  // 通用
16
  'common.confirm': '确认',
 
 
17
  'common.cancel': '取消',
18
  'common.save': '保存',
19
  'common.saving': '保存中...',
20
- 'common.delete': '删除',
21
  'common.deleting': '删除中...',
22
  'common.edit': '编辑',
23
  'common.close': '关闭',
24
- 'common.reset': '重置',
 
 
25
  'common.loading': '加载中...',
26
  'common.chars': '字符',
27
  'common.digits': '位数',
@@ -32,11 +35,135 @@ const translations = {
32
  'common.retrying': '重试中...',
33
  'common.retry_failed': '重试失败,请稍后再试',
34
  'common.no_more': '没有更多了',
 
 
 
 
 
35
  'common.parse_error': '响应解析失败',
36
  'common.network_error_retry': '网络连接失败,请稍后重试',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  'common.more': '更多',
38
- 'common.back': '返回',
39
  'common.search': '搜索',
 
 
 
 
 
40
  'common.refresh': '刷新',
41
  'common.copy': '复制',
42
  'common.copied': '已复制',
@@ -49,7 +176,6 @@ const translations = {
49
  'common.yes': '是',
50
  'common.no': '否',
51
  'common.ok': '好的',
52
- 'common.send': '发送',
53
  'common.reset': '重置',
54
  'common.install': '安装',
55
  'common.installed': '已安装',
@@ -58,6 +184,7 @@ const translations = {
58
  'common.paid': '付费',
59
 
60
  // 导航
 
61
  'nav.tools': '插件榜',
62
  'nav.skills': 'Skill',
63
  'nav.apps': '工作流',
@@ -71,6 +198,8 @@ const translations = {
71
  'nav.logout': '退出登录',
72
  'nav.notifications': '消息',
73
  'nav.community': '社区精选',
 
 
74
 
75
  // 设置页面
76
  'settings.title': '界面设置',
@@ -119,7 +248,6 @@ const translations = {
119
  'auth.token_expired': '登录凭证已失效,请重新登录',
120
  'auth.email': '邮箱',
121
  'auth.password': '密码',
122
- 'auth.confirm_password': '确认密码',
123
  'auth.username': '用户名',
124
  'auth.nickname': '昵称',
125
  'auth.forgot_password': '忘记密码?',
@@ -130,13 +258,11 @@ const translations = {
130
  'auth.your_account': '您的账号',
131
  'auth.go_reset_password': '去重置密码',
132
  'auth.reset_password': '重置密码',
133
- 'auth.verification_code': '验证码',
134
  'auth.send_code': '发送验证码',
135
  'auth.resend_code': '重新发送',
136
  'auth.login_success': '登录成功',
137
  'auth.register_success': '注册成功',
138
  'auth.logout_success': '已退出登录',
139
- 'auth.password_mismatch': '两次密码不一致',
140
  'auth.weak_password': '密码强度不足',
141
  'auth.account_exists': '账号已存在',
142
  'auth.invalid_credentials': '账号或密码错误',
@@ -211,11 +337,9 @@ const translations = {
211
  'profile.their_published': 'TA的发布',
212
  'profile.my_purchased': '我购买的',
213
  'profile.my_tasks': '我的任务',
214
- 'profile.transactions': '交易明细',
215
  'profile.my_posts': '我的帖子',
216
  'profile.my_collected': '我的收藏',
217
  'profile.recent_likes': '近期点赞',
218
- 'profile.following': '关注的人',
219
  'profile.max_privilege': '最高特权',
220
  'profile.beta_max_privilege': '内测最高特权',
221
  'profile.edit_profile': '编辑资料',
@@ -277,11 +401,13 @@ const translations = {
277
  'tx.task_payment': '📋 任务尾款',
278
  'tx.task_income': '📋 任务收入',
279
  'tx.task_freeze': '🔒 任务冻结',
 
280
  'tx.task_refund': '↩️ 任务退款',
281
  'tx.task_cancel_refund': '🔄 任务取消退款',
282
  'tx.sale': '销售收入',
283
  'tx.recharge': '💰 充值',
284
  'tx.refund': '↩️ 退款',
 
285
  'tx.withdraw_fee': '💸 手续费',
286
  'tx.withdraw_pending': '待打款',
287
  'tx.withdraw_completed': '已完成',
@@ -313,6 +439,9 @@ const translations = {
313
  'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
314
  'wallet.recharge.processing': '处理中...',
315
  'wallet.recharge.timeout': '支付查询超时,如已支付请稍后刷新查看余额',
 
 
 
316
 
317
  // 提现相关
318
  'wallet.withdraw.title': '💳 收益提现中心',
@@ -331,7 +460,8 @@ const translations = {
331
  'wallet.withdraw.resend_in': '{seconds}s 后重发',
332
  'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
333
  'wallet.withdraw.submitting': '⏳ 提交中...',
334
- 'wallet.withdraw.timeout': '提现请求超时,请检查网络后重试',
 
335
  'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
336
  'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
337
  'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
@@ -357,10 +487,6 @@ const translations = {
357
  'market.detail': '详情',
358
  'market.comments': '评论',
359
  'market.reviews': '评价',
360
- 'market.like': '点赞',
361
- 'market.liked': '已赞',
362
- 'market.favorite': '收藏',
363
- 'market.favorited': '已收藏',
364
  'market.views': '🔥 浏览总量',
365
  'market.daily_views': '🔥 今日热门',
366
  'market.rating': '★ 评分最高',
@@ -375,7 +501,6 @@ const translations = {
375
  'social.messages': '消息',
376
  'social.notifications': '通知',
377
  'social.comment': '评论',
378
- 'social.reply': '回复',
379
  'social.share': '分享',
380
  'social.mention': '@提及',
381
  'social.new_message': '新消息',
@@ -400,6 +525,7 @@ const translations = {
400
  'feedback.success': '操作成功',
401
  'feedback.error': '操作失败,请重试',
402
  'feedback.timeout': '请求超时',
 
403
  'feedback.saved': '已保存',
404
  'feedback.logged_out': '已退出登录',
405
  'feedback.login_required': '请先登录您的账号!',
@@ -433,9 +559,11 @@ const translations = {
433
 
434
  // 反馈补充
435
  'feedback.send_failed': '发送失败',
 
436
 
437
  // 评分
438
  'rating.stars': '评分',
 
439
  'rating.count': '{count}人评分',
440
  'rating.your_score': '你的评分',
441
  'rating.login_required': '登录后评分',
@@ -449,6 +577,7 @@ const translations = {
449
  'notif.title': '消息与通知',
450
  'notif.clear': '清空记录',
451
  'notif.no_messages': '暂无任何消息通知',
 
452
  'notif.followed_you': '关注了您',
453
  'notif.liked': '点赞了您的作品',
454
  'notif.favorited': '收藏了您的作品',
@@ -552,6 +681,10 @@ const translations = {
552
  'market.delete_permanently': '永久删除',
553
  'market.delete_confirm': '🚨 警告:确定要彻底删除该发布内容吗?<br><br><span style="color:#aaa;">该操作不可逆,且相关的所有评论数据也将被同步永久清空。</span>',
554
  'item.refundable': '支持退款',
 
 
 
 
555
  'item.confirm_refund_title': '确认退款',
556
  'item.confirm_refund': '确认退款',
557
  'item.refund_amount': '退款金额:',
@@ -571,6 +704,8 @@ const translations = {
571
  'item.refund_consequence_2': '已下载的文件将被自动删除',
572
  'item.refund_consequence_3': '30天内禁止再次购买此商品',
573
  'item.refund_success': '退款成功',
 
 
574
  'item.credits_refunded': '积分已退还到您的账户',
575
  'item.ban_days_notice': '{days}天内禁止再次购买此商品',
576
  'item.refund_failed_retry': '退款失败,请重试',
@@ -587,6 +722,7 @@ const translations = {
587
  'item.delete_request_failed': '删除请求失败,请检查网络',
588
  'item.no_tips_yet': '该资源暂无专属打赏,快来成为首个赞赏人吧!',
589
  'item.author': '作者',
 
590
  'item.original_notice': '原创内容,请勿商用',
591
  'item.tip_encourage': '赞赏鼓励该作品',
592
  'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
@@ -596,6 +732,20 @@ const translations = {
596
  'web.install_on_desktop': '请在电脑端进行安装',
597
  'web.purchase_success_desktop': '购买成功!请在电脑端进行安装',
598
  'crop.ratio_label': '比例',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  'crop.gesture_hint_touch': '单指拖动调整位置 | 双指捏合或滑块缩放',
600
  'crop.gesture_hint_mouse': '拖动调整位置 | 滚轮缩放',
601
  'item.update_available': '发现新版本 (点击静默热更新)',
@@ -611,7 +761,19 @@ const translations = {
611
  'feedback.deleted': '已成功删除',
612
  'feedback.delete_failed': '删除失败',
613
  'feedback.retry_suffix': ',请重试',
 
 
 
 
 
 
 
 
 
 
 
614
  'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
 
615
  // 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
616
  'feedback.offline_no_cache': '网络已断开,且无本地缓存',
617
  // ☁️ 云端不可达冷却期内、且本地无缓存可兜底时抛出(详见 网络请求_基础设施.js)。
@@ -682,10 +844,8 @@ const translations = {
682
 
683
  // 任务详情
684
  'task.detail_title': '任务详情',
685
- 'task.deposit_ratio': '订金',
686
  'task.published_at': '发布于',
687
  'task.deadline_label': '截止日期',
688
- 'task.description_label': '📄 任务描述',
689
  'task.ref_images': '🖼️ 参考图片',
690
  'task.ref_link': '🔗 参考链接',
691
  'task.assignee_label': '👷 接单者',
@@ -699,9 +859,7 @@ const translations = {
699
  'task.login_to_operate': '请登录后操作',
700
  'task.cancel_task': '❌ 取消任务',
701
  'task.edit_task': '编辑任务',
702
- 'task.title_label': '任务标题',
703
  'task.description_label': '任务描述',
704
- 'task.title_required': '请输入任务标题',
705
  'task.edit_success': '任务修改成功',
706
  'task.edit_failed': '任务修改失败',
707
  'task.edit_limited_notice': '任务已有人接单,仅可修改标题、描述和参考链接',
@@ -715,13 +873,11 @@ const translations = {
715
  'task.accept': '✅ 验收通过',
716
  'task.reject': '❌ 验收不通过',
717
  'task.dispute': '⚖️ 发起申诉',
718
- 'task.view_dispute': '⚖️ 查看申诉详情',
719
  'task.apply_message': '请输入申请留言(可选):',
720
  'task.apply_success': '申请成功!',
721
  'task.apply_failed': '申请失败',
722
  'task.cancel_apply_confirm': '确定撤回申请吗?',
723
  'task.cancel_apply_success': '已撤回申请',
724
- 'task.cancel_apply_failed': '撤��失败',
725
  'task.cancel_confirm': '确定取消任务吗?此操作不可撤销。',
726
  'task.cancel_success': '任务已取消',
727
  'task.cancel_failed': '取消失败',
@@ -751,7 +907,6 @@ const translations = {
751
  // 发布任务
752
  'task.publish_title': '📝 发布任务',
753
  'task.title_label': '📌 任务标题',
754
- 'task.title_placeholder': '简洁描述您的需求,50字以内',
755
  'task.desc_label': '📄 任务描述',
756
  'task.desc_placeholder': '详细描述任务要求、交付标准、参考说明等...',
757
  'task.images_label': '🖼️ 参考图片(可选,最多6张)',
@@ -780,6 +935,9 @@ const translations = {
780
  'task.max_images': '最多上传6张图片',
781
 
782
  // 申诉相关
 
 
 
783
  'dispute.title': '申诉详情',
784
  'dispute.status_pending': '等待回应',
785
  'dispute.status_responded': '等待仲裁',
@@ -960,6 +1118,7 @@ const translations = {
960
  'prompt.text_label': '提示词正文(付费内容)',
961
  'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
962
  'prompt.price_label': '定价',
 
963
  'prompt.video_label': '示例视频',
964
  'prompt.title_required': '请输入标题',
965
  'prompt.content_required': '请输入介绍文案',
@@ -988,15 +1147,12 @@ const translations = {
988
  'post.images_label': '🖼️ 上传图片',
989
  'post.images_required': '(最多9张,第一张为封面)',
990
  'post.images_placeholder': '点击或拖拽上传图片',
991
- 'post.title_label': '📝 标题',
992
  'post.title_placeholder': '给你的作品起个标题...',
993
- 'post.content_label': '✍️ 正文',
994
  'post.content_placeholder': '分享你的创作心得、使用技巧...',
995
  'post.notice_title': '💡 发布须知:',
996
  'post.notice_1': '请上传原创作品或标明出处',
997
  'post.notice_2': '图片将自动压缩优化',
998
  'post.notice_3': '发布后可在个人中心管理',
999
- 'post.cover': '封面',
1000
  'post.images_required_error': '请至少上传一张图片',
1001
  'post.title_required': '请输入标题',
1002
  'post.uploading': '⏳ 上传图片中...',
@@ -1007,20 +1163,14 @@ const translations = {
1007
  'post.mode_label': '发布模式',
1008
  'post.mode_image': '图文',
1009
  'post.mode_video': '视频',
1010
- 'post.upload_images': '上传图片',
1011
- 'post.max_9_images': '最多9张',
1012
- 'post.click_upload': '点击上传',
1013
  'post.upload_video': '上传视频',
1014
  'post.click_upload_video': '点击选择视频',
1015
  'post.video_cover': '视频封面',
1016
  'post.capture_frame': '截取当前帧',
1017
  'post.upload_cover': '手动上传封面',
1018
- 'post.publish_notice': '发布须知',
1019
- 'post.notice_original': '请上传原创作品或标明出处',
1020
- 'post.notice_compress': '图片将自动压缩优化',
1021
- 'post.notice_manage': '发布后可在个人中心管理',
1022
  'post.error_video_format': '仅支持 mp4、webm、mov 格式的视频',
1023
  'post.error_video_size': '视频大小不能超过 100MB',
 
1024
  'post.error_video_duration': '视频时长不能超过 3 分钟',
1025
  'post.error_video_load': '无法读取视频信息',
1026
  'post.existing_video': '已有视频',
@@ -1028,7 +1178,6 @@ const translations = {
1028
  'post.cover_captured': '封面已更新',
1029
  'post.cover_capture_failed': '封面截取失败',
1030
  'post.error_cover_format': '请选择图片文件作为封面',
1031
- 'post.error_no_title': '请输入标题',
1032
  'post.error_no_content': '请输入正文内容',
1033
  'post.error_no_video': '请选择视频文件',
1034
  'post.error_no_cover': '请等待封面生成或手动上传封面',
@@ -1045,24 +1194,42 @@ const translations = {
1045
  'post.error_no_audio': '请选择音频文件',
1046
  'post.uploading_audio': '上传音频中...',
1047
  'post.media_exclusive': '视频与「图片 + 音频」二选一,已自动清空另一组',
1048
- 'post.publishing': '发布中...',
1049
- 'post.edit_success': '保存成功',
1050
- 'post.edit_failed': '保存失败',
1051
- 'post.error_no_image': '请至少上传一张图片',
1052
- 'post.compressing_images': '压缩图片中...',
1053
- 'post.compressing_progress': '压缩图片 ({current}/{total})',
1054
- 'post.uploading_progress': '上传图片 ({current}/{total})',
1055
 
1056
  // 通用补充 - 组件使用
1057
  'common.message': '私信',
1058
  'common.credits': '积分',
1059
  'common.unknown': '未知',
1060
  'common.load_failed': '加载失败',
 
1061
 
1062
  // 🎰 打赏等级
1063
  'tip.new_fan': '新粉丝',
 
 
 
 
 
 
 
 
1064
  'tip.supreme_sponsor': '至尊赞助者',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1065
  'tip.points': '积分',
 
1066
 
1067
  // 📊 图表
1068
  'chart.loading': '正在加载图表...',
@@ -1073,6 +1240,18 @@ const translations = {
1073
  'image.load_failed': '图片加载失败',
1074
  'image.load_timeout': '加载超时',
1075
  'image.gallery': '效果展示图',
 
 
 
 
 
 
 
 
 
 
 
 
1076
  'common.operation_failed': '操作失败',
1077
  'common.login_required': '请登录后操作',
1078
  'common.publish': '发布',
@@ -1133,6 +1312,8 @@ const translations = {
1133
  'task.reference_link_optional': '参考链接(可选)',
1134
  'task.deposit_ratio': '订金比例',
1135
  'task.min_10_credits': '最低10积分',
 
 
1136
  'task.deposit_deducted_on_assign': '接单时扣除',
1137
  'task.price_description': '价格说明',
1138
  'task.deposit_on_assign': '订金(指派接单时扣除)',
@@ -1143,10 +1324,17 @@ const translations = {
1143
  'task.please_enter_description': '请输入任务描述',
1144
  'task.price_min_10': '任务价格不能低于10积分',
1145
  'task.please_select_deadline': '请选择截止日期',
 
 
 
 
 
1146
  'task.uploading_images': '上传图片中',
1147
  'task.upload_progress': '上传图片 ({current}/{total})',
1148
  'task.publishing': '发布中',
1149
  'task.max_6_images': '最多上传6张图片',
 
 
1150
  'task.view_total': '🔥 浏览总量',
1151
  'task.view_today': '📅 今日浏览',
1152
  'task.save_changes': '保存修改',
@@ -1175,9 +1363,12 @@ const translations = {
1175
  // 发布帖子补充
1176
  'post.upload_images': '上传图片',
1177
  'post.max_9_images': '最多9张,第一张为封面',
 
 
 
 
 
1178
  'post.click_upload': '点击或拖拽上传图片',
1179
- 'post.title_label': '标题',
1180
- 'post.content_label': '正文',
1181
  'post.publish_notice': '发布须知',
1182
  'post.notice_original': '请上传原创作品或标明出处',
1183
  'post.notice_compress': '图片将自动压缩优化',
@@ -1287,6 +1478,10 @@ const translations = {
1287
  'publish.uploading_images': '正在上传图片 ({current}/{total})...',
1288
  'publish.syncing': '正在同步全网数据库...',
1289
  'publish.save_success': '修改已保存并同步全网!',
 
 
 
 
1290
  'publish.publish_success': '发布成功!您的作品已全网同步。',
1291
 
1292
  // 🌐 个人中心页面
@@ -1304,6 +1499,32 @@ const translations = {
1304
  'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
1305
  'profile.no_following': '暂无关注的人',
1306
  'profile.no_followers': '暂无粉丝',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1307
  'profile.load_more': '加载更多 ({count})',
1308
  'profile.no_collected': '还没有收藏任何内容',
1309
  'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
@@ -1380,6 +1601,28 @@ const translations = {
1380
  'settings_form.save_failed': '设置失败',
1381
  'settings_form.avatar_upload_success': '头像上传成功!请点击底部保存设置生效。',
1382
  'settings_form.banner_cleared': '已清除背景图,请点击底部保存设置生效。',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1383
  // 视频播放器
1384
  'video.play': '播放',
1385
  'video.pause': '暂停',
@@ -1394,6 +1637,10 @@ const translations = {
1394
  'video.volume': '音量',
1395
 
1396
  // 关于插件
 
 
 
 
1397
  'about.title': 'ComfyUI 社区精选',
1398
  'about.subtitle': '专为 ComfyUI 打造的现代化 Web3.0 级别生态引擎',
1399
  'about.stage_alpha': 'Alpha 内测',
@@ -1459,14 +1706,17 @@ const translations = {
1459
  'en-US': {
1460
  // Common
1461
  'common.confirm': 'Confirm',
 
 
1462
  'common.cancel': 'Cancel',
1463
  'common.save': 'Save',
1464
  'common.saving': 'Saving...',
1465
- 'common.delete': 'Delete',
1466
  'common.deleting': 'Deleting...',
1467
  'common.edit': 'Edit',
1468
  'common.close': 'Close',
1469
- 'common.reset': 'Reset',
 
 
1470
  'common.loading': 'Loading...',
1471
  'common.chars': 'chars',
1472
  'common.digits': 'digits',
@@ -1477,11 +1727,135 @@ const translations = {
1477
  'common.retrying': 'Retrying...',
1478
  'common.retry_failed': 'Retry failed, please try again later',
1479
  'common.no_more': 'No more items',
 
 
 
 
 
1480
  'common.parse_error': 'Failed to parse response',
1481
  'common.network_error_retry': 'Network connection failed, please try again later',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1482
  'common.more': 'More',
1483
- 'common.back': 'Back',
1484
  'common.search': 'Search',
 
 
 
 
 
1485
  'common.refresh': 'Refresh',
1486
  'common.copy': 'Copy',
1487
  'common.copied': 'Copied',
@@ -1494,7 +1868,6 @@ const translations = {
1494
  'common.yes': 'Yes',
1495
  'common.no': 'No',
1496
  'common.ok': 'OK',
1497
- 'common.send': 'Send',
1498
  'common.reset': 'Reset',
1499
  'common.install': 'Install',
1500
  'common.installed': 'Installed',
@@ -1503,6 +1876,7 @@ const translations = {
1503
  'common.paid': 'Paid',
1504
 
1505
  // Navigation
 
1506
  'nav.tools': 'Plugins',
1507
  'nav.skills': 'Skills',
1508
  'nav.apps': 'Workflows',
@@ -1516,6 +1890,8 @@ const translations = {
1516
  'nav.logout': 'Logout',
1517
  'nav.notifications': 'Notifications',
1518
  'nav.community': 'Ranking',
 
 
1519
 
1520
  // Settings
1521
  'settings.title': 'Settings',
@@ -1717,11 +2093,13 @@ const translations = {
1717
  'tx.task_payment': '📋 Task Payment',
1718
  'tx.task_income': '📋 Task Income',
1719
  'tx.task_freeze': '🔒 Task Frozen',
 
1720
  'tx.task_refund': '↩️ Task Refund',
1721
  'tx.task_cancel_refund': '🔄 Task Cancel Refund',
1722
  'tx.sale': 'Sales Income',
1723
  'tx.recharge': '💰 Recharge',
1724
  'tx.refund': '↩️ Refund',
 
1725
  'tx.withdraw_fee': '💸 Withdrawal Fee',
1726
  'tx.withdraw_pending': 'Pending',
1727
  'tx.withdraw_completed': 'Completed',
@@ -1753,6 +2131,9 @@ const translations = {
1753
  'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
1754
  'wallet.recharge.processing': 'Processing...',
1755
  'wallet.recharge.timeout': 'Payment check timed out. If you have already paid, refresh later to see your balance',
 
 
 
1756
 
1757
  // Withdraw
1758
  'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
@@ -1771,7 +2152,8 @@ const translations = {
1771
  'wallet.withdraw.resend_in': 'Resend in {seconds}s',
1772
  'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
1773
  'wallet.withdraw.submitting': '⏳ Submitting...',
1774
- 'wallet.withdraw.timeout': 'Withdrawal request timed out. Check your network and try again',
 
1775
  'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
1776
  'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
1777
  'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
@@ -1797,10 +2179,6 @@ const translations = {
1797
  'market.detail': 'Details',
1798
  'market.comments': 'Comments',
1799
  'market.reviews': 'Reviews',
1800
- 'market.like': 'Like',
1801
- 'market.liked': 'Liked',
1802
- 'market.favorite': 'Favorite',
1803
- 'market.favorited': 'Favorited',
1804
  'market.views': '🔥 Most Viewed',
1805
  'market.daily_views': '🔥 Trending Today',
1806
  'market.rating': '★ Highest Rated',
@@ -1840,6 +2218,7 @@ const translations = {
1840
  'feedback.success': 'Success',
1841
  'feedback.error': 'Failed, please retry',
1842
  'feedback.timeout': 'Request timeout',
 
1843
  'feedback.saved': 'Saved',
1844
  'feedback.logged_out': 'Logged out',
1845
  'feedback.login_required': 'Please log in first!',
@@ -1870,9 +2249,11 @@ const translations = {
1870
  'common.send': 'Send',
1871
  'common.delete': 'Delete',
1872
  'feedback.send_failed': 'Send failed',
 
1873
 
1874
  // Rating
1875
  'rating.stars': 'Rating',
 
1876
  'rating.count': '{count} ratings',
1877
  'rating.your_score': 'Your rating',
1878
  'rating.login_required': 'Login to rate',
@@ -1886,6 +2267,7 @@ const translations = {
1886
  'notif.title': 'Messages & Notifications',
1887
  'notif.clear': 'Clear All',
1888
  'notif.no_messages': 'No messages yet',
 
1889
  'notif.followed_you': 'followed you',
1890
  'notif.liked': 'liked your work',
1891
  'notif.favorited': 'favorited your work',
@@ -1987,6 +2369,10 @@ const translations = {
1987
  'market.delete_permanently': 'Delete',
1988
  'market.delete_confirm': '🚨 Warning: Are you sure you want to permanently delete this content?<br><br><span style="color:#aaa;">This action is irreversible and all related comments will be deleted.</span>',
1989
  'item.refundable': 'Refundable',
 
 
 
 
1990
  'item.confirm_refund_title': 'Confirm Refund',
1991
  'item.confirm_refund': 'Confirm Refund',
1992
  'item.refund_amount': 'Refund Amount: ',
@@ -2006,6 +2392,8 @@ const translations = {
2006
  'item.refund_consequence_2': 'Downloaded files will be automatically deleted',
2007
  'item.refund_consequence_3': 'Re-purchase is prohibited for 30 days',
2008
  'item.refund_success': 'Refund Successful',
 
 
2009
  'item.credits_refunded': ' credits have been refunded to your account',
2010
  'item.ban_days_notice': 'Re-purchase prohibited for {days} days',
2011
  'item.refund_failed_retry': 'Refund failed, please retry',
@@ -2022,6 +2410,7 @@ const translations = {
2022
  'item.delete_request_failed': 'Delete request failed, please check network',
2023
  'item.no_tips_yet': 'No tips yet for this resource. Be the first to support!',
2024
  'item.author': 'Author',
 
2025
  'item.original_notice': 'Original content, do not use commercially',
2026
  'item.tip_encourage': 'Tip to encourage this work',
2027
  'item.tip_board_title': 'Top Supporters (TOP 10)',
@@ -2031,6 +2420,20 @@ const translations = {
2031
  'web.install_on_desktop': 'Please install it in the desktop version',
2032
  'web.purchase_success_desktop': 'Purchase successful! Please install it in the desktop version',
2033
  'crop.ratio_label': 'Ratio',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2034
  'crop.gesture_hint_touch': 'Drag with one finger to reposition | Pinch or use the slider to zoom',
2035
  'crop.gesture_hint_mouse': 'Drag to reposition | Scroll to zoom',
2036
  'item.update_available': 'Update available (Click for silent hot update)',
@@ -2042,7 +2445,19 @@ const translations = {
2042
  'feedback.deleted': 'Successfully deleted',
2043
  'feedback.delete_failed': 'Delete failed',
2044
  'feedback.retry_suffix': ', please try again',
 
 
 
 
 
 
 
 
 
 
 
2045
  'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
 
2046
  'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
2047
  'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
2048
  'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
@@ -2235,6 +2650,8 @@ const translations = {
2235
  'task.add_reference_image': 'Add reference image',
2236
  'task.reference_link_optional': 'Reference link (optional)',
2237
  'task.min_10_credits': 'Minimum 10 credits',
 
 
2238
  'task.deposit_deducted_on_assign': 'Deducted on assignment',
2239
  'task.price_description': 'Price Breakdown',
2240
  'task.deposit_on_assign': 'Deposit (deducted when assigning)',
@@ -2245,15 +2662,25 @@ const translations = {
2245
  'task.please_enter_description': 'Please enter a task description',
2246
  'task.price_min_10': 'Task price cannot be lower than 10 credits',
2247
  'task.please_select_deadline': 'Please select a deadline',
 
 
 
 
 
2248
  'task.uploading_images': 'Uploading images',
2249
  'task.upload_progress': 'Uploading images ({current}/{total})',
2250
  'task.publishing': 'Publishing',
2251
  'task.max_6_images': 'Max 6 images',
 
 
2252
  'task.view_total': '🔥 Total Views',
2253
  'task.view_today': '📅 Today Views',
2254
  'task.save_changes': 'Save Changes',
2255
 
2256
  // Dispute
 
 
 
2257
  'dispute.title': 'Dispute Details',
2258
  'dispute.status_pending': 'Pending Response',
2259
  'dispute.status_responded': 'Pending Arbitration',
@@ -2460,6 +2887,7 @@ const translations = {
2460
  'prompt.text_label': 'Prompt Text (Paid Content)',
2461
  'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
2462
  'prompt.price_label': 'Price',
 
2463
  'prompt.video_label': 'Sample Video',
2464
  'prompt.title_required': 'Please enter a title',
2465
  'prompt.content_required': 'Please enter the intro',
@@ -2507,6 +2935,11 @@ const translations = {
2507
  'post.mode_video': 'Video',
2508
  'post.upload_images': 'Upload Images',
2509
  'post.max_9_images': 'Max 9',
 
 
 
 
 
2510
  'post.click_upload': 'Click to upload',
2511
  'post.upload_video': 'Upload Video',
2512
  'post.click_upload_video': 'Click to select video',
@@ -2519,6 +2952,7 @@ const translations = {
2519
  'post.notice_manage': 'Manage in profile after publishing',
2520
  'post.error_video_format': 'Only mp4, webm, mov supported',
2521
  'post.error_video_size': 'Video cannot exceed 100MB',
 
2522
  'post.error_video_duration': 'Video cannot exceed 3 minutes',
2523
  'post.error_video_load': 'Cannot read video info',
2524
  'post.existing_video': 'Existing video',
@@ -2556,15 +2990,52 @@ const translations = {
2556
  'common.credits': 'Points',
2557
  'common.unknown': 'Unknown',
2558
  'common.load_failed': 'Load failed',
 
2559
  'tip.new_fan': 'New Fan',
 
 
 
 
 
 
 
 
2560
  'tip.supreme_sponsor': 'Supreme Sponsor',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2561
  'tip.points': 'pts',
 
2562
  'chart.loading': 'Loading chart...',
2563
  'chart.load_failed': 'Chart failed to load',
2564
  'image.loading': 'Loading...',
2565
  'image.load_failed': 'Image load failed',
2566
  'image.load_timeout': 'Load timeout',
2567
  'image.gallery': 'Gallery',
 
 
 
 
 
 
 
 
 
 
 
 
2568
  'common.operation_failed': 'Operation failed',
2569
  'common.login_required': 'Please login to continue',
2570
  'common.publish': 'Publish',
@@ -2663,6 +3134,10 @@ const translations = {
2663
  'publish.uploading_images': 'Uploading images ({current}/{total})...',
2664
  'publish.syncing': 'Syncing to global database...',
2665
  'publish.save_success': 'Changes saved and synced!',
 
 
 
 
2666
  'publish.publish_success': 'Published! Your work has been synced globally.',
2667
 
2668
  // Profile page
@@ -2678,6 +3153,32 @@ const translations = {
2678
  'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
2679
  'profile.no_following': 'Not following anyone',
2680
  'profile.no_followers': 'No followers yet',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2681
  'profile.load_more': 'Load More ({count})',
2682
  'profile.no_collected': 'No favorites yet',
2683
  'profile.go_discover': 'Go discover content in rankings or forum!',
@@ -2754,6 +3255,28 @@ const translations = {
2754
  'settings_form.save_failed': 'Settings failed',
2755
  'settings_form.avatar_upload_success': 'Avatar uploaded! Click Save below to apply.',
2756
  'settings_form.banner_cleared': 'Banner cleared. Click Save below to apply.',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2757
 
2758
  // Video player
2759
  'video.play': 'Play',
@@ -2769,6 +3292,10 @@ const translations = {
2769
  'video.volume': 'Volume',
2770
 
2771
  // About plugin
 
 
 
 
2772
  'about.title': 'ComfyUI Community Hub',
2773
  'about.subtitle': 'A modern Web3.0-level ecosystem engine built for ComfyUI',
2774
  'about.stage_alpha': 'Alpha',
 
14
  'zh-CN': {
15
  // 通用
16
  'common.confirm': '确认',
17
+ 'common.confirm_action': '操作确认',
18
+ 'common.confirm_execute': '确认执行',
19
  'common.cancel': '取消',
20
  'common.save': '保存',
21
  'common.saving': '保存中...',
 
22
  'common.deleting': '删除中...',
23
  'common.edit': '编辑',
24
  'common.close': '关闭',
25
+ 'common.advertisement': '广告',
26
+ 'common.advertisement_details': '广告详情',
27
+ 'common.advertisement_details_image': '广告详情图',
28
  'common.loading': '加载中...',
29
  'common.chars': '字符',
30
  'common.digits': '位数',
 
35
  'common.retrying': '重试中...',
36
  'common.retry_failed': '重试失败,请稍后再试',
37
  'common.no_more': '没有更多了',
38
+ 'pagination.loading': '正在加载更多内容',
39
+ 'pagination.end': '已经到底了',
40
+ 'pagination.failed': '加载更多失败,请重试',
41
+ 'pagination.searching': '正在继续查找匹配内容…',
42
+ 'pagination.search_empty': '没有找到匹配的内容',
43
  'common.parse_error': '响应解析失败',
44
  'common.network_error_retry': '网络连接失败,请稍后重试',
45
+ 'install.connection_uncertain': '安装连接中断,结果暂时无法确认。请先检查本地插件目录,再决定是否重试,避免重复安装。',
46
+ 'install.cancel_in_progress': '取消安装',
47
+ 'install.cancelling': '正在请求取消…',
48
+ 'install.cancel_outcome_uncertain': '已请求取消安装,但最终结果可能无法确认。请检查本地插件目录后再决定是否重试。',
49
+ 'install.error_missing_params': '安装请求信息不完整,请刷新页面后重试。',
50
+ 'install.error_invalid_repository': '仓库地址无效或不受支持,请检查资源链接。',
51
+ 'install.error_invalid_target': '安装目标目录无效,已阻止写入。',
52
+ 'install.error_git_missing': '未检测到 Git。请安装 Git 并重启 ComfyUI 后重试。',
53
+ 'install.error_timeout': '安装等待超时。请检查网络和仓库大小,再确认目录状态后重试。',
54
+ 'install.error_git_clone': '镜像源和直连均无法克隆仓库。请检查网络、代理或仓库权限。',
55
+ 'install.error_disk_space': '磁盘空间不足。释放空间后再重试。',
56
+ 'install.error_remote_rejected': '云端拒绝或无法获取资源。请检查购买权限、仓库权限和资源地址。',
57
+ 'install.error_package_too_large': '下载包超过本地安全上限,已停止安装。',
58
+ 'install.error_download': '下载多次中断。请检查网络后重试。',
59
+ 'install.error_target_occupied': '旧版本文件仍被占用。关闭 ComfyUI 后再重试更新。',
60
+ 'install.error_invalid_archive': '下载内容不是有效安装包,或压缩包结构不受支持。',
61
+ 'install.error_generic': '安装失败。请检查本地目录和网络后重试。',
62
+ 'install.diagnostics': '查看安装过程记录',
63
+ 'install.diagnostic_entry': '{time} · {stage} · {progress}',
64
+ 'install.stage_unknown': '未知阶段',
65
+ 'install.stage_validate': '校验参数',
66
+ 'install.stage_queued': '等待其它安装任务完成',
67
+ 'install.error_queue_timeout': '安装任务较多,请稍后重试。',
68
+ 'install.error_queue_full': '安装队列已满,请稍后再试。',
69
+ 'install.error_repository_too_large': '仓库体积或文件数量超过安装上限,请查看资源说明并手动安装。',
70
+ 'install.error_process_guard': '无法建立安装进程保护,请检查插件文件和 ComfyUI 运行环境后重试。',
71
+ 'install.stage_auth': '验证权限',
72
+ 'install.stage_cleanup': '准备安装目录',
73
+ 'install.stage_git_mirror': '连接镜像源',
74
+ 'install.stage_git_clone': '克隆仓库',
75
+ 'install.stage_git_fallback': '切换下载源',
76
+ 'install.stage_git_direct': '直连仓库',
77
+ 'install.stage_downloading': '下载资源包',
78
+ 'install.stage_download_done': '下载完成',
79
+ 'install.stage_extracting': '解压文件',
80
+ 'install.stage_installing': '写入安装目录',
81
+ 'install.stage_complete': '安装完成',
82
+ 'install.stage_failed': '安装失败',
83
+ 'acquire.balance_short': '积分余额不足(当前:{balance},需要:{price})。',
84
+ 'acquire.balance_recharge_prompt': '是否前往个人中心充值?',
85
+ 'acquire.validation_running': '正在检测云端资源有效性...',
86
+ 'acquire.validation_blocked': '资源校验未通过:{message}',
87
+ 'acquire.purchase_confirmation_html': '该资源标价为 <strong style="color:#FF9800;">{price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(一次购买永久免费,已购买的资源不会重复扣款)</span>',
88
+ 'acquire.purchase_running': '正在校验购买状态与扣款安全...',
89
+ 'acquire.invalid_id': '获取失败:资源 ID 无效。',
90
+ 'acquire.already_owned_no_charge': '您已购买过此商品,此次使用不会收费!',
91
+ 'acquire.purchase_success': '支付成功!已扣除 {price} 积分。',
92
+ 'acquire.purchase_balance_short': '获取失败:积分余额不足。',
93
+ 'acquire.purchase_failed': '获取失败:{message}',
94
+ 'acquire.purchase_verification_unavailable': '暂时无法确认安全购买通道,本次未扣款。请稍后重试或联系维护者更新服务。',
95
+ 'acquire.price_changed': '价格已变化或尚未确认,本次未扣款。请刷新资源并重新确认价格。',
96
+ 'acquire.purchase_unknown': '暂未确认购买结果。请先查看钱包和购买记录,重新打开资源确认状态后再操作。',
97
+ 'acquire.pure_link_authorized': '✅ 授权成功,请前往资源原始页面获取:',
98
+ 'install.background_started': '正在后台安装;关闭侧边栏不会影响安装。',
99
+ 'install.open_source': '前往资源原始页面',
100
+ 'install.completed_update': '插件「{title}」的新版本已就绪,重启 ComfyUI 后生效!',
101
+ 'install.completed_restart': '插件「{title}」安装成功!请重启 ComfyUI。',
102
+ 'install.failed_for_item': '插件「{title}」安装失败:{message}',
103
+ 'install.restart_to_apply': '重启 ComfyUI 后即可应用更新。',
104
+ 'install.restart_to_load': '重启 ComfyUI 以加载新节点。',
105
+ 'workflow.loaded_for_item': '工作流「{title}」已成功加载到画布!',
106
+ 'workflow.failed': '工作流加载失败:{message}',
107
+ 'workflow.loaded_warning': '工作流已加载到画布。由于本地节点差异,部分节点可能显示异常;请使用管理器补全节点。',
108
+ 'workflow.local_connection_failed': '无法连接到本地服务:{message}',
109
+ 'netdisk.authorized_with_password': '✅ 授权成功,网盘资源信息如下:',
110
+ 'netdisk.authorized_no_password': '✅ 授权成功,请前往网盘下载:',
111
+ 'netdisk.code_label': '提取码:',
112
+ 'netdisk.open': '打开网盘链接',
113
+ 'netdisk.copy': '复制提取码',
114
+ 'netdisk.download_hint': '复制提取码后前往网盘下载资源。',
115
+ 'netdisk.invalid_link': '资源链接无效或不受支持。',
116
+ 'netdisk.copy_failed': '无法复制提取码,请手动选择并复制。',
117
+ 'workflow.fallback_loading': '授权已通过,正在安全鉴权并加载工作流...',
118
+ 'workflow.http_failed': '工作流加载失败({status}):{message}',
119
+ 'workflow.parse_failed': '响应解析失败,请重试。',
120
+ 'common.downloaded': '已下载',
121
+ 'common.acquired': '已获取',
122
+ 'workflow.canvas_load_failed': '工作流已下载,但无法加载到当前画布。请检查 ComfyUI 页面状态后重试。',
123
+ 'workflow.error_missing_params': '下载请求缺少必要信息,请刷新页面后重试。',
124
+ 'workflow.error_invalid_request': '下载请求格式无效,请刷新页面后重试。',
125
+ 'workflow.error_invalid_id': '工作流资源 ID 无效。',
126
+ 'workflow.error_invalid_data': '云端返回的工作流格式无效,请稍后重试或联系资源发布者。',
127
+ 'workflow.error_auth': '云端未授权此次下载。请检查购买状态或重新登录。',
128
+ 'workflow.error_remote': '云端拒绝或无法获取该工作流。请检查资源链接和访问权限。',
129
+ 'workflow.error_network': '连接云端失败。请检查网络后重试。',
130
+ 'workflow.error_cloud_cooldown': '云端暂时无法连接,请稍后重试。',
131
+ 'workflow.error_generic': '工作流下载失败,请检查网络与本地服务后重试。',
132
+ 'workflow.stage_validate': '校验下载请求',
133
+ 'workflow.stage_cache_check': '检查本地缓存',
134
+ 'workflow.stage_cache_hit': '从本地缓存加载',
135
+ 'workflow.stage_downloading': '从云端下载工作流',
136
+ 'workflow.stage_saving': '保存本地缓存',
137
+ 'workflow.stage_download_complete': '下载完成,正在加载画布',
138
+ 'workflow.stage_failed': '工作流下载失败',
139
+ 'install.progress_label': '安装进度',
140
+ 'install.preparing': '准备中...',
141
+ 'install.complete': '安装完成!',
142
+ 'install.failed': '安装失败',
143
+ 'install.elapsed': '已用时 {time}',
144
+ 'install.preview_loading': '正在检查安装目标...',
145
+ 'install.confirm_git': '授权已通过。确认通过 Git 安装到:{folder}?',
146
+ 'install.confirm_zip': '授权已通过。确认下载并更新到:{folder}?',
147
+ 'install.target_unknown': '本地目标目录待核验',
148
+ 'install.target_self_update': '这是当前插件自身的更新;新版本将在重启 ComfyUI 后应用。',
149
+ 'install.target_exists': '目标目录已存在。安装先写入暂存目录,成功后替换;失败时保留旧版本。',
150
+ 'install.target_new': '目标目录当前不存在,将作为首次安装。',
151
+ 'install.current_git_version': '检测到本地 Git 版本:{version}',
152
+ 'install.recorded_local_version': '前端记录的已安装版本:{version}',
153
+ 'install.version_record_unavailable': '安装成功,但浏览器存储不可用,无法保存本地版本标记。',
154
+ 'install.version_unconfirmed': '安装成功,但本次安装版本未能确认,仍可重新安装。',
155
+ 'install.version_status_unavailable': '浏览器本地存储不可用,暂时无法确认此资源的本地安装版本。',
156
+ 'install.preview_unavailable': '暂时无法读取目录状态;本地安装引擎开始安装时仍会再次校验。',
157
+ 'install.confirm_button': '同意并安装',
158
+ 'install.confirmation_region': '安装目标确认',
159
+ 'workflow.connection_uncertain': '工作流连接中断,加载结果暂时无法确认。请检查画布后再决定是否重试。',
160
  'common.more': '更多',
 
161
  'common.search': '搜索',
162
+ 'common.sort_items': '排序榜单内容',
163
+ 'common.filter_task_status': '按任务状态筛选',
164
+ 'common.sort_tasks': '任务排序方式',
165
+ 'common.sort_posts': '帖子排序方式',
166
+ 'common.sort_prompts': '提示词排序方式',
167
  'common.refresh': '刷新',
168
  'common.copy': '复制',
169
  'common.copied': '已复制',
 
176
  'common.yes': '是',
177
  'common.no': '否',
178
  'common.ok': '好的',
 
179
  'common.reset': '重置',
180
  'common.install': '安装',
181
  'common.installed': '已安装',
 
184
  'common.paid': '付费',
185
 
186
  // 导航
187
+ 'nav.board_navigation': '榜单导航',
188
  'nav.tools': '插件榜',
189
  'nav.skills': 'Skill',
190
  'nav.apps': '工作流',
 
198
  'nav.logout': '退出登录',
199
  'nav.notifications': '消息',
200
  'nav.community': '社区精选',
201
+ 'nav.about_title': '查看关于本插件与作者信息',
202
+ 'footer.community_name': 'ComfyUI精选社区',
203
 
204
  // 设置页面
205
  'settings.title': '界面设置',
 
248
  'auth.token_expired': '登录凭证已失效,请重新登录',
249
  'auth.email': '邮箱',
250
  'auth.password': '密码',
 
251
  'auth.username': '用户名',
252
  'auth.nickname': '昵称',
253
  'auth.forgot_password': '忘记密码?',
 
258
  'auth.your_account': '您的账号',
259
  'auth.go_reset_password': '去重置密码',
260
  'auth.reset_password': '重置密码',
 
261
  'auth.send_code': '发送验证码',
262
  'auth.resend_code': '重新发送',
263
  'auth.login_success': '登录成功',
264
  'auth.register_success': '注册成功',
265
  'auth.logout_success': '已退出登录',
 
266
  'auth.weak_password': '密码强度不足',
267
  'auth.account_exists': '账号已存在',
268
  'auth.invalid_credentials': '账号或密码错误',
 
337
  'profile.their_published': 'TA的发布',
338
  'profile.my_purchased': '我购买的',
339
  'profile.my_tasks': '我的任务',
 
340
  'profile.my_posts': '我的帖子',
341
  'profile.my_collected': '我的收藏',
342
  'profile.recent_likes': '近期点赞',
 
343
  'profile.max_privilege': '最高特权',
344
  'profile.beta_max_privilege': '内测最高特权',
345
  'profile.edit_profile': '编辑资料',
 
401
  'tx.task_payment': '📋 任务尾款',
402
  'tx.task_income': '📋 任务收入',
403
  'tx.task_freeze': '🔒 任务冻结',
404
+ 'tx.task_price_adjust': '🔒 任务价格调整',
405
  'tx.task_refund': '↩️ 任务退款',
406
  'tx.task_cancel_refund': '🔄 任务取消退款',
407
  'tx.sale': '销售收入',
408
  'tx.recharge': '💰 充值',
409
  'tx.refund': '↩️ 退款',
410
+ 'tx.sale_refund': '↩️ 销售退款扣回',
411
  'tx.withdraw_fee': '💸 手续费',
412
  'tx.withdraw_pending': '待打款',
413
  'tx.withdraw_completed': '已完成',
 
439
  'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
440
  'wallet.recharge.processing': '处理中...',
441
  'wallet.recharge.timeout': '支付查询超时,如已支付请稍后刷新查看余额',
442
+ 'wallet.recharge.confirm_amount': '确认充值 {points} 积分(¥{price})?',
443
+ 'wallet.recharge.balance_unavailable': '余额暂未更新,请稍后刷新查看',
444
+ 'wallet.recharge.order_uncertain': '订单结果尚未确认,请勿重复创建,如已支付请查询余额',
445
 
446
  // 提现相关
447
  'wallet.withdraw.title': '💳 收益提现中心',
 
460
  'wallet.withdraw.resend_in': '{seconds}s 后重发',
461
  'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
462
  'wallet.withdraw.submitting': '⏳ 提交中...',
463
+ 'wallet.withdraw.timeout': '提现结果尚未确认,请查看交易记录,勿重复提交',
464
+ 'wallet.withdraw.balance_unavailable': '暂时无法读取最新余额,请稍后再打开提现',
465
  'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
466
  'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
467
  'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
 
487
  'market.detail': '详情',
488
  'market.comments': '评论',
489
  'market.reviews': '评价',
 
 
 
 
490
  'market.views': '🔥 浏览总量',
491
  'market.daily_views': '🔥 今日热门',
492
  'market.rating': '★ 评分最高',
 
501
  'social.messages': '消息',
502
  'social.notifications': '通知',
503
  'social.comment': '评论',
 
504
  'social.share': '分享',
505
  'social.mention': '@提及',
506
  'social.new_message': '新消息',
 
525
  'feedback.success': '操作成功',
526
  'feedback.error': '操作失败,请重试',
527
  'feedback.timeout': '请求超时',
528
+ 'feedback.response_format_error': '服务器响应格式错误',
529
  'feedback.saved': '已保存',
530
  'feedback.logged_out': '已退出登录',
531
  'feedback.login_required': '请先登录您的账号!',
 
559
 
560
  // 反馈补充
561
  'feedback.send_failed': '发送失败',
562
+ 'feedback.send_failed_retry': '发送失败,请重试',
563
 
564
  // 评分
565
  'rating.stars': '评分',
566
+ 'rating.choose_score': '给出 {score} 星评分',
567
  'rating.count': '{count}人评分',
568
  'rating.your_score': '你的评分',
569
  'rating.login_required': '登录后评分',
 
577
  'notif.title': '消息与通知',
578
  'notif.clear': '清空记录',
579
  'notif.no_messages': '暂无任何消息通知',
580
+ 'notif.open_target': '查看通知关联内容',
581
  'notif.followed_you': '关注了您',
582
  'notif.liked': '点赞了您的作品',
583
  'notif.favorited': '收藏了您的作品',
 
681
  'market.delete_permanently': '永久删除',
682
  'market.delete_confirm': '🚨 警告:确定要彻底删除该发布内容吗?<br><br><span style="color:#aaa;">该操作不可逆,且相关的所有评论数据也将被同步永久清空。</span>',
683
  'item.refundable': '支持退款',
684
+ 'item.purchase_status_loading': '正在加载购买状态...',
685
+ 'easter.title': '猪的飞行梦',
686
+ 'easter.hint': '鼠标移动改变视角 | PgUp 加速 PgDn 减速 | ESC 关闭',
687
+ 'easter.speed': '速度',
688
  'item.confirm_refund_title': '确认退款',
689
  'item.confirm_refund': '确认退款',
690
  'item.refund_amount': '退款金额:',
 
704
  'item.refund_consequence_2': '已下载的文件将被自动删除',
705
  'item.refund_consequence_3': '30天内禁止再次购买此商品',
706
  'item.refund_success': '退款成功',
707
+ 'item.refund_success_toast': '退款成功!积分已退还',
708
+ 'item.refund_amount_returned': '{amount} 积分已退还到您的账户',
709
  'item.credits_refunded': '积分已退还到您的账户',
710
  'item.ban_days_notice': '{days}天内禁止再次购买此商品',
711
  'item.refund_failed_retry': '退款失败,请重试',
 
722
  'item.delete_request_failed': '删除请求失败,请检查网络',
723
  'item.no_tips_yet': '该资源暂无专属打赏,快来成为首个赞赏人吧!',
724
  'item.author': '作者',
725
+ 'item.sponsor_thanks': '感谢砚影科技的支持!',
726
  'item.original_notice': '原创内容,请勿商用',
727
  'item.tip_encourage': '赞赏鼓励该作品',
728
  'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
 
732
  'web.install_on_desktop': '请在电脑端进行安装',
733
  'web.purchase_success_desktop': '购买成功!请在电脑端进行安装',
734
  'crop.ratio_label': '比例',
735
+ 'crop.title': '裁剪图片',
736
+ 'crop.zoom_label': '缩放',
737
+ 'crop.confirm': '确认裁剪并上传',
738
+ 'crop.processing': '处理中...',
739
+ 'crop.process_failed': '处理失败,重试',
740
+ 'feedback.processing': '处理中...',
741
+ 'feedback.copied': '已复制到剪贴板',
742
+ 'feedback.copy_failed': '复制失败',
743
+ 'feedback.operation_success': '操作成功',
744
+ 'profile.post_detail_load_failed': '加载帖子详情失败',
745
+ 'profile.prompt_detail_load_failed': '加载提示词详情失败',
746
+ 'tip.invalid_amount': '打赏金额必须大于 0',
747
+ 'tip.amount_limit': '单次打赏上限为 {max} 积分',
748
+ 'tip.balance_short_recharge': '您的积分余额不足,是否立刻前往个人中心充值?',
749
  'crop.gesture_hint_touch': '单指拖动调整位置 | 双指捏合或滑块缩放',
750
  'crop.gesture_hint_mouse': '拖动调整位置 | 滚轮缩放',
751
  'item.update_available': '发现新版本 (点击静默热更新)',
 
761
  'feedback.deleted': '已成功删除',
762
  'feedback.delete_failed': '删除失败',
763
  'feedback.retry_suffix': ',请重试',
764
+ 'feedback.no_search_results': '🔍 没有搜索到相关内容',
765
+ 'feedback.no_list_data': '📭 暂无数据,快来抢沙发吧!',
766
+ 'feedback.load_posts_failed': '讨论区暂时加载失败',
767
+ 'feedback.load_tasks_failed': '任务榜暂时加载失败',
768
+ 'feedback.load_prompts_failed': '提示词列表暂时加载失败',
769
+ 'auth.session_changed': '登录状态已变化,请重试。',
770
+ 'auth.reopen_list': '登录状态已变化,请重新打开榜单后重试。',
771
+ 'auth.form_data_incomplete': '表单信息不完整,请检查必填项。',
772
+ 'task.session_changed': '登录状态已变化,请返回列表后重新打开任务。',
773
+ 'task.cached_readonly': '正在显示上次保存的公开内容,联网确认后可操作。',
774
+ 'task.cached_refresh_failed': '暂时无法刷新,仍显示公开缓存内容;操作需联网确认。',
775
  'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
776
+ 'feedback.refresh_failed_cached': '网络刷新失败,仍显示上次结果',
777
  // 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
778
  'feedback.offline_no_cache': '网络已断开,且无本地缓存',
779
  // ☁️ 云端不可达冷却期内、且本地无缓存可兜底时抛出(详见 网络请求_基础设施.js)。
 
844
 
845
  // 任务详情
846
  'task.detail_title': '任务详情',
 
847
  'task.published_at': '发布于',
848
  'task.deadline_label': '截止日期',
 
849
  'task.ref_images': '🖼️ 参考图片',
850
  'task.ref_link': '🔗 参考链接',
851
  'task.assignee_label': '👷 接单者',
 
859
  'task.login_to_operate': '请登录后操作',
860
  'task.cancel_task': '❌ 取消任务',
861
  'task.edit_task': '编辑任务',
 
862
  'task.description_label': '任务描述',
 
863
  'task.edit_success': '任务修改成功',
864
  'task.edit_failed': '任务修改失败',
865
  'task.edit_limited_notice': '任务已有人接单,仅可修改标题、描述和参考链接',
 
873
  'task.accept': '✅ 验收通过',
874
  'task.reject': '❌ 验收不通过',
875
  'task.dispute': '⚖️ 发起申诉',
 
876
  'task.apply_message': '请输入申请留言(可选):',
877
  'task.apply_success': '申请成功!',
878
  'task.apply_failed': '申请失败',
879
  'task.cancel_apply_confirm': '确定撤回申请吗?',
880
  'task.cancel_apply_success': '已撤回申请',
 
881
  'task.cancel_confirm': '确定取消任务吗?此操作不可撤销。',
882
  'task.cancel_success': '任务已取消',
883
  'task.cancel_failed': '取消失败',
 
907
  // 发布任务
908
  'task.publish_title': '📝 发布任务',
909
  'task.title_label': '📌 任务标题',
 
910
  'task.desc_label': '📄 任务描述',
911
  'task.desc_placeholder': '详细描述任务要求、交付标准、参考说明等...',
912
  'task.images_label': '🖼️ 参考图片(可选,最多6张)',
 
935
  'task.max_images': '最多上传6张图片',
936
 
937
  // 申诉相关
938
+ 'dispute.session_changed': '登录状态已变化,请返回后重新打开申诉。',
939
+ 'dispute.open_evidence': '打开证据图片 {index}',
940
+ 'dispute.remove_evidence': '移除证据图片 {index}',
941
  'dispute.title': '申诉详情',
942
  'dispute.status_pending': '等待回应',
943
  'dispute.status_responded': '等待仲裁',
 
1118
  'prompt.text_label': '提示词正文(付费内容)',
1119
  'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
1120
  'prompt.price_label': '定价',
1121
+ 'prompt.price_invalid': '请输入 0–{max} 的整数价格。',
1122
  'prompt.video_label': '示例视频',
1123
  'prompt.title_required': '请输入标题',
1124
  'prompt.content_required': '请输入介绍文案',
 
1147
  'post.images_label': '🖼️ 上传图片',
1148
  'post.images_required': '(最多9张,第一张为封面)',
1149
  'post.images_placeholder': '点击或拖拽上传图片',
 
1150
  'post.title_placeholder': '给你的作品起个标题...',
 
1151
  'post.content_placeholder': '分享你的创作心得、使用技巧...',
1152
  'post.notice_title': '💡 发布须知:',
1153
  'post.notice_1': '请上传原创作品或标明出处',
1154
  'post.notice_2': '图片将自动压缩优化',
1155
  'post.notice_3': '发布后可在个人中心管理',
 
1156
  'post.images_required_error': '请至少上传一张图片',
1157
  'post.title_required': '请输入标题',
1158
  'post.uploading': '⏳ 上传图片中...',
 
1163
  'post.mode_label': '发布模式',
1164
  'post.mode_image': '图文',
1165
  'post.mode_video': '视频',
 
 
 
1166
  'post.upload_video': '上传视频',
1167
  'post.click_upload_video': '点击选择视频',
1168
  'post.video_cover': '视频封面',
1169
  'post.capture_frame': '截取当前帧',
1170
  'post.upload_cover': '手动上传封面',
 
 
 
 
1171
  'post.error_video_format': '仅支持 mp4、webm、mov 格式的视频',
1172
  'post.error_video_size': '视频大小不能超过 100MB',
1173
+ 'post.video_limits_hint': 'mp4/webm/mov,最多 {megabytes}MB、{minutes} 分钟',
1174
  'post.error_video_duration': '视频时长不能超过 3 分钟',
1175
  'post.error_video_load': '无法读取视频信息',
1176
  'post.existing_video': '已有视频',
 
1178
  'post.cover_captured': '封面已更新',
1179
  'post.cover_capture_failed': '封面截取失败',
1180
  'post.error_cover_format': '请选择图片文件作为封面',
 
1181
  'post.error_no_content': '请输入正文内容',
1182
  'post.error_no_video': '请选择视频文件',
1183
  'post.error_no_cover': '请等待封面生成或手动上传封面',
 
1194
  'post.error_no_audio': '请选择音频文件',
1195
  'post.uploading_audio': '上传音频中...',
1196
  'post.media_exclusive': '视频与「图片 + 音频」二选一,已自动清空另一组',
 
 
 
 
 
 
 
1197
 
1198
  // 通用补充 - 组件使用
1199
  'common.message': '私信',
1200
  'common.credits': '积分',
1201
  'common.unknown': '未知',
1202
  'common.load_failed': '加载失败',
1203
+ 'common.sidebar_load_failed': '侧边栏加载失败,请刷新页面重试',
1204
 
1205
  // 🎰 打赏等级
1206
  'tip.new_fan': '新粉丝',
1207
+ 'tip.level_max': '至尊赞助者 (已满级)',
1208
+ 'tip.level_super': '超级赞助者',
1209
+ 'tip.level_gold': '黄金赞助者',
1210
+ 'tip.level_silver': '白银赞助者',
1211
+ 'tip.level_bronze': '青铜赞助者',
1212
+ 'tip.level_fan': '热心粉丝',
1213
+ 'tip.level_supporter': '支持者',
1214
+ 'tip.no_tips_yet': '暂无打赏,快来成为首个赞赏人吧!',
1215
  'tip.supreme_sponsor': '至尊赞助者',
1216
+ 'tip.supreme_sponsor_success': '恭喜您成为至尊赞助者!已达最高等级。',
1217
+ 'tip.success_thanks': '打赏成功!感谢您的慷慨支持。',
1218
+ 'tip.confirm': '确认打赏',
1219
+ 'tip.amount_label': '输入打赏金额',
1220
+ 'tip.level_preview': '打赏后等级预览',
1221
+ 'tip.rules_title': '等级规则',
1222
+ 'tip.rule_star': '每 {points} 积分为一档星星',
1223
+ 'tip.rule_moon': '每 {stars} 颗星星组成一个月亮',
1224
+ 'tip.rule_sun': '每 {moons} 个月亮组成一个太阳',
1225
+ 'tip.rule_max': '最高 {suns} 个太阳,共 {points} 积分',
1226
+ 'tip.anonymous': '匿名打赏(隐藏我的名字)',
1227
+ 'tip.dialog_title': '赞赏 {name}',
1228
+ 'tip.integer_amount': '打赏金额必须是大于 0 的整数',
1229
+ 'tip.unknown_result': '暂未确认打赏结果。请先查看钱包和赞赏记录,再进行下一次操作。',
1230
+ 'tip.balance_refresh_failed': '打赏已成功,余额暂未取得,请刷新钱包确认。',
1231
  'tip.points': '积分',
1232
+ 'common.seconds': '秒',
1233
 
1234
  // 📊 图表
1235
  'chart.loading': '正在加载图表...',
 
1240
  'image.load_failed': '图片加载失败',
1241
  'image.load_timeout': '加载超时',
1242
  'image.gallery': '效果展示图',
1243
+ 'image.viewer.fullscreen': '全屏播放',
1244
+ 'image.viewer.load_failed': '图像播放器加载失败,请刷新页面重试',
1245
+ 'image.viewer.close': '关闭 (ESC)',
1246
+ 'image.viewer.previous': '上一张 (←)',
1247
+ 'image.viewer.next': '下一张 (→)',
1248
+ 'image.viewer.autoplay': '自动播放 (空格)',
1249
+ 'image.viewer.pause': '暂停自动播放 (空格)',
1250
+ 'image.viewer.dialog': '图片查看器',
1251
+ 'image.viewer.previous_short': '上一张',
1252
+ 'image.viewer.next_short': '下一张',
1253
+ 'image.viewer.close_short': '关闭',
1254
+ 'image.viewer.image_position': '第 {current} 张,共 {total} 张',
1255
  'common.operation_failed': '操作失败',
1256
  'common.login_required': '请登录后操作',
1257
  'common.publish': '发布',
 
1312
  'task.reference_link_optional': '参考链接(可选)',
1313
  'task.deposit_ratio': '订金比例',
1314
  'task.min_10_credits': '最低10积分',
1315
+ 'task.price_range': '请输入 0–{max} 的整数任务赏金。',
1316
+ 'task.deposit_invalid': '请选择有效的订金比例。',
1317
  'task.deposit_deducted_on_assign': '接单时扣除',
1318
  'task.price_description': '价格说明',
1319
  'task.deposit_on_assign': '订金(指派接单时扣除)',
 
1324
  'task.please_enter_description': '请输入任务描述',
1325
  'task.price_min_10': '任务价格不能低于10积分',
1326
  'task.please_select_deadline': '请选择截止日期',
1327
+ 'task.deadline_invalid': '请选择有效的截止日期。',
1328
+ 'task.price_locked': '⚠️ 任务进行中,价格不可修改',
1329
+ 'task.deposit_locked': '⚠️ 任务进行中,订金比例不可修改',
1330
+ 'task.deadline_locked': '⚠️ 任务进行中,截止日期不可修改',
1331
+ 'task.remove_reference_image': '删除第 {index} 张参考图片',
1332
  'task.uploading_images': '上传图片中',
1333
  'task.upload_progress': '上传图片 ({current}/{total})',
1334
  'task.publishing': '发布中',
1335
  'task.max_6_images': '最多上传6张图片',
1336
+ 'task.images_reading': '参考图片仍在读取,请稍候再提交。',
1337
+ 'task.image_size_limit': '请选择非空且不超过 {max} MB 的参考图片。',
1338
  'task.view_total': '🔥 浏览总量',
1339
  'task.view_today': '📅 今日浏览',
1340
  'task.save_changes': '保存修改',
 
1363
  // 发布帖子补充
1364
  'post.upload_images': '上传图片',
1365
  'post.max_9_images': '最多9张,第一张为封面',
1366
+ 'post.image_invalid_file': '图片格式无效或超出处理限制,请重新选择图片',
1367
+ 'post.image_preview_failed': '图片预览失败,请移除后重新选择',
1368
+ 'post.image_position': '第 {index} 张图片,共 {total} 张',
1369
+ 'post.remove_image': '删除第 {index} 张图片',
1370
+ 'post.image_move_hint': '按 Alt 加左右方向键,在已有图片或新图片组内调整顺序;第一张为封面。',
1371
  'post.click_upload': '点击或拖拽上传图片',
 
 
1372
  'post.publish_notice': '发布须知',
1373
  'post.notice_original': '请上传原创作品或标明出处',
1374
  'post.notice_compress': '图片将自动压缩优化',
 
1478
  'publish.uploading_images': '正在上传图片 ({current}/{total})...',
1479
  'publish.syncing': '正在同步全网数据库...',
1480
  'publish.save_success': '修改已保存并同步全网!',
1481
+ 'publish.save_unconfirmed': '保存结果尚未确认。请先返回个人作品核对,避免重复发布或修改。',
1482
+ 'publish.result_pending': '保存结果待确认',
1483
+ 'publish.edit_conflict': '这条作品已被其他页面修改。请返回并重新打开编辑,核对最新内容后再保存。',
1484
+ 'publish.edit_reopen': '请重新打开编辑',
1485
  'publish.publish_success': '发布成功!您的作品已全网同步。',
1486
 
1487
  // 🌐 个人中心页面
 
1499
  'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
1500
  'profile.no_following': '暂无关注的人',
1501
  'profile.no_followers': '暂无粉丝',
1502
+ 'profile.no_purchased_resources': '还没有购买任何资源',
1503
+ 'profile.discover_resources': '去榜单页面发现优秀插件吧!',
1504
+ 'profile.loading_resources': '正在加载资源...',
1505
+ 'profile.loading_tasks': '正在加载任务...',
1506
+ 'profile.no_task_records': '还没有任何任务记录',
1507
+ 'profile.go_tasks': '去任务榜发布或接受任务吧!',
1508
+ 'profile.published_tasks': '我发布的任务',
1509
+ 'profile.assigned_tasks': '我接的任务',
1510
+ 'profile.load_tasks_failed': '任务加载失败',
1511
+ 'profile.loading_transactions': '正在加载交易记录...',
1512
+ 'profile.task_earnings_stats': '任务收益统计',
1513
+ 'profile.task_income': '任务收入',
1514
+ 'profile.task_expense': '任务支出',
1515
+ 'profile.net_earnings': '净收益',
1516
+ 'profile.no_transactions': '暂无交易记录',
1517
+ 'profile.load_transactions_failed': '交易记录加载失败',
1518
+ 'profile.loading_posts': '正在加载帖子...',
1519
+ 'profile.no_posts': '还没有发布任何帖子',
1520
+ 'profile.go_posts': '去讨论区分享你的想法吧!',
1521
+ 'profile.load_posts_failed': '帖子加载失败',
1522
+ 'profile.loading_prompts': '正在加载提示词...',
1523
+ 'profile.no_prompts': '还没有发布任何提示词',
1524
+ 'profile.go_prompts': '去提示词广场分享你的创作吧!',
1525
+ 'profile.load_prompts_failed': '提示词加载失败',
1526
+ 'profile.fetching_data': '正在拉取数据...',
1527
+ 'profile.no_records': '暂无记录',
1528
  'profile.load_more': '加载更多 ({count})',
1529
  'profile.no_collected': '还没有收藏任何内容',
1530
  'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
 
1601
  'settings_form.save_failed': '设置失败',
1602
  'settings_form.avatar_upload_success': '头像上传成功!请点击底部保存设置生效。',
1603
  'settings_form.banner_cleared': '已清除背景图,请点击底部保存设置生效。',
1604
+ 'settings_form.file_read_failed': '文件读取失败,请重试。',
1605
+ 'settings_form.crop_avatar': '裁剪头像',
1606
+ 'settings_form.crop_banner': '裁剪个人资料卡背景',
1607
+ 'settings_form.crop_ui_bg': '裁剪界面背景',
1608
+ 'settings_form.uploading': '上传中...',
1609
+ 'settings_form.avatar_process_failed': '头像处理失败,请重新选择图片后重试。',
1610
+ 'settings_form.banner_selected': '背景图已选择,点击底部保存设置后才会上传。',
1611
+ 'settings_form.ui_bg_applied': '界面背景设置成功,已立即生效!',
1612
+ 'settings_form.ui_bg_cleared': '已清除界面背景,已立即生效。',
1613
+ 'settings_form.saving': '保存中...',
1614
+ 'settings_form.uploading_banner': '上传背景图...',
1615
+ 'settings_form.banner_upload_failed': '背景图上传失败,请检查网络后重试。',
1616
+ 'settings_form.saving_settings': '保存设置...',
1617
+ 'settings_form.settings_saved': '设置已保存并同步至云端!',
1618
+ 'settings_form.settings_save_failed': '设置保存失败,请检查网络后重试。',
1619
+ 'settings_form.local_storage_unavailable': '浏览器本地存储操作失败,请检查存储空间或权限。',
1620
+ 'settings_form.enabled': '已启用',
1621
+ 'settings_form.disabled': '未启用',
1622
+ 'settings_form.moderation_quota': '本月已用:阿里云 {aliyun}/3000,腾讯云 {tencent}/10000',
1623
+ 'settings_form.moderation_skipped': '上传图片将跳过审核',
1624
+ 'settings_form.moderation_enabled': '图像审核已启用',
1625
+ 'settings_form.moderation_disabled': '图像审核已关闭',
1626
  // 视频播放器
1627
  'video.play': '播放',
1628
  'video.pause': '暂停',
 
1637
  'video.volume': '音量',
1638
 
1639
  // 关于插件
1640
+ 'about.brand': '社区精选',
1641
+ 'about.stage_unknown': '阶段未知',
1642
+ 'about.version_unknown': '版本未知',
1643
+ 'about.author_link': '访问作者的哔哩哔哩主页',
1644
  'about.title': 'ComfyUI 社区精选',
1645
  'about.subtitle': '专为 ComfyUI 打造的现代化 Web3.0 级别生态引擎',
1646
  'about.stage_alpha': 'Alpha 内测',
 
1706
  'en-US': {
1707
  // Common
1708
  'common.confirm': 'Confirm',
1709
+ 'common.confirm_action': 'Confirm action',
1710
+ 'common.confirm_execute': 'Confirm and continue',
1711
  'common.cancel': 'Cancel',
1712
  'common.save': 'Save',
1713
  'common.saving': 'Saving...',
 
1714
  'common.deleting': 'Deleting...',
1715
  'common.edit': 'Edit',
1716
  'common.close': 'Close',
1717
+ 'common.advertisement': 'Advertisement',
1718
+ 'common.advertisement_details': 'Advertisement details',
1719
+ 'common.advertisement_details_image': 'Advertisement detail image',
1720
  'common.loading': 'Loading...',
1721
  'common.chars': 'chars',
1722
  'common.digits': 'digits',
 
1727
  'common.retrying': 'Retrying...',
1728
  'common.retry_failed': 'Retry failed, please try again later',
1729
  'common.no_more': 'No more items',
1730
+ 'pagination.loading': 'Loading more items',
1731
+ 'pagination.end': 'You have reached the end',
1732
+ 'pagination.failed': 'Could not load more items. Please retry.',
1733
+ 'pagination.searching': 'Searching more items…',
1734
+ 'pagination.search_empty': 'No matching items were found',
1735
  'common.parse_error': 'Failed to parse response',
1736
  'common.network_error_retry': 'Network connection failed, please try again later',
1737
+ 'install.connection_uncertain': 'The install connection was interrupted, so its result is unknown. Check the local plugin folder before retrying to avoid duplicate installs.',
1738
+ 'install.cancel_in_progress': 'Cancel installation',
1739
+ 'install.cancelling': 'Requesting cancellation…',
1740
+ 'install.cancel_outcome_uncertain': 'Cancellation was requested, but the final result may be unknown. Check the local plugin folder before retrying.',
1741
+ 'install.error_missing_params': 'The install request is incomplete. Refresh the page and try again.',
1742
+ 'install.error_invalid_repository': 'The repository URL is invalid or unsupported. Check the resource link.',
1743
+ 'install.error_invalid_target': 'The install target is invalid. No files were written.',
1744
+ 'install.error_git_missing': 'Git was not found. Install Git and restart ComfyUI before retrying.',
1745
+ 'install.error_timeout': 'The install timed out. Check the network and repository size, then verify the folder before retrying.',
1746
+ 'install.error_git_clone': 'Neither the mirror nor direct Git connection could clone the repository. Check the network, proxy, or repository access.',
1747
+ 'install.error_disk_space': 'There is not enough disk space. Free space before retrying.',
1748
+ 'install.error_remote_rejected': 'The cloud service rejected or could not fetch the resource. Check purchase access, repository permissions, and the resource URL.',
1749
+ 'install.error_package_too_large': 'The download exceeded the local safety limit. Installation was stopped.',
1750
+ 'install.error_download': 'The download failed repeatedly. Check the network before retrying.',
1751
+ 'install.error_target_occupied': 'Files from the previous version are in use. Close ComfyUI before retrying the update.',
1752
+ 'install.error_invalid_archive': 'The download is not a valid package or its archive structure is unsupported.',
1753
+ 'install.error_generic': 'Installation failed. Check the local folder and network before retrying.',
1754
+ 'install.diagnostics': 'View installation log',
1755
+ 'install.diagnostic_entry': '{time} · {stage} · {progress}',
1756
+ 'install.stage_unknown': 'Unknown stage',
1757
+ 'install.stage_validate': 'Validate request',
1758
+ 'install.stage_queued': 'Waiting for other installations',
1759
+ 'install.error_queue_timeout': 'There are too many installation tasks. Please try again later.',
1760
+ 'install.error_queue_full': 'The installation queue is full. Please try again later.',
1761
+ 'install.error_repository_too_large': 'The repository exceeds the installation size or file count limit. Check the resource instructions and install it manually.',
1762
+ 'install.error_process_guard': 'Installation process protection could not be set up. Check the plugin files and ComfyUI environment, then retry.',
1763
+ 'install.stage_auth': 'Verify access',
1764
+ 'install.stage_cleanup': 'Prepare target folder',
1765
+ 'install.stage_git_mirror': 'Connect to mirror',
1766
+ 'install.stage_git_clone': 'Clone repository',
1767
+ 'install.stage_git_fallback': 'Switch download source',
1768
+ 'install.stage_git_direct': 'Connect directly to repository',
1769
+ 'install.stage_downloading': 'Download package',
1770
+ 'install.stage_download_done': 'Download complete',
1771
+ 'install.stage_extracting': 'Extract files',
1772
+ 'install.stage_installing': 'Write install folder',
1773
+ 'install.stage_complete': 'Installation complete',
1774
+ 'install.stage_failed': 'Installation failed',
1775
+ 'acquire.balance_short': 'Insufficient points (current: {balance}, required: {price}).',
1776
+ 'acquire.balance_recharge_prompt': 'Go to your profile to add funds?',
1777
+ 'acquire.validation_running': 'Checking resource availability...',
1778
+ 'acquire.validation_blocked': 'Resource validation failed: {message}',
1779
+ 'acquire.purchase_confirmation_html': 'This resource costs <strong style="color:#FF9800;">{price} points</strong>.<br><br>Continue?<br><span style="font-size:12px;color:#aaa;">(Each purchase is permanent. Previously purchased resources are not charged again.)</span>',
1780
+ 'acquire.purchase_running': 'Verifying purchase status and payment...',
1781
+ 'acquire.invalid_id': 'Could not continue: the resource ID is invalid.',
1782
+ 'acquire.already_owned_no_charge': 'You already own this item. This use will not charge you again.',
1783
+ 'acquire.purchase_success': 'Payment successful. {price} points were charged.',
1784
+ 'acquire.purchase_balance_short': 'Could not continue: insufficient points.',
1785
+ 'acquire.purchase_failed': 'Could not continue: {message}',
1786
+ 'acquire.purchase_verification_unavailable': 'A safe purchase cannot be verified. No charge was made. Try later or ask the maintainer to update the service.',
1787
+ 'acquire.price_changed': 'The price changed or was not confirmed. No charge was made. Refresh the resource and confirm its price again.',
1788
+ 'acquire.purchase_unknown': 'The purchase result is unconfirmed. Check your wallet and purchases, then reopen the resource to confirm its status before continuing.',
1789
+ 'acquire.pure_link_authorized': '✅ Access granted. Continue on the resource source page:',
1790
+ 'install.background_started': 'Installing in the background. Closing the sidebar will not stop it.',
1791
+ 'install.open_source': 'Open the resource source page',
1792
+ 'install.completed_update': 'The new version of “{title}” is ready. Restart ComfyUI to apply it.',
1793
+ 'install.completed_restart': '“{title}” installed. Restart ComfyUI to load it.',
1794
+ 'install.failed_for_item': 'Could not install “{title}”: {message}',
1795
+ 'install.restart_to_apply': 'Restart ComfyUI to apply the update.',
1796
+ 'install.restart_to_load': 'Restart ComfyUI to load the new nodes.',
1797
+ 'workflow.loaded_for_item': 'Workflow “{title}” loaded onto the canvas.',
1798
+ 'workflow.failed': 'Could not load workflow: {message}',
1799
+ 'workflow.loaded_warning': 'Workflow loaded. Some nodes may be unavailable locally; use the manager to install missing nodes.',
1800
+ 'workflow.local_connection_failed': 'Could not connect to the local service: {message}',
1801
+ 'netdisk.authorized_with_password': '✅ Access granted. Net disk details:',
1802
+ 'netdisk.authorized_no_password': '✅ Access granted. Open the net disk to download:',
1803
+ 'netdisk.code_label': 'Access code:',
1804
+ 'netdisk.open': 'Open net disk link',
1805
+ 'netdisk.copy': 'Copy access code',
1806
+ 'netdisk.download_hint': 'Copy the access code, then use it to download from the net disk.',
1807
+ 'netdisk.invalid_link': 'The resource link is invalid or unsupported.',
1808
+ 'netdisk.copy_failed': 'Could not copy the access code. Select and copy it manually.',
1809
+ 'workflow.fallback_loading': 'Authorization passed. Verifying access and loading the workflow...',
1810
+ 'workflow.http_failed': 'Could not load workflow ({status}): {message}',
1811
+ 'workflow.parse_failed': 'Could not parse the response. Please retry.',
1812
+ 'common.downloaded': 'Downloaded',
1813
+ 'common.acquired': 'Acquired',
1814
+ 'workflow.canvas_load_failed': 'The workflow was downloaded but could not be loaded onto the current canvas. Check the ComfyUI page and try again.',
1815
+ 'workflow.error_missing_params': 'The download request is incomplete. Refresh the page and try again.',
1816
+ 'workflow.error_invalid_request': 'The download request is invalid. Refresh the page and try again.',
1817
+ 'workflow.error_invalid_id': 'The workflow resource ID is invalid.',
1818
+ 'workflow.error_invalid_data': 'The cloud service returned an invalid workflow. Try again later or contact the publisher.',
1819
+ 'workflow.error_auth': 'The cloud service did not authorize this download. Check the purchase status or sign in again.',
1820
+ 'workflow.error_remote': 'The cloud service rejected or could not fetch this workflow. Check the resource URL and access permissions.',
1821
+ 'workflow.error_network': 'Could not connect to the cloud service. Check the network and try again.',
1822
+ 'workflow.error_cloud_cooldown': 'The cloud service is temporarily unreachable. Try again later.',
1823
+ 'workflow.error_generic': 'Workflow download failed. Check the network and local service before retrying.',
1824
+ 'workflow.stage_validate': 'Validate download request',
1825
+ 'workflow.stage_cache_check': 'Check local cache',
1826
+ 'workflow.stage_cache_hit': 'Load from local cache',
1827
+ 'workflow.stage_downloading': 'Download workflow from cloud',
1828
+ 'workflow.stage_saving': 'Save local cache',
1829
+ 'workflow.stage_download_complete': 'Download complete; loading canvas',
1830
+ 'workflow.stage_failed': 'Workflow download failed',
1831
+ 'install.progress_label': 'Installation progress',
1832
+ 'install.preparing': 'Preparing...',
1833
+ 'install.complete': 'Installation complete!',
1834
+ 'install.failed': 'Installation failed',
1835
+ 'install.elapsed': 'Elapsed {time}',
1836
+ 'install.preview_loading': 'Checking the install target...',
1837
+ 'install.confirm_git': 'Authorization passed. Install with Git to: {folder}?',
1838
+ 'install.confirm_zip': 'Authorization passed. Download and update: {folder}?',
1839
+ 'install.target_unknown': 'Local target folder needs verification',
1840
+ 'install.target_self_update': 'This updates the current plugin. The new version will apply after ComfyUI restarts.',
1841
+ 'install.target_exists': 'The folder exists. Installation uses a staging folder, replaces it only after success, and preserves the old version on failure.',
1842
+ 'install.target_new': 'The target folder does not exist; this will be a first-time install.',
1843
+ 'install.current_git_version': 'Detected local Git version: {version}',
1844
+ 'install.recorded_local_version': 'Installed version recorded by the frontend: {version}',
1845
+ 'install.version_record_unavailable': 'Installation succeeded, but browser storage is unavailable; the local version marker could not be saved.',
1846
+ 'install.version_unconfirmed': 'Installation succeeded, but this installed version could not be confirmed. You can still reinstall.',
1847
+ 'install.version_status_unavailable': 'Browser storage is unavailable, so the locally installed version cannot be confirmed.',
1848
+ 'install.preview_unavailable': 'Folder status is unavailable. The local installer will validate the target again before installation.',
1849
+ 'install.confirm_button': 'Confirm install',
1850
+ 'install.confirmation_region': 'Install target confirmation',
1851
+ 'workflow.connection_uncertain': 'The workflow connection was interrupted, so its result is unknown. Check the canvas before retrying.',
1852
  'common.more': 'More',
 
1853
  'common.search': 'Search',
1854
+ 'common.sort_items': 'Sort board items',
1855
+ 'common.filter_task_status': 'Filter tasks by status',
1856
+ 'common.sort_tasks': 'Task sort order',
1857
+ 'common.sort_posts': 'Post sort order',
1858
+ 'common.sort_prompts': 'Prompt sort order',
1859
  'common.refresh': 'Refresh',
1860
  'common.copy': 'Copy',
1861
  'common.copied': 'Copied',
 
1868
  'common.yes': 'Yes',
1869
  'common.no': 'No',
1870
  'common.ok': 'OK',
 
1871
  'common.reset': 'Reset',
1872
  'common.install': 'Install',
1873
  'common.installed': 'Installed',
 
1876
  'common.paid': 'Paid',
1877
 
1878
  // Navigation
1879
+ 'nav.board_navigation': 'Board navigation',
1880
  'nav.tools': 'Plugins',
1881
  'nav.skills': 'Skills',
1882
  'nav.apps': 'Workflows',
 
1890
  'nav.logout': 'Logout',
1891
  'nav.notifications': 'Notifications',
1892
  'nav.community': 'Ranking',
1893
+ 'nav.about_title': 'About this plugin and its author',
1894
+ 'footer.community_name': 'ComfyUI Community',
1895
 
1896
  // Settings
1897
  'settings.title': 'Settings',
 
2093
  'tx.task_payment': '📋 Task Payment',
2094
  'tx.task_income': '📋 Task Income',
2095
  'tx.task_freeze': '🔒 Task Frozen',
2096
+ 'tx.task_price_adjust': '🔒 Task Price Adjustment',
2097
  'tx.task_refund': '↩️ Task Refund',
2098
  'tx.task_cancel_refund': '🔄 Task Cancel Refund',
2099
  'tx.sale': 'Sales Income',
2100
  'tx.recharge': '💰 Recharge',
2101
  'tx.refund': '↩️ Refund',
2102
+ 'tx.sale_refund': '↩️ Sales refund reversal',
2103
  'tx.withdraw_fee': '💸 Withdrawal Fee',
2104
  'tx.withdraw_pending': 'Pending',
2105
  'tx.withdraw_completed': 'Completed',
 
2131
  'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
2132
  'wallet.recharge.processing': 'Processing...',
2133
  'wallet.recharge.timeout': 'Payment check timed out. If you have already paid, refresh later to see your balance',
2134
+ 'wallet.recharge.confirm_amount': 'Recharge {points} points (¥{price})?',
2135
+ 'wallet.recharge.balance_unavailable': 'Balance is not updated yet. Refresh later',
2136
+ 'wallet.recharge.order_uncertain': 'Order is not confirmed. Do not create another order; check balance if paid',
2137
 
2138
  // Withdraw
2139
  'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
 
2152
  'wallet.withdraw.resend_in': 'Resend in {seconds}s',
2153
  'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
2154
  'wallet.withdraw.submitting': '⏳ Submitting...',
2155
+ 'wallet.withdraw.timeout': 'Withdrawal is not confirmed. Check transactions before submitting again',
2156
+ 'wallet.withdraw.balance_unavailable': 'Current balance is unavailable. Reopen withdrawal later',
2157
  'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
2158
  'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
2159
  'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
 
2179
  'market.detail': 'Details',
2180
  'market.comments': 'Comments',
2181
  'market.reviews': 'Reviews',
 
 
 
 
2182
  'market.views': '🔥 Most Viewed',
2183
  'market.daily_views': '🔥 Trending Today',
2184
  'market.rating': '★ Highest Rated',
 
2218
  'feedback.success': 'Success',
2219
  'feedback.error': 'Failed, please retry',
2220
  'feedback.timeout': 'Request timeout',
2221
+ 'feedback.response_format_error': 'The server response format is invalid',
2222
  'feedback.saved': 'Saved',
2223
  'feedback.logged_out': 'Logged out',
2224
  'feedback.login_required': 'Please log in first!',
 
2249
  'common.send': 'Send',
2250
  'common.delete': 'Delete',
2251
  'feedback.send_failed': 'Send failed',
2252
+ 'feedback.send_failed_retry': 'Failed to send. Please try again.',
2253
 
2254
  // Rating
2255
  'rating.stars': 'Rating',
2256
+ 'rating.choose_score': 'Rate {score} stars',
2257
  'rating.count': '{count} ratings',
2258
  'rating.your_score': 'Your rating',
2259
  'rating.login_required': 'Login to rate',
 
2267
  'notif.title': 'Messages & Notifications',
2268
  'notif.clear': 'Clear All',
2269
  'notif.no_messages': 'No messages yet',
2270
+ 'notif.open_target': 'Open related content',
2271
  'notif.followed_you': 'followed you',
2272
  'notif.liked': 'liked your work',
2273
  'notif.favorited': 'favorited your work',
 
2369
  'market.delete_permanently': 'Delete',
2370
  'market.delete_confirm': '🚨 Warning: Are you sure you want to permanently delete this content?<br><br><span style="color:#aaa;">This action is irreversible and all related comments will be deleted.</span>',
2371
  'item.refundable': 'Refundable',
2372
+ 'item.purchase_status_loading': 'Loading purchase status...',
2373
+ 'easter.title': 'Flying Pig Dream',
2374
+ 'easter.hint': 'Move the mouse to look | PgUp speeds up, PgDn slows down | ESC closes',
2375
+ 'easter.speed': 'Speed',
2376
  'item.confirm_refund_title': 'Confirm Refund',
2377
  'item.confirm_refund': 'Confirm Refund',
2378
  'item.refund_amount': 'Refund Amount: ',
 
2392
  'item.refund_consequence_2': 'Downloaded files will be automatically deleted',
2393
  'item.refund_consequence_3': 'Re-purchase is prohibited for 30 days',
2394
  'item.refund_success': 'Refund Successful',
2395
+ 'item.refund_success_toast': 'Refund successful. Points have been returned.',
2396
+ 'item.refund_amount_returned': '{amount} points have been refunded to your account',
2397
  'item.credits_refunded': ' credits have been refunded to your account',
2398
  'item.ban_days_notice': 'Re-purchase prohibited for {days} days',
2399
  'item.refund_failed_retry': 'Refund failed, please retry',
 
2410
  'item.delete_request_failed': 'Delete request failed, please check network',
2411
  'item.no_tips_yet': 'No tips yet for this resource. Be the first to support!',
2412
  'item.author': 'Author',
2413
+ 'item.sponsor_thanks': 'With thanks to Yanying Technology for its support.',
2414
  'item.original_notice': 'Original content, do not use commercially',
2415
  'item.tip_encourage': 'Tip to encourage this work',
2416
  'item.tip_board_title': 'Top Supporters (TOP 10)',
 
2420
  'web.install_on_desktop': 'Please install it in the desktop version',
2421
  'web.purchase_success_desktop': 'Purchase successful! Please install it in the desktop version',
2422
  'crop.ratio_label': 'Ratio',
2423
+ 'crop.title': 'Crop image',
2424
+ 'crop.zoom_label': 'Zoom',
2425
+ 'crop.confirm': 'Crop and upload',
2426
+ 'crop.processing': 'Processing...',
2427
+ 'crop.process_failed': 'Processing failed. Try again.',
2428
+ 'feedback.processing': 'Processing...',
2429
+ 'feedback.copied': 'Copied to clipboard',
2430
+ 'feedback.copy_failed': 'Could not copy',
2431
+ 'feedback.operation_success': 'Operation completed',
2432
+ 'profile.post_detail_load_failed': 'Could not load post details',
2433
+ 'profile.prompt_detail_load_failed': 'Could not load prompt details',
2434
+ 'tip.invalid_amount': 'Tip amount must be greater than 0',
2435
+ 'tip.amount_limit': 'The maximum tip per transaction is {max} points',
2436
+ 'tip.balance_short_recharge': 'Your points balance is too low. Go to your profile to recharge now?',
2437
  'crop.gesture_hint_touch': 'Drag with one finger to reposition | Pinch or use the slider to zoom',
2438
  'crop.gesture_hint_mouse': 'Drag to reposition | Scroll to zoom',
2439
  'item.update_available': 'Update available (Click for silent hot update)',
 
2445
  'feedback.deleted': 'Successfully deleted',
2446
  'feedback.delete_failed': 'Delete failed',
2447
  'feedback.retry_suffix': ', please try again',
2448
+ 'feedback.no_search_results': '🔍 No matching results found',
2449
+ 'feedback.no_list_data': '📭 No data yet',
2450
+ 'feedback.load_posts_failed': 'Could not load the forum right now',
2451
+ 'feedback.load_tasks_failed': 'Could not load tasks right now',
2452
+ 'feedback.load_prompts_failed': 'Could not load prompts right now',
2453
+ 'auth.session_changed': 'Your sign-in status changed. Please try again.',
2454
+ 'auth.reopen_list': 'Your sign-in status changed. Reopen the list and try again.',
2455
+ 'auth.form_data_incomplete': 'The form is incomplete. Check the required fields.',
2456
+ 'task.session_changed': 'Your sign-in status changed. Return to the list and reopen this task.',
2457
+ 'task.cached_readonly': 'Showing saved public content. Actions become available after an online check.',
2458
+ 'task.cached_refresh_failed': 'Refresh unavailable. Saved public content remains visible; actions require an online check.',
2459
  'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
2460
+ 'feedback.refresh_failed_cached': 'Refresh failed. The previous results are still shown.',
2461
  'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
2462
  'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
2463
  'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
 
2650
  'task.add_reference_image': 'Add reference image',
2651
  'task.reference_link_optional': 'Reference link (optional)',
2652
  'task.min_10_credits': 'Minimum 10 credits',
2653
+ 'task.price_range': 'Enter a whole-number task reward from 0 to {max}.',
2654
+ 'task.deposit_invalid': 'Select a valid deposit ratio.',
2655
  'task.deposit_deducted_on_assign': 'Deducted on assignment',
2656
  'task.price_description': 'Price Breakdown',
2657
  'task.deposit_on_assign': 'Deposit (deducted when assigning)',
 
2662
  'task.please_enter_description': 'Please enter a task description',
2663
  'task.price_min_10': 'Task price cannot be lower than 10 credits',
2664
  'task.please_select_deadline': 'Please select a deadline',
2665
+ 'task.deadline_invalid': 'Select a valid deadline date.',
2666
+ 'task.price_locked': '⚠️ The task is in progress. Its reward cannot be changed.',
2667
+ 'task.deposit_locked': '⚠️ The task is in progress. Its deposit ratio cannot be changed.',
2668
+ 'task.deadline_locked': '⚠️ The task is in progress. Its deadline cannot be changed.',
2669
+ 'task.remove_reference_image': 'Remove reference image {index}',
2670
  'task.uploading_images': 'Uploading images',
2671
  'task.upload_progress': 'Uploading images ({current}/{total})',
2672
  'task.publishing': 'Publishing',
2673
  'task.max_6_images': 'Max 6 images',
2674
+ 'task.images_reading': 'Reference images are still loading. Wait before submitting.',
2675
+ 'task.image_size_limit': 'Choose a non-empty reference image no larger than {max} MB.',
2676
  'task.view_total': '🔥 Total Views',
2677
  'task.view_today': '📅 Today Views',
2678
  'task.save_changes': 'Save Changes',
2679
 
2680
  // Dispute
2681
+ 'dispute.session_changed': 'Your sign-in status changed. Go back and reopen this dispute.',
2682
+ 'dispute.open_evidence': 'Open evidence image {index}',
2683
+ 'dispute.remove_evidence': 'Remove evidence image {index}',
2684
  'dispute.title': 'Dispute Details',
2685
  'dispute.status_pending': 'Pending Response',
2686
  'dispute.status_responded': 'Pending Arbitration',
 
2887
  'prompt.text_label': 'Prompt Text (Paid Content)',
2888
  'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
2889
  'prompt.price_label': 'Price',
2890
+ 'prompt.price_invalid': 'Enter a whole-number price from 0 to {max}.',
2891
  'prompt.video_label': 'Sample Video',
2892
  'prompt.title_required': 'Please enter a title',
2893
  'prompt.content_required': 'Please enter the intro',
 
2935
  'post.mode_video': 'Video',
2936
  'post.upload_images': 'Upload Images',
2937
  'post.max_9_images': 'Max 9',
2938
+ 'post.image_invalid_file': 'Invalid image or image exceeds processing limits. Choose another image.',
2939
+ 'post.image_preview_failed': 'Image preview failed. Remove it and choose the image again.',
2940
+ 'post.image_position': 'Image {index} of {total}',
2941
+ 'post.remove_image': 'Remove image {index}',
2942
+ 'post.image_move_hint': 'Press Alt and Left or Right to reorder within existing or new images. The first image is the cover.',
2943
  'post.click_upload': 'Click to upload',
2944
  'post.upload_video': 'Upload Video',
2945
  'post.click_upload_video': 'Click to select video',
 
2952
  'post.notice_manage': 'Manage in profile after publishing',
2953
  'post.error_video_format': 'Only mp4, webm, mov supported',
2954
  'post.error_video_size': 'Video cannot exceed 100MB',
2955
+ 'post.video_limits_hint': 'mp4/webm/mov, up to {megabytes}MB and {minutes} minutes',
2956
  'post.error_video_duration': 'Video cannot exceed 3 minutes',
2957
  'post.error_video_load': 'Cannot read video info',
2958
  'post.existing_video': 'Existing video',
 
2990
  'common.credits': 'Points',
2991
  'common.unknown': 'Unknown',
2992
  'common.load_failed': 'Load failed',
2993
+ 'common.sidebar_load_failed': 'Could not load the sidebar. Refresh the page and try again.',
2994
  'tip.new_fan': 'New Fan',
2995
+ 'tip.level_max': 'Supreme Sponsor (Maximum Level)',
2996
+ 'tip.level_super': 'Super Sponsor',
2997
+ 'tip.level_gold': 'Gold Sponsor',
2998
+ 'tip.level_silver': 'Silver Sponsor',
2999
+ 'tip.level_bronze': 'Bronze Sponsor',
3000
+ 'tip.level_fan': 'Dedicated Fan',
3001
+ 'tip.level_supporter': 'Supporter',
3002
+ 'tip.no_tips_yet': 'No tips yet. Be the first to support!',
3003
  'tip.supreme_sponsor': 'Supreme Sponsor',
3004
+ 'tip.supreme_sponsor_success': 'Congratulations, you reached the highest sponsor level!',
3005
+ 'tip.success_thanks': 'Tip sent successfully. Thank you for your generous support!',
3006
+ 'tip.confirm': 'Confirm Tip',
3007
+ 'tip.amount_label': 'Tip amount',
3008
+ 'tip.level_preview': 'Tip level preview',
3009
+ 'tip.rules_title': 'Level rules',
3010
+ 'tip.rule_star': 'One star per block of {points} points',
3011
+ 'tip.rule_moon': 'Every {stars} stars forms one moon',
3012
+ 'tip.rule_sun': 'Every {moons} moons forms one sun',
3013
+ 'tip.rule_max': 'Maximum {suns} suns at {points} points',
3014
+ 'tip.anonymous': 'Tip anonymously (hide my name)',
3015
+ 'tip.dialog_title': 'Support {name}',
3016
+ 'tip.integer_amount': 'Tip amount must be a positive whole number',
3017
+ 'tip.unknown_result': 'The tip result is unconfirmed. Check your wallet and tip history before another action.',
3018
+ 'tip.balance_refresh_failed': 'The tip succeeded, but the balance is unavailable. Refresh your wallet to confirm.',
3019
  'tip.points': 'pts',
3020
+ 'common.seconds': 'sec',
3021
  'chart.loading': 'Loading chart...',
3022
  'chart.load_failed': 'Chart failed to load',
3023
  'image.loading': 'Loading...',
3024
  'image.load_failed': 'Image load failed',
3025
  'image.load_timeout': 'Load timeout',
3026
  'image.gallery': 'Gallery',
3027
+ 'image.viewer.fullscreen': 'Open fullscreen viewer',
3028
+ 'image.viewer.load_failed': 'Could not load the image viewer. Refresh and try again.',
3029
+ 'image.viewer.close': 'Close (Esc)',
3030
+ 'image.viewer.previous': 'Previous image (←)',
3031
+ 'image.viewer.next': 'Next image (→)',
3032
+ 'image.viewer.autoplay': 'Start slideshow (Space)',
3033
+ 'image.viewer.pause': 'Pause slideshow (Space)',
3034
+ 'image.viewer.dialog': 'Image viewer',
3035
+ 'image.viewer.previous_short': 'Previous',
3036
+ 'image.viewer.next_short': 'Next',
3037
+ 'image.viewer.close_short': 'Close',
3038
+ 'image.viewer.image_position': 'Image {current} of {total}',
3039
  'common.operation_failed': 'Operation failed',
3040
  'common.login_required': 'Please login to continue',
3041
  'common.publish': 'Publish',
 
3134
  'publish.uploading_images': 'Uploading images ({current}/{total})...',
3135
  'publish.syncing': 'Syncing to global database...',
3136
  'publish.save_success': 'Changes saved and synced!',
3137
+ 'publish.save_unconfirmed': 'The save result is unconfirmed. Check your works before publishing or editing again.',
3138
+ 'publish.result_pending': 'Save result unconfirmed',
3139
+ 'publish.edit_conflict': 'This work was changed in another view. Reopen the editor and check the latest content before saving.',
3140
+ 'publish.edit_reopen': 'Reopen the editor',
3141
  'publish.publish_success': 'Published! Your work has been synced globally.',
3142
 
3143
  // Profile page
 
3153
  'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
3154
  'profile.no_following': 'Not following anyone',
3155
  'profile.no_followers': 'No followers yet',
3156
+ 'profile.no_purchased_resources': 'You have not purchased any resources yet',
3157
+ 'profile.discover_resources': 'Explore the rankings to find useful plugins.',
3158
+ 'profile.loading_resources': 'Loading resources...',
3159
+ 'profile.loading_tasks': 'Loading tasks...',
3160
+ 'profile.no_task_records': 'No task records yet',
3161
+ 'profile.go_tasks': 'Publish a task or accept one from the task board.',
3162
+ 'profile.published_tasks': 'Tasks I published',
3163
+ 'profile.assigned_tasks': 'Tasks I accepted',
3164
+ 'profile.load_tasks_failed': 'Failed to load tasks',
3165
+ 'profile.loading_transactions': 'Loading transactions...',
3166
+ 'profile.task_earnings_stats': 'Task Earnings Statistics',
3167
+ 'profile.task_income': 'Task Income',
3168
+ 'profile.task_expense': 'Task Expenses',
3169
+ 'profile.net_earnings': 'Net Earnings',
3170
+ 'profile.no_transactions': 'No transactions yet',
3171
+ 'profile.load_transactions_failed': 'Failed to load transactions',
3172
+ 'profile.loading_posts': 'Loading posts...',
3173
+ 'profile.no_posts': 'You have not published any posts yet',
3174
+ 'profile.go_posts': 'Share your thoughts in the forum.',
3175
+ 'profile.load_posts_failed': 'Failed to load posts',
3176
+ 'profile.loading_prompts': 'Loading prompts...',
3177
+ 'profile.no_prompts': 'You have not published any prompts yet',
3178
+ 'profile.go_prompts': 'Share your creations in the prompt gallery.',
3179
+ 'profile.load_prompts_failed': 'Failed to load prompts',
3180
+ 'profile.fetching_data': 'Fetching data...',
3181
+ 'profile.no_records': 'No records yet',
3182
  'profile.load_more': 'Load More ({count})',
3183
  'profile.no_collected': 'No favorites yet',
3184
  'profile.go_discover': 'Go discover content in rankings or forum!',
 
3255
  'settings_form.save_failed': 'Settings failed',
3256
  'settings_form.avatar_upload_success': 'Avatar uploaded! Click Save below to apply.',
3257
  'settings_form.banner_cleared': 'Banner cleared. Click Save below to apply.',
3258
+ 'settings_form.file_read_failed': 'Could not read the file. Please try again.',
3259
+ 'settings_form.crop_avatar': 'Crop avatar',
3260
+ 'settings_form.crop_banner': 'Crop profile banner',
3261
+ 'settings_form.crop_ui_bg': 'Crop interface background',
3262
+ 'settings_form.uploading': 'Uploading...',
3263
+ 'settings_form.avatar_process_failed': 'Could not process the avatar. Choose another image and try again.',
3264
+ 'settings_form.banner_selected': 'Banner selected. Save settings below to upload it.',
3265
+ 'settings_form.ui_bg_applied': 'Interface background updated.',
3266
+ 'settings_form.ui_bg_cleared': 'Interface background cleared.',
3267
+ 'settings_form.saving': 'Saving...',
3268
+ 'settings_form.uploading_banner': 'Uploading banner...',
3269
+ 'settings_form.banner_upload_failed': 'Could not upload the banner. Check the network and try again.',
3270
+ 'settings_form.saving_settings': 'Saving settings...',
3271
+ 'settings_form.settings_saved': 'Settings saved and synced to the cloud.',
3272
+ 'settings_form.settings_save_failed': 'Could not save settings. Check the network and try again.',
3273
+ 'settings_form.local_storage_unavailable': 'Browser storage operation failed. Check available storage and permissions.',
3274
+ 'settings_form.enabled': 'Enabled',
3275
+ 'settings_form.disabled': 'Disabled',
3276
+ 'settings_form.moderation_quota': 'Used this month: Aliyun {aliyun}/3000, Tencent Cloud {tencent}/10000',
3277
+ 'settings_form.moderation_skipped': 'Uploaded images will skip moderation',
3278
+ 'settings_form.moderation_enabled': 'Image moderation enabled',
3279
+ 'settings_form.moderation_disabled': 'Image moderation disabled',
3280
 
3281
  // Video player
3282
  'video.play': 'Play',
 
3292
  'video.volume': 'Volume',
3293
 
3294
  // About plugin
3295
+ 'about.brand': 'Community Hub',
3296
+ 'about.stage_unknown': 'Unknown stage',
3297
+ 'about.version_unknown': 'Unknown version',
3298
+ 'about.author_link': 'Visit the author on Bilibili',
3299
  'about.title': 'ComfyUI Community Hub',
3300
  'about.subtitle': 'A modern Web3.0-level ecosystem engine built for ComfyUI',
3301
  'about.stage_alpha': 'Alpha',
前端页面/components/用户体验增强.js CHANGED
@@ -38,16 +38,27 @@ export function createAutoSaver(formId, formElement, options = {}) {
38
  } = options;
39
 
40
  let debounceTimer = null;
 
 
 
41
  const storageKey = DRAFT_PREFIX + formId;
 
 
 
 
 
 
 
 
42
 
43
  // 获取表单数据
44
  const getFormData = () => {
45
- const data = {};
46
  const inputs = formElement.querySelectorAll('input, textarea, select');
47
 
48
  inputs.forEach(input => {
49
  const name = input.name || input.id;
50
- if (!name || excludeFields.includes(name)) return;
51
 
52
  if (input.type === 'checkbox') {
53
  data[name] = input.checked;
@@ -63,22 +74,34 @@ export function createAutoSaver(formId, formElement, options = {}) {
63
 
64
  // 保存草稿
65
  const saveDraft = () => {
 
66
  try {
67
  const data = getFormData();
68
  data._savedAt = Date.now();
69
  localStorage.setItem(storageKey, JSON.stringify(data));
70
- console.log(`📝 草稿已自动保存: ${formId}`);
71
  } catch (e) {
72
- console.warn('草稿保存失败:', e);
73
  }
74
  };
75
 
76
  // 加载草稿
77
  const loadDraft = () => {
 
78
  try {
79
  const saved = localStorage.getItem(storageKey);
80
  if (!saved) return null;
81
- return JSON.parse(saved);
 
 
 
 
 
 
 
 
 
 
82
  } catch (e) {
83
  return null;
84
  }
@@ -86,28 +109,37 @@ export function createAutoSaver(formId, formElement, options = {}) {
86
 
87
  // 恢复草稿到表单
88
  const restoreDraft = (data) => {
89
- if (!data) return;
 
 
90
 
91
  const inputs = formElement.querySelectorAll('input, textarea, select');
92
  inputs.forEach(input => {
93
  const name = input.name || input.id;
94
- if (!name || !(name in data)) return;
 
 
95
 
96
  if (input.type === 'checkbox') {
97
  input.checked = !!data[name];
98
  } else if (input.type === 'radio') {
99
  input.checked = input.value === data[name];
100
  } else {
101
- input.value = data[name] || '';
102
  }
103
  });
104
 
105
- if (onRestore) onRestore(data);
106
  };
107
 
108
  // 清除草稿
109
  const clearDraft = () => {
110
- localStorage.removeItem(storageKey);
 
 
 
 
 
111
  };
112
 
113
  // 检查是否有草稿
@@ -118,8 +150,14 @@ export function createAutoSaver(formId, formElement, options = {}) {
118
 
119
  // 输入事件监听(防抖保存)
120
  const handleInput = () => {
 
121
  if (debounceTimer) clearTimeout(debounceTimer);
122
- debounceTimer = setTimeout(saveDraft, DRAFT_DEBOUNCE);
 
 
 
 
 
123
  };
124
 
125
  // 页面离开前保存
@@ -129,12 +167,16 @@ export function createAutoSaver(formId, formElement, options = {}) {
129
 
130
  // 初始化
131
  const init = () => {
132
- // 监听输入事件
133
- formElement.addEventListener('input', handleInput);
134
- formElement.addEventListener('change', handleInput);
 
 
 
135
 
136
- // 页面离开前保存
137
- window.addEventListener('beforeunload', handleBeforeUnload);
 
138
 
139
  // 检查是否有草稿需要恢复
140
  const draft = loadDraft();
@@ -151,10 +193,14 @@ export function createAutoSaver(formId, formElement, options = {}) {
151
  // 防止内存泄漏和已卸载 DOM 上的事件监听器残留。
152
  // 调用时机:弹窗关闭回调、组件 unmount 钩子、或 beforeunload 兜底。
153
  const destroy = () => {
 
 
 
154
  formElement.removeEventListener('input', handleInput);
155
  formElement.removeEventListener('change', handleInput);
156
  window.removeEventListener('beforeunload', handleBeforeUnload);
157
  if (debounceTimer) clearTimeout(debounceTimer);
 
158
  };
159
 
160
  return {
 
38
  } = options;
39
 
40
  let debounceTimer = null;
41
+ let initialized = false;
42
+ let disposed = false;
43
+ let saveGeneration = 0;
44
  const storageKey = DRAFT_PREFIX + formId;
45
+
46
+ const canSaveInput = input => {
47
+ const name = input.name || input.id;
48
+ return name && name !== '_savedAt' && !excludeFields.includes(name)
49
+ && !['password', 'file', 'hidden', 'submit', 'button', 'reset'].includes(input.type)
50
+ && !['current-password', 'new-password', 'one-time-code'].includes(input.autocomplete)
51
+ && (!fields.length || fields.some(selector => input.matches(selector)));
52
+ };
53
 
54
  // 获取表单数据
55
  const getFormData = () => {
56
+ const data = Object.create(null);
57
  const inputs = formElement.querySelectorAll('input, textarea, select');
58
 
59
  inputs.forEach(input => {
60
  const name = input.name || input.id;
61
+ if (!canSaveInput(input)) return;
62
 
63
  if (input.type === 'checkbox') {
64
  data[name] = input.checked;
 
74
 
75
  // 保存草稿
76
  const saveDraft = () => {
77
+ if (disposed) return;
78
  try {
79
  const data = getFormData();
80
  data._savedAt = Date.now();
81
  localStorage.setItem(storageKey, JSON.stringify(data));
82
+ console.log('📝 草稿已自动保存');
83
  } catch (e) {
84
+ console.warn('草稿保存失败');
85
  }
86
  };
87
 
88
  // 加载草稿
89
  const loadDraft = () => {
90
+ if (disposed) return null;
91
  try {
92
  const saved = localStorage.getItem(storageKey);
93
  if (!saved) return null;
94
+ const data = JSON.parse(saved);
95
+ if (!data || typeof data !== 'object' || Array.isArray(data)
96
+ || !Number.isFinite(data._savedAt) || data._savedAt <= 0) return null;
97
+ const projected = Object.create(null);
98
+ projected._savedAt = data._savedAt;
99
+ formElement.querySelectorAll('input, textarea, select').forEach(input => {
100
+ const name = input.name || input.id;
101
+ if (canSaveInput(input) && Object.prototype.hasOwnProperty.call(data, name)
102
+ && ['string', 'number', 'boolean'].includes(typeof data[name])) projected[name] = data[name];
103
+ });
104
+ return projected;
105
  } catch (e) {
106
  return null;
107
  }
 
109
 
110
  // 恢复草稿到表单
111
  const restoreDraft = (data) => {
112
+ if (disposed || !data || typeof data !== 'object' || Array.isArray(data)) return;
113
+ const restored = Object.create(null);
114
+ if (Number.isFinite(data._savedAt) && data._savedAt > 0) restored._savedAt = data._savedAt;
115
 
116
  const inputs = formElement.querySelectorAll('input, textarea, select');
117
  inputs.forEach(input => {
118
  const name = input.name || input.id;
119
+ if (!canSaveInput(input) || !Object.prototype.hasOwnProperty.call(data, name)) return;
120
+ if (!['string', 'number', 'boolean'].includes(typeof data[name])) return;
121
+ restored[name] = data[name];
122
 
123
  if (input.type === 'checkbox') {
124
  input.checked = !!data[name];
125
  } else if (input.type === 'radio') {
126
  input.checked = input.value === data[name];
127
  } else {
128
+ input.value = data[name] ?? '';
129
  }
130
  });
131
 
132
+ if (onRestore) onRestore(restored);
133
  };
134
 
135
  // 清除草稿
136
  const clearDraft = () => {
137
+ if (disposed) return;
138
+ saveGeneration++;
139
+ if (debounceTimer) clearTimeout(debounceTimer);
140
+ debounceTimer = null;
141
+ try { localStorage.removeItem(storageKey); }
142
+ catch { console.warn('草稿清除失败'); }
143
  };
144
 
145
  // 检查是否有草稿
 
150
 
151
  // 输入事件监听(防抖保存)
152
  const handleInput = () => {
153
+ if (disposed) return;
154
  if (debounceTimer) clearTimeout(debounceTimer);
155
+ const generation = ++saveGeneration;
156
+ debounceTimer = setTimeout(() => {
157
+ if (disposed || generation !== saveGeneration) return;
158
+ debounceTimer = null;
159
+ saveDraft();
160
+ }, DRAFT_DEBOUNCE);
161
  };
162
 
163
  // 页面离开前保存
 
167
 
168
  // 初始化
169
  const init = () => {
170
+ if (disposed) return { hasDraft: false };
171
+ if (!initialized) {
172
+ initialized = true;
173
+ // 监听输入事件
174
+ formElement.addEventListener('input', handleInput);
175
+ formElement.addEventListener('change', handleInput);
176
 
177
+ // 页面离开前保存
178
+ window.addEventListener('beforeunload', handleBeforeUnload);
179
+ }
180
 
181
  // 检查是否有草稿需要恢复
182
  const draft = loadDraft();
 
193
  // 防止内存泄漏和已卸载 DOM 上的事件监听器残留。
194
  // 调用时机:弹窗关闭回调、组件 unmount 钩子、或 beforeunload 兜底。
195
  const destroy = () => {
196
+ if (disposed) return;
197
+ disposed = true;
198
+ saveGeneration++;
199
  formElement.removeEventListener('input', handleInput);
200
  formElement.removeEventListener('change', handleInput);
201
  window.removeEventListener('beforeunload', handleBeforeUnload);
202
  if (debounceTimer) clearTimeout(debounceTimer);
203
+ debounceTimer = null;
204
  };
205
 
206
  return {
前端页面/components/顶部导航组件.js CHANGED
@@ -2,18 +2,21 @@
2
  import { createAuthView } from "../auth/用户注册登录组件.js";
3
  import { openUserProfileModal } from "../profile/个人中心视图.js";
4
  import { openChatModal, loadChatUnreadCount } from "../social/私信聊天组件.js";
5
- import { api } from "../core/网络请求API.js";
6
  import { showToast } from "./UI交互提示组件.js";
7
  import { showAboutInfo } from "./关于插件组件.js";
8
  import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
9
  import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
10
- import { CACHE, escapeHtml } from "../core/全局配置.js";
 
 
11
  import { t } from "./用户体验增强.js"; // 🌐 多语言支持
12
  import { createTopBanner } from "./顶部广告组件.js";
13
  import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
14
 
15
  // 🚀 新增:消息轮询定时器
16
  let messagePollingTimer = null;
 
17
  let isPollingActive = false; // 🔧 P3优化:防止重复启动轮询
18
 
19
  // 🔧 P1修复:window 监听只安装一次并始终指向最新导航实例
@@ -37,89 +40,59 @@ function _registerNavWindowListeners() {
37
  }
38
 
39
  export function createTopNav() {
 
40
  const userHeader = document.createElement("div");
41
- Object.assign(userHeader.style, {
42
- display: "flex", justifyContent: "space-between", alignItems: "center",
43
- padding: "15px 10px", backgroundColor: "#1a1a1a", borderBottom: "1px solid var(--border-color, #333)"
44
- });
45
-
46
- const titleSpan = document.createElement("div");
47
- titleSpan.innerHTML = `<strong style='color:#4CAF50;'>ComfyUI</strong> ${t('nav.community')}`;
48
-
49
- Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" });
50
- titleSpan.title = "查看关于本插件与作者信息";
51
- titleSpan.onmouseover = () => titleSpan.style.opacity = "0.8";
52
- titleSpan.onmouseout = () => titleSpan.style.opacity = "1";
53
  titleSpan.onclick = () => showAboutInfo(currentUser);
54
 
55
- // ⚙️ 设置按钮(放在社区精选旁边)
56
  const settingsBtn = document.createElement("button");
57
- Object.assign(settingsBtn.style, {
58
- background: "transparent",
59
- border: "none",
60
- color: "#888",
61
- cursor: "pointer",
62
- fontSize: "16px",
63
- marginLeft: "8px",
64
- padding: "4px",
65
- transition: "0.2s",
66
- verticalAlign: "middle"
67
- });
68
- settingsBtn.innerHTML = "⚙️";
69
  settingsBtn.title = t('nav.settings');
70
- settingsBtn.onmouseover = () => { settingsBtn.style.color = "#4CAF50"; settingsBtn.style.transform = "rotate(30deg)"; };
71
- settingsBtn.onmouseout = () => { settingsBtn.style.color = "#888"; settingsBtn.style.transform = "rotate(0deg)"; };
72
  settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsPage(); };
73
 
74
- // 📦 将标题和设置按钮包裹在一起
75
  const titleWrapper = document.createElement("div");
76
- titleWrapper.style.display = "flex";
77
- titleWrapper.style.alignItems = "center";
78
  titleWrapper.appendChild(titleSpan);
79
  titleWrapper.appendChild(settingsBtn);
80
 
81
  const userActionBtn = document.createElement("button");
82
- Object.assign(userActionBtn.style, { padding: "6px 12px", backgroundColor: "#333", color: "#fff", border: "1px solid #555", borderRadius: "4px", cursor: "pointer", fontSize: "12px", transition: "background 0.2s" });
 
83
 
84
  const chatEntryBtn = document.createElement("button");
85
- Object.assign(chatEntryBtn.style, { background: "transparent", border: "none", color: "#aaa", cursor: "pointer", fontSize: "18px", marginRight: "10px", transition: "0.2s", position: "relative" });
86
- // 🔔 私信未读红点:与铃铛 #unread-badge 同款样式,数据源为 chats.json 各会话未读汇总
87
- chatEntryBtn.innerHTML = `✉️<span id="chat-unread-badge" style="display:none; position:absolute; top:-4px; right:-8px; background:#F44336; color:white; font-size:10px; font-weight:bold; padding:2px 5px; border-radius:10px; line-height:1;">0</span>`;
88
  chatEntryBtn.title = t('social.chat');
 
89
 
90
  const bellBtn = document.createElement("button");
91
- Object.assign(bellBtn.style, { background: "transparent", border: "none", color: "#aaa", cursor: "pointer", fontSize: "18px", position: "relative", marginRight: "15px" });
92
- bellBtn.innerHTML = `🔔<span id="unread-badge" style="display:none; position:absolute; top:-4px; right:-8px; background:#F44336; color:white; font-size:10px; font-weight:bold; padding:2px 5px; border-radius:10px; line-height:1;">0</span>`;
93
-
94
- let currentUser = null;
95
- try {
96
- const savedUserStr = localStorage.getItem("ComfyCommunity_User");
97
- if (savedUserStr) {
98
- const data = JSON.parse(savedUserStr);
99
- if (data.expireAt && Date.now() < data.expireAt) { currentUser = data.user; }
100
- else { localStorage.removeItem("ComfyCommunity_User"); localStorage.removeItem("ComfyCommunity_Token"); }
101
- } else {
102
- const tempUserStr = sessionStorage.getItem("ComfyCommunity_User");
103
- if (tempUserStr) { currentUser = JSON.parse(tempUserStr).user; }
104
- }
105
- } catch (e) {}
106
 
107
- // 🔧 P1修复:登出/token失效/storage 监听已提升至模块级统一注册(见 _registerNavWindowListeners),
108
- // 避免每次 createTopNav 重复 addEventListener 导致闭包累积与轮询失效
109
-
110
- chatEntryBtn.onclick = () => {
111
- if (!currentUser) return showToast(t('auth.login_required'), "warning");
112
- openChatModal(currentUser);
113
- };
114
-
115
- // 【核心瘦身】:直接调用拆分出去的独立组件模块
116
- bellBtn.onclick = () => openNotificationCenter(currentUser, bellBtn);
117
 
118
  const updateUserButtonState = () => {
119
  if (currentUser) {
120
  userActionBtn.innerHTML = `👤 ${escapeHtml(currentUser.name || currentUser.account || t('common.unknown_user'))}`;
121
- userActionBtn.style.backgroundColor = "#2196F3";
122
- userActionBtn.style.borderColor = "#2196F3";
123
  userActionBtn.onclick = () => openUserProfileModal(currentUser);
124
  loadUnreadCount(currentUser, bellBtn);
125
  loadChatUnreadCount(currentUser, chatEntryBtn);
@@ -131,85 +104,71 @@ export function createTopNav() {
131
  stopMessagePolling();
132
 
133
  userActionBtn.innerHTML = `🔑 ${t('nav.login')}`;
134
- userActionBtn.style.backgroundColor = "#333";
135
- userActionBtn.style.borderColor = "#555";
136
  bellBtn.querySelector("#unread-badge").style.display = "none";
137
  chatEntryBtn.querySelector("#chat-unread-badge").style.display = "none";
138
 
139
  userActionBtn.onclick = () => {
140
- const view = createAuthView(async (formData) => {
 
 
 
 
141
  try {
142
- userActionBtn.innerHTML = `⏳ ${t('common.loading')}`;
143
- let userData; let token; let isRemember = false;
144
-
145
- if (formData.type === "reset") {
146
- userActionBtn.innerHTML = `⏳ ${t('auth.resetting_password')}`;
147
-
148
- // 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲!
149
- await api.resetPassword(formData);
150
-
151
- showToast(t('auth.password_reset_success'), "success");
152
- window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return;
153
  }
154
- if (formData.type === "register") {
155
- // 🚀 核心修复:/api/upload 需要登录凭证,注册时尚无 token 必然 401。
156
- // 调整时序为:先注册(不带头像)→ 自动登录拿到 token → 再上传头像并写入资料。
157
- const pendingAvatarFile = formData.avatarFile || null;
158
- delete formData.avatarFile;
159
- delete formData.avatarDataUrl; // base64 预览值会被后端拒绝,不随注册提交
160
-
161
- userActionBtn.innerHTML = `⏳ ${t('auth.registering')}`;
162
- await api.register(formData);
163
- showToast(t('auth.register_success') + t('auth.auto_login'), "success");
164
- // 注册后自动登录,默认保持登录
165
- const res = await api.login(formData.account, formData.password, true);
166
- userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res };
167
- token = res.token; isRemember = true;
168
-
169
- // 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization)
170
- if (pendingAvatarFile && token) {
171
- userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar')}`;
172
- // 提前写入 token,确保上传与资料更新请求能通过认证
173
- localStorage.setItem("ComfyCommunity_Token", token);
174
- try {
175
- // 统一走带 processAvatar 压缩的包装器,规避后端 2MB 头像限制
176
- const cloudAvatarUrl = await uploadFile(pendingAvatarFile, "avatar");
177
- if (cloudAvatarUrl) {
178
- const updateRes = await api.updateUserProfile(formData.account, { avatarDataUrl: cloudAvatarUrl });
179
- userData = { ...userData, ...(updateRes.data || {}) };
180
- if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl;
181
- } else {
182
- showToast(t('auth.avatar_set_failed'), "warning");
183
- }
184
- } catch (avatarErr) {
185
- console.warn("注册后设置头像失败:", avatarErr.message || avatarErr);
186
- showToast(t('auth.avatar_set_failed'), "warning");
187
- }
188
- }
189
- } else if (formData.type === "login") {
190
- const res = await api.login(formData.account, formData.password, formData.remember);
191
- token = res.token; isRemember = formData.remember;
192
- try {
193
- if (isRemember) localStorage.setItem("ComfyCommunity_Token", token);
194
- else sessionStorage.setItem("ComfyCommunity_Token", token);
195
- const profileRes = await api.getUserProfile(formData.account);
196
- userData = { account: formData.account, ...profileRes.data };
197
- } catch (e) {
198
- userData = { account: formData.account, name: res.name || res.data?.name || formData.account, avatar: res.avatar || res.data?.avatar };
199
- }
200
- showToast(t('auth.login_success'), "success");
201
  }
202
-
203
- currentUser = userData;
204
- const sessionData = JSON.stringify({ user: currentUser, expireAt: Date.now() + 30 * 24 * 60 * 60 * 1000 });
205
- if (isRemember) { localStorage.setItem("ComfyCommunity_User", sessionData); localStorage.setItem("ComfyCommunity_Token", token); }
206
- else { sessionStorage.setItem("ComfyCommunity_User", sessionData); sessionStorage.setItem("ComfyCommunity_Token", token); }
207
-
208
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
209
- updateUserButtonState();
210
- window.dispatchEvent(new CustomEvent("comfy-user-login"));
211
- } catch (err) {
212
- showToast(`${t('common.error')}: ` + err.message, "error"); updateUserButtonState();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  }
214
  });
215
  window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
@@ -219,31 +178,19 @@ export function createTopNav() {
219
 
220
  // 🔄 跨标签页登录状态同步:其他标签页修改 localStorage 时触发
221
  // 🔧 P1修复:改为模块级统一注册的 storage 监听回调(onStorage),不再逐实例 addEventListener
222
- const handleCrossTabStorage = (event) => {
223
- if (event.key === 'ComfyCommunity_Token' || event.key === 'ComfyCommunity_User') {
224
- if (!event.newValue) {
225
- // 其他标签页登出 → 本标签页也同步登出
226
- if (currentUser) {
227
- currentUser = null;
228
- updateUserButtonState();
229
- }
230
- } else if (event.key === 'ComfyCommunity_User') {
231
- // 其他标签页登入了不同账号 → 刷新本标签页用户状态
232
- try {
233
- const data = JSON.parse(event.newValue);
234
- if (data.user && data.user.account !== (currentUser && currentUser.account)) {
235
- currentUser = data.user;
236
- updateUserButtonState();
237
- }
238
- } catch (e) {}
239
- }
240
  }
241
  };
242
-
243
  updateUserButtonState();
244
 
245
  // 🔧 P1修复:注册为当前活跃实例,模块级监听将路由到本实例的回调
246
  _navInstance = {
 
247
  onLogout: () => { currentUser = null; updateUserButtonState(); },
248
  onStorage: handleCrossTabStorage,
249
  onChatUnreadChanged: () => loadChatUnreadCount(currentUser, chatEntryBtn)
@@ -251,16 +198,16 @@ export function createTopNav() {
251
  _registerNavWindowListeners();
252
 
253
  const actionWrapper = document.createElement("div");
254
- actionWrapper.style.display = "flex"; actionWrapper.style.alignItems = "center";
255
  actionWrapper.appendChild(chatEntryBtn);
256
  actionWrapper.appendChild(bellBtn);
257
  actionWrapper.appendChild(userActionBtn);
258
 
259
- userHeader.appendChild(titleWrapper); // ⚙️ 改为 titleWrapper
260
- // 顶部广告横幅
 
261
  const banner = createTopBanner();
262
  if (banner) userHeader.appendChild(banner);
263
- userHeader.appendChild(actionWrapper);
264
 
265
  return {
266
  dom: userHeader,
@@ -270,38 +217,32 @@ export function createTopNav() {
270
 
271
  // 🚀 新增:启动消息定时轮询
272
  function startMessagePolling(currentUser, bellBtn, chatEntryBtn) {
273
- if (!CACHE.MESSAGE_POLL.ENABLED) return;
274
- if (isPollingActive) return; // 🔧 P3优化:防止重复启动
275
-
276
- // 清除旧的定时器
277
  stopMessagePolling();
278
  isPollingActive = true;
279
-
280
- // 创建新的定时器,每隔一定时间检查新消息
281
- messagePollingTimer = setInterval(async () => {
282
- // 🔧 P3优化:双重检查,确保用户仍在线
283
- if (!currentUser || !isPollingActive) {
284
- stopMessagePolling();
 
285
  return;
286
  }
287
- try {
288
- // 🔔 铃铛未读(点赞/评论/购买/任务/系统公告等)与 ✉️ 私信未读分别刷新
289
- await loadUnreadCount(currentUser, bellBtn);
290
- await loadChatUnreadCount(currentUser, chatEntryBtn);
291
- } catch (e) {
292
- // 静默失败,不影响用户体验
293
- }
294
- }, CACHE.MESSAGE_POLL.INTERVAL);
295
-
296
- console.log(`📨 消息轮询已启动,间隔 ${CACHE.MESSAGE_POLL.INTERVAL / 1000} 秒`);
297
  }
298
 
299
  // 🚀 新增:停止消息定时轮询
300
  function stopMessagePolling() {
 
301
  isPollingActive = false; // 🔧 P3优化:先标记为停止
302
  if (messagePollingTimer) {
303
- clearInterval(messagePollingTimer);
304
  messagePollingTimer = null;
305
  console.log('📨 消息轮询已停止');
306
  }
307
- }
 
2
  import { createAuthView } from "../auth/用户注册登录组件.js";
3
  import { openUserProfileModal } from "../profile/个人中心视图.js";
4
  import { openChatModal, loadChatUnreadCount } from "../social/私信聊天组件.js";
5
+ import { api, captureRequestSession } from "../core/网络请求API.js";
6
  import { showToast } from "./UI交互提示组件.js";
7
  import { showAboutInfo } from "./关于插件组件.js";
8
  import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
9
  import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
10
+ import { setUser, updateUser, initUserState } from "../core/状态管理.js";
11
+ import { readUserSession, sessionProfile } from "../core/用户会话存储.js";
12
+ import { API, CACHE, escapeHtml } from "../core/全局配置.js";
13
  import { t } from "./用户体验增强.js"; // 🌐 多语言支持
14
  import { createTopBanner } from "./顶部广告组件.js";
15
  import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
16
 
17
  // 🚀 新增:消息轮询定时器
18
  let messagePollingTimer = null;
19
+ let pollingGeneration = 0;
20
  let isPollingActive = false; // 🔧 P3优化:防止重复启动轮询
21
 
22
  // 🔧 P1修复:window 监听只安装一次并始终指向最新导航实例
 
40
  }
41
 
42
  export function createTopNav() {
43
+ stopMessagePolling();
44
  const userHeader = document.createElement("div");
45
+ userHeader.className = "top-nav";
46
+
47
+ const titleSpan = document.createElement("button");
48
+ titleSpan.type = "button";
49
+ titleSpan.className = "top-nav__brand";
50
+ titleSpan.innerHTML = `<strong>ComfyUI</strong> ${t('nav.community')}`;
51
+ titleSpan.title = t('nav.about_title');
52
+ titleSpan.setAttribute("aria-label", t('nav.about_title'));
 
 
 
 
53
  titleSpan.onclick = () => showAboutInfo(currentUser);
54
 
 
55
  const settingsBtn = document.createElement("button");
56
+ settingsBtn.type = "button";
57
+ settingsBtn.className = "top-nav__icon";
58
+ settingsBtn.textContent = "⚙️";
 
 
 
 
 
 
 
 
 
59
  settingsBtn.title = t('nav.settings');
60
+ settingsBtn.setAttribute("aria-label", t('nav.settings'));
 
61
  settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsPage(); };
62
 
 
63
  const titleWrapper = document.createElement("div");
64
+ titleWrapper.className = "top-nav__heading";
 
65
  titleWrapper.appendChild(titleSpan);
66
  titleWrapper.appendChild(settingsBtn);
67
 
68
  const userActionBtn = document.createElement("button");
69
+ userActionBtn.type = "button";
70
+ userActionBtn.className = "top-nav__identity";
71
 
72
  const chatEntryBtn = document.createElement("button");
73
+ chatEntryBtn.type = "button";
74
+ chatEntryBtn.className = "top-nav__icon";
75
+ chatEntryBtn.innerHTML = `✉️<span id="chat-unread-badge" class="top-nav__badge" style="display:none">0</span>`;
76
  chatEntryBtn.title = t('social.chat');
77
+ chatEntryBtn.setAttribute("aria-label", t('social.chat'));
78
 
79
  const bellBtn = document.createElement("button");
80
+ bellBtn.type = "button";
81
+ bellBtn.className = "top-nav__icon";
82
+ bellBtn.innerHTML = `🔔<span id="unread-badge" class="top-nav__badge" style="display:none">0</span>`;
83
+ bellBtn.title = t('social.notifications');
84
+ bellBtn.setAttribute("aria-label", t('social.notifications'));
 
 
 
 
 
 
 
 
 
 
85
 
86
+ let currentUser = null;
87
+ const sessionStorages = () => ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
88
+ const restoredSession = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS);
89
+ currentUser = restoredSession?.user || null;
 
 
 
 
 
 
90
 
91
  const updateUserButtonState = () => {
92
  if (currentUser) {
93
  userActionBtn.innerHTML = `👤 ${escapeHtml(currentUser.name || currentUser.account || t('common.unknown_user'))}`;
94
+ userActionBtn.dataset.authenticated = "true";
95
+ userActionBtn.title = currentUser.name || currentUser.account || t('common.unknown_user');
96
  userActionBtn.onclick = () => openUserProfileModal(currentUser);
97
  loadUnreadCount(currentUser, bellBtn);
98
  loadChatUnreadCount(currentUser, chatEntryBtn);
 
104
  stopMessagePolling();
105
 
106
  userActionBtn.innerHTML = `🔑 ${t('nav.login')}`;
107
+ userActionBtn.dataset.authenticated = "false";
108
+ userActionBtn.title = t('nav.login');
109
  bellBtn.querySelector("#unread-badge").style.display = "none";
110
  chatEntryBtn.querySelector("#chat-unread-badge").style.display = "none";
111
 
112
  userActionBtn.onclick = () => {
113
+ const view = createAuthView(async (formData, context) => {
114
+ const originalSession = captureRequestSession();
115
+ const current = () => context.isCurrent() && userHeader.isConnected
116
+ && _navInstance?.dom === userHeader && originalSession();
117
+ if (!current()) return;
118
  try {
119
+ if (formData.type === 'reset') {
120
+ const reset = await api.resetPassword(formData);
121
+ if (reset?.status !== 'success') throw new Error('Unconfirmed reset');
122
+ if (!current()) return;
123
+ showToast(t('auth.password_reset_success'), 'success');
124
+ window.dispatchEvent(new CustomEvent('comfy-route-back')); updateUserButtonState(); return;
 
 
 
 
 
125
  }
126
+ const account = formData.account;
127
+ const isRegister = formData.type === 'register';
128
+ if (!isRegister && formData.type !== 'login') return;
129
+ const pendingAvatarFile = isRegister ? formData.avatarFile : null;
130
+ if (isRegister) {
131
+ const registration = { ...formData }; delete registration.avatarFile; delete registration.avatarDataUrl;
132
+ const registered = await api.register(registration);
133
+ if (registered?.status !== 'success') throw new Error('Unconfirmed registration');
134
+ if (!current()) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  }
136
+ const remember = isRegister || formData.remember === true;
137
+ const result = await api.login(account, formData.password, remember);
138
+ if (!current()) return;
139
+ if (result?.status !== 'success' || typeof result.token !== 'string' || !result.token
140
+ || (result.account && result.account !== account)) throw new Error('Invalid login response');
141
+ currentUser = setUser({ account, name: result.name || formData.name || account, avatar: result.avatar }, result.token, remember);
142
+ const authenticatedSession = captureRequestSession();
143
+ const sameLogin = () => authenticatedSession() && userHeader.isConnected
144
+ && _navInstance?.dom === userHeader && currentUser?.account === account;
145
+ showToast(t(isRegister ? 'auth.register_success' : 'auth.login_success'), 'success');
146
+ // 凭证已发布即完成登录,资料补充不阻塞页面返回。
147
+ window.dispatchEvent(new CustomEvent('comfy-route-back'));
148
+ updateUserButtonState(); window.dispatchEvent(new CustomEvent('comfy-user-login'));
149
+ const applyProfile = data => {
150
+ if (!sameLogin() || data?.account !== account) return;
151
+ currentUser = sessionProfile({ ...currentUser, ...data });
152
+ if (currentUser.avatarDataUrl) currentUser.avatar = currentUser.avatarDataUrl;
153
+ updateUser(currentUser); updateUserButtonState();
154
+ };
155
+ try {
156
+ const profile = await api.getUserProfile(account, { timeout: API.BACKGROUND_TIMEOUT,
157
+ retries: API.BACKGROUND_RETRIES, componentId: `login-profile-${account}` });
158
+ if (profile?.status === 'success') applyProfile(profile.data);
159
+ } catch (_) {}
160
+ if (!sameLogin() || !pendingAvatarFile) return;
161
+ try {
162
+ const avatar = await uploadFile(pendingAvatarFile, 'avatar', { isCurrent: sameLogin, silent: true });
163
+ if (!sameLogin()) return;
164
+ if (!avatar) { showToast(t('auth.avatar_set_failed'), 'warning'); return; }
165
+ const updated = await api.updateUserProfile(account, { avatarDataUrl: avatar });
166
+ if (!sameLogin()) return;
167
+ if (updated?.status === 'success') applyProfile(updated.data);
168
+ else showToast(t('auth.avatar_set_failed'), 'warning');
169
+ } catch (_) { if (sameLogin()) showToast(t('auth.avatar_set_failed'), 'warning'); }
170
+ } catch (_) {
171
+ if (current()) { showToast(t('common.error'), 'error'); updateUserButtonState(); }
172
  }
173
  });
174
  window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
 
178
 
179
  // 🔄 跨标签页登录状态同步:其他标签页修改 localStorage 时触发
180
  // 🔧 P1修复:改为模块级统一注册的 storage 监听回调(onStorage),不再逐实例 addEventListener
181
+ const handleCrossTabStorage = event => {
182
+ if (event.key === null || [CACHE.LEGACY_KEYS.USER, CACHE.LEGACY_KEYS.TOKEN].includes(event.key)) {
183
+ currentUser = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS)?.user || null;
184
+ stopMessagePolling();
185
+ initUserState(); updateUserButtonState();
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }
187
  };
188
+
189
  updateUserButtonState();
190
 
191
  // 🔧 P1修复:注册为当前活跃实例,模块级监听将路由到本实例的回调
192
  _navInstance = {
193
+ dom: userHeader,
194
  onLogout: () => { currentUser = null; updateUserButtonState(); },
195
  onStorage: handleCrossTabStorage,
196
  onChatUnreadChanged: () => loadChatUnreadCount(currentUser, chatEntryBtn)
 
198
  _registerNavWindowListeners();
199
 
200
  const actionWrapper = document.createElement("div");
201
+ actionWrapper.className = "top-nav__actions";
202
  actionWrapper.appendChild(chatEntryBtn);
203
  actionWrapper.appendChild(bellBtn);
204
  actionWrapper.appendChild(userActionBtn);
205
 
206
+ userHeader.appendChild(titleWrapper);
207
+ userHeader.appendChild(actionWrapper);
208
+ // 广告独占下一行,DOM/键盘顺序与视觉顺序一致。
209
  const banner = createTopBanner();
210
  if (banner) userHeader.appendChild(banner);
 
211
 
212
  return {
213
  dom: userHeader,
 
217
 
218
  // 🚀 新增:启动消息定时轮询
219
  function startMessagePolling(currentUser, bellBtn, chatEntryBtn) {
220
+ if (!CACHE.MESSAGE_POLL.ENABLED || isPollingActive) return;
 
 
 
221
  stopMessagePolling();
222
  isPollingActive = true;
223
+ const generation = pollingGeneration;
224
+ const sessionMatches = captureRequestSession();
225
+ const current = () => isPollingActive && generation === pollingGeneration
226
+ && sessionMatches() && _navInstance?.dom?.contains(bellBtn) && bellBtn.isConnected && chatEntryBtn.isConnected;
227
+ const poll = async () => {
228
+ if (!current()) {
229
+ if (generation === pollingGeneration) stopMessagePolling();
230
  return;
231
  }
232
+ await Promise.allSettled([loadUnreadCount(currentUser, bellBtn), loadChatUnreadCount(currentUser, chatEntryBtn)]);
233
+ if (current()) messagePollingTimer = setTimeout(poll, CACHE.MESSAGE_POLL.INTERVAL);
234
+ else if (generation === pollingGeneration) stopMessagePolling();
235
+ };
236
+ messagePollingTimer = setTimeout(poll, CACHE.MESSAGE_POLL.INTERVAL);
 
 
 
 
 
237
  }
238
 
239
  // 🚀 新增:停止消息定时轮询
240
  function stopMessagePolling() {
241
+ pollingGeneration++;
242
  isPollingActive = false; // 🔧 P3优化:先标记为停止
243
  if (messagePollingTimer) {
244
+ clearTimeout(messagePollingTimer);
245
  messagePollingTimer = null;
246
  console.log('📨 消息轮询已停止');
247
  }
248
+ }
前端页面/components/顶部广告组件.js CHANGED
@@ -1,21 +1,23 @@
1
  // 前端页面/components/顶部广告组件.js
2
 
3
  import { api } from "../core/网络请求API.js";
 
 
4
 
5
  // ========== 广告配置数据(默认值 / fallback) ==========
6
  const BANNER_CONFIG = {
7
  // 横幅图片URL(支持GIF)
8
  bannerImage: "",
9
  // 广告标题
10
- title: "创世者计划",
11
  // 广告描述
12
- description: "100位优质创作者,瓜分平台未来两年收益",
13
  // 详情页内容(HTML字符串或纯文本)
14
- detailContent: "详细广告内容...",
15
  // 详情页大图
16
  detailImage: "",
17
  // 是否启用广告
18
- enabled: true
19
  };
20
 
21
  // 运行时配置(异步加载后更新)
@@ -25,25 +27,82 @@ let activeConfig = { ...BANNER_CONFIG };
25
  let bannerContainer = null;
26
 
27
  // 本地缓存配置
28
- const BANNER_CACHE_KEY = "ComfyRanking_BannerConfig";
29
- const BANNER_CACHE_TTL = 60 * 60 * 1000; // 1小时缓存
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  /**
32
  * 读取本地广告配置缓存(非破坏性,不删除过期数据)
33
- * @returns {{ data: Object|null, fresh: boolean }} data 为缓存内容(无缓存为 null),fresh 表示是否仍在 TTL 内
34
  */
35
  function readBannerCache() {
36
  try {
37
  const cached = localStorage.getItem(BANNER_CACHE_KEY);
38
  if (cached) {
39
- const { data, timestamp } = JSON.parse(cached);
40
- return { data: data || null, fresh: Date.now() - timestamp < BANNER_CACHE_TTL };
41
  }
42
  } catch (parseErr) {
43
  // 缓存数据损坏,清除
44
- localStorage.removeItem(BANNER_CACHE_KEY);
 
 
 
 
45
  }
46
- return { data: null, fresh: false };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
 
49
  /**
@@ -51,26 +110,43 @@ function readBannerCache() {
51
  * 成功后更新缓存并刷新 DOM;失败时保留当前已展示的内容
52
  */
53
  async function syncBannerInBackground() {
 
54
  try {
55
- const res = await api.getPublicBannerConfig();
 
 
 
56
  if (res && res.status === "success") {
 
 
 
 
 
57
  if (res.data) {
58
- // 只缓存有效配置,不缓存 null(禁用后再启用可立即生效)
59
- localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({
60
- data: res.data,
61
- timestamp: Date.now()
62
- }));
63
- activeConfig = { ...BANNER_CONFIG, ...res.data };
 
 
64
  } else {
65
- // 广告已禁用,清除旧缓存避免显示过期内容
66
- localStorage.removeItem(BANNER_CACHE_KEY);
67
- activeConfig = { ...BANNER_CONFIG };
 
 
 
68
  }
 
 
 
69
  updateBannerDOM();
70
  }
71
  } catch (e) {
72
- // 网络失败:保留当前展示内容(首屏已由缓存或默认值渲染)
73
- console.warn("后台更新广告配置失败(保留当前内容):", e);
74
  }
75
  }
76
 
@@ -80,19 +156,10 @@ async function syncBannerInBackground() {
80
  */
81
  export function createTopBanner() {
82
  // 创建广告容器(初始隐藏,异步加载后决定是否显示)
83
- const container = document.createElement("div");
84
- Object.assign(container.style, {
85
- flex: "1",
86
- display: "none",
87
- justifyContent: "center",
88
- alignItems: "center",
89
- margin: "0 15px",
90
- maxHeight: "42px",
91
- overflow: "hidden",
92
- borderRadius: "6px",
93
- cursor: "pointer",
94
- transition: "opacity 0.2s"
95
- });
96
 
97
  // 保存模块级引用
98
  bannerContainer = container;
@@ -100,15 +167,16 @@ export function createTopBanner() {
100
  // ⚡ 完全不阻塞首屏:先读本地缓存(过期也立即使用)并立即渲染,
101
  // 网络请求一律退到后台静默同步,保证断网/云端不通时横幅不受影响
102
  // (与插件榜/提示词等榜单的缓存优先策略一致)
103
- const { data: cachedData, fresh } = readBannerCache();
104
- if (cachedData) {
105
- activeConfig = { ...BANNER_CONFIG, ...cachedData };
106
- updateBannerDOM();
107
- }
108
- // 缓存新鲜则无需请求;过期或无缓存时后台静默同步
109
- if (!fresh) {
110
- syncBannerInBackground();
111
  }
 
 
 
112
 
113
  return container;
114
  }
@@ -129,52 +197,82 @@ function updateBannerDOM() {
129
  }
130
 
131
  // 创建横幅图片
132
- const img = document.createElement("img");
133
- img.src = activeConfig.bannerImage;
134
- img.alt = activeConfig.title || "广告";
135
- Object.assign(img.style, {
136
- maxWidth: "100%",
137
- maxHeight: "100%",
138
- objectFit: "contain"
139
- });
140
 
141
  bannerContainer.appendChild(img);
142
  bannerContainer.style.display = "flex";
143
 
144
- // 鼠标悬停效��
145
- bannerContainer.onmouseover = () => { bannerContainer.style.opacity = "0.85"; };
146
- bannerContainer.onmouseout = () => { bannerContainer.style.opacity = "1"; };
147
-
148
  // 点击打开广告详情页
149
- bannerContainer.onclick = () => {
150
  const view = createBannerDetailView();
151
  window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
152
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
 
155
  /**
156
  * 刷新顶部广告横幅(重新加载配置并更新DOM)
157
  */
158
  export async function refreshBanner() {
159
- // 清除自定义缓存
160
- localStorage.removeItem(BANNER_CACHE_KEY);
161
- try {
162
- // 绕过请求级缓存,强制从服务器获取最新配置
163
- const res = await api.getPublicBannerConfig({ noCache: true });
164
- if (res && res.status === "success" && res.data) {
165
- activeConfig = { ...BANNER_CONFIG, ...res.data };
166
- // 更新本地缓存
167
- localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({
168
- data: res.data,
169
- timestamp: Date.now()
170
- }));
171
- } else {
172
- activeConfig = { ...BANNER_CONFIG };
173
- }
174
- } catch (e) {
175
- console.warn("刷新广告配置失败", e);
176
- }
177
- updateBannerDOM();
178
  }
179
 
180
  /**
@@ -220,7 +318,7 @@ function createBannerDetailView() {
220
  boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
221
  transition: "0.2s"
222
  });
223
- backBtn.textContent = "⬅ 返回";
224
  backBtn.onmouseover = () => {
225
  backBtn.style.background = "#4CAF50";
226
  backBtn.style.borderColor = "#4CAF50";
@@ -234,7 +332,7 @@ function createBannerDetailView() {
234
  };
235
 
236
  const pageTitle = document.createElement("span");
237
- pageTitle.textContent = activeConfig.title || "广告详情";
238
  Object.assign(pageTitle.style, {
239
  marginLeft: "12px",
240
  fontSize: "16px",
@@ -287,7 +385,7 @@ function createBannerDetailView() {
287
  if (activeConfig.detailImage) {
288
  const detailImg = document.createElement("img");
289
  detailImg.src = activeConfig.detailImage;
290
- detailImg.alt = activeConfig.title || "广告详情图";
291
  Object.assign(detailImg.style, {
292
  maxWidth: "100%",
293
  borderRadius: "8px"
@@ -311,7 +409,7 @@ function createBannerDetailView() {
311
  wordWrap: "break-word"
312
  });
313
  // 支持HTML内容
314
- detailEl.innerHTML = activeConfig.detailContent;
315
  content.appendChild(detailEl);
316
  }
317
 
 
1
  // 前端页面/components/顶部广告组件.js
2
 
3
  import { api } from "../core/网络请求API.js";
4
+ import { API, CACHE } from "../core/全局配置.js";
5
+ import { t } from "./用户体验_国际化.js";
6
 
7
  // ========== 广告配置数据(默认值 / fallback) ==========
8
  const BANNER_CONFIG = {
9
  // 横幅图片URL(支持GIF)
10
  bannerImage: "",
11
  // 广告标题
12
+ title: "",
13
  // 广告描述
14
+ description: "",
15
  // 详情页内容(HTML字符串或纯文本)
16
+ detailContent: "",
17
  // 详情页大图
18
  detailImage: "",
19
  // 是否启用广告
20
+ enabled: false
21
  };
22
 
23
  // 运行时配置(异步加载后更新)
 
27
  let bannerContainer = null;
28
 
29
  // 本地缓存配置
30
+ const BANNER_CACHE_KEY = CACHE.LOCAL_KEYS.BANNER_CONFIG;
31
+ let bannerSyncGeneration = 0;
32
+ let hasActiveConfig = false;
33
+ // 保留已下载的横幅节点,提交时直接显示,避免重新赋值 src 再次联网。
34
+ let activeBannerImage = null;
35
+
36
+ /** 缓存与云端共用配置结构检查,异常结果不能覆盖已有横幅。 */
37
+ function validateBannerConfig(data) {
38
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
39
+ throw new Error("Invalid banner configuration");
40
+ }
41
+ if (typeof data.enabled !== "boolean") throw new Error("Invalid banner enabled state");
42
+ for (const field of ["bannerImage", "title", "description", "detailContent", "detailImage"]) {
43
+ if (data[field] !== undefined && typeof data[field] !== "string") {
44
+ throw new Error("Invalid banner field type");
45
+ }
46
+ }
47
+ const config = { ...BANNER_CONFIG, ...data };
48
+ if (config.enabled && !config.bannerImage.trim()) throw new Error("Missing banner image");
49
+ return config;
50
+ }
51
 
52
  /**
53
  * 读取本地广告配置缓存(非破坏性,不删除过期数据)
54
+ * @returns {Object|null} data 为缓存内容,无缓存时为 null
55
  */
56
  function readBannerCache() {
57
  try {
58
  const cached = localStorage.getItem(BANNER_CACHE_KEY);
59
  if (cached) {
60
+ const { data } = JSON.parse(cached);
61
+ return validateBannerConfig(data);
62
  }
63
  } catch (parseErr) {
64
  // 缓存数据损坏,清除
65
+ try {
66
+ localStorage.removeItem(BANNER_CACHE_KEY);
67
+ } catch (storageErr) {
68
+ console.warn("广告缓存不可清理,继续使用默认显示流程");
69
+ }
70
  }
71
+ return null;
72
+ }
73
+
74
+ /** 确认新配置引用的图片均可下载后,再允许替换当前横幅。 */
75
+ function preloadImage(url) {
76
+ if (!url) return Promise.resolve();
77
+ return new Promise((resolve, reject) => {
78
+ const image = new Image();
79
+ const finish = (error) => {
80
+ clearTimeout(timer);
81
+ image.onload = null;
82
+ image.onerror = null;
83
+ if (error) reject(error);
84
+ else resolve(image);
85
+ };
86
+ const timer = setTimeout(() => {
87
+ finish(new Error("Banner image loading timed out"));
88
+ image.removeAttribute("src");
89
+ }, API.BACKGROUND_TIMEOUT);
90
+ image.onload = () => finish();
91
+ image.onerror = () => finish(new Error("Banner image download failed"));
92
+ image.src = url;
93
+ });
94
+ }
95
+
96
+ async function prepareBannerConfig(data) {
97
+ // 只有明确的 null 表示服务端关闭广告;缺失或异常数据视为更新失败。
98
+ if (data === null) return { config: { ...BANNER_CONFIG }, image: null };
99
+ const config = validateBannerConfig(data);
100
+ if (!config.enabled) return { config, image: null };
101
+ const [image] = await Promise.all([
102
+ preloadImage(config.bannerImage),
103
+ preloadImage(config.detailImage)
104
+ ]);
105
+ return { config, image };
106
  }
107
 
108
  /**
 
110
  * 成功后更新缓存并刷新 DOM;失败时保留当前已展示的内容
111
  */
112
  async function syncBannerInBackground() {
113
+ const generation = ++bannerSyncGeneration;
114
  try {
115
+ // 绕过请求层缓存,确保拿到的确实是后台新配置。
116
+ const res = await api.getPublicBannerConfig({
117
+ noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES
118
+ });
119
  if (res && res.status === "success") {
120
+ if (generation !== bannerSyncGeneration) return;
121
+ const prepared = await prepareBannerConfig(res.data);
122
+ const nextConfig = prepared.config;
123
+ // 图片加载期间可能又发起了一次刷新,只让最新请求提交结果。
124
+ if (generation !== bannerSyncGeneration) return;
125
  if (res.data) {
126
+ try {
127
+ localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({
128
+ data: nextConfig,
129
+ timestamp: Date.now()
130
+ }));
131
+ } catch (storageError) {
132
+ console.warn("新广告配置无法写入本地缓存,仍显示已下载的新内容");
133
+ }
134
  } else {
135
+ // 服务端明确返回无配置,作为成功的新结果清除旧配置。
136
+ try {
137
+ localStorage.removeItem(BANNER_CACHE_KEY);
138
+ } catch (storageError) {
139
+ console.warn("无法清除本地广告缓存,仍应用服务端的无广告状态");
140
+ }
141
  }
142
+ activeConfig = nextConfig;
143
+ activeBannerImage = prepared.image;
144
+ hasActiveConfig = true;
145
  updateBannerDOM();
146
  }
147
  } catch (e) {
148
+ // 网络失败或图片下载失败:不写缓存、不替换当前已显示的缓存横幅。
149
+ console.warn("后台更新广告配置失败(保留当前内容)");
150
  }
151
  }
152
 
 
156
  */
157
  export function createTopBanner() {
158
  // 创建广告容器(初始隐藏,异步加载后决定是否显示)
159
+ const container = document.createElement("button");
160
+ container.type = "button";
161
+ container.className = "top-banner";
162
+ container.style.display = "none";
 
 
 
 
 
 
 
 
 
163
 
164
  // 保存模块级引用
165
  bannerContainer = container;
 
167
  // ⚡ 完全不阻塞首屏:先读本地缓存(过期也立即使用)并立即渲染,
168
  // 网络请求一律退到后台静默同步,保证断网/云端不通时横幅不受影响
169
  // (与插件榜/提示词等榜单的缓存优先策略一致)
170
+ if (!hasActiveConfig) {
171
+ const cachedData = readBannerCache();
172
+ if (cachedData) {
173
+ activeConfig = cachedData;
174
+ hasActiveConfig = true;
175
+ }
 
 
176
  }
177
+ updateBannerDOM();
178
+ // 始终在后台尝试获取新配置;当前缓存先显示,成功且图片下载完成后再整体替换。
179
+ syncBannerInBackground();
180
 
181
  return container;
182
  }
 
197
  }
198
 
199
  // 创建横幅图片
200
+ const img = activeBannerImage || document.createElement("img");
201
+ if (!activeBannerImage) img.src = activeConfig.bannerImage;
202
+ img.alt = activeConfig.title || t("common.advertisement");
203
+ bannerContainer.setAttribute("aria-label", activeConfig.title || t("common.advertisement"));
204
+ img.className = "top-banner__image";
 
 
 
205
 
206
  bannerContainer.appendChild(img);
207
  bannerContainer.style.display = "flex";
208
 
 
 
 
 
209
  // 点击打开广告详情页
210
+ const openBannerDetail = () => {
211
  const view = createBannerDetailView();
212
  window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
213
  };
214
+ bannerContainer.onclick = openBannerDetail;
215
+ }
216
+
217
+ const BANNER_ALLOWED_TAGS = new Set([
218
+ "a", "b", "blockquote", "br", "code", "em", "h2", "h3", "h4",
219
+ "hr", "i", "li", "ol", "p", "pre", "s", "strong", "u", "ul"
220
+ ]);
221
+ const BANNER_BLOCKED_TAGS = new Set([
222
+ "audio", "button", "embed", "form", "iframe", "img", "input", "math",
223
+ "object", "script", "style", "svg", "textarea", "video"
224
+ ]);
225
+
226
+ /** Copy only simple formatting and safe HTTP(S) links from administrator-provided content. */
227
+ function sanitizeBannerDetailContent(value) {
228
+ const source = new DOMParser().parseFromString(String(value ?? ""), "text/html");
229
+ const fragment = document.createDocumentFragment();
230
+
231
+ const copyNodes = (sourceNode, targetNode) => {
232
+ for (const node of sourceNode.childNodes) {
233
+ if (node.nodeType === Node.TEXT_NODE) {
234
+ targetNode.appendChild(document.createTextNode(node.nodeValue || ""));
235
+ continue;
236
+ }
237
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
238
+
239
+ const tag = node.tagName.toLowerCase();
240
+ if (BANNER_BLOCKED_TAGS.has(tag)) continue;
241
+ if (!BANNER_ALLOWED_TAGS.has(tag)) {
242
+ copyNodes(node, targetNode);
243
+ continue;
244
+ }
245
+
246
+ const cleanElement = document.createElement(tag);
247
+ if (tag === "a") {
248
+ const rawHref = node.getAttribute("href");
249
+ try {
250
+ if (!rawHref?.trim()) throw new Error("Missing link target");
251
+ const url = new URL(rawHref.trim(), document.baseURI);
252
+ if (["http:", "https:"].includes(url.protocol) && !url.username && !url.password) {
253
+ cleanElement.href = url.href;
254
+ cleanElement.target = "_blank";
255
+ cleanElement.rel = "noopener noreferrer";
256
+ }
257
+ } catch (_) {
258
+ // Invalid links keep their label but receive no navigable href.
259
+ }
260
+ }
261
+ targetNode.appendChild(cleanElement);
262
+ copyNodes(node, cleanElement);
263
+ }
264
+ };
265
+
266
+ copyNodes(source.body, fragment);
267
+ return fragment;
268
  }
269
 
270
  /**
271
  * 刷新顶部广告横幅(重新加载配置并更新DOM)
272
  */
273
  export async function refreshBanner() {
274
+ // 手动刷新也保留当前缓存横幅,直到新配置和图片都准备好。
275
+ await syncBannerInBackground();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  }
277
 
278
  /**
 
318
  boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
319
  transition: "0.2s"
320
  });
321
+ backBtn.textContent = `⬅ ${t("common.back")}`;
322
  backBtn.onmouseover = () => {
323
  backBtn.style.background = "#4CAF50";
324
  backBtn.style.borderColor = "#4CAF50";
 
332
  };
333
 
334
  const pageTitle = document.createElement("span");
335
+ pageTitle.textContent = activeConfig.title || t("common.advertisement_details");
336
  Object.assign(pageTitle.style, {
337
  marginLeft: "12px",
338
  fontSize: "16px",
 
385
  if (activeConfig.detailImage) {
386
  const detailImg = document.createElement("img");
387
  detailImg.src = activeConfig.detailImage;
388
+ detailImg.alt = activeConfig.title || t("common.advertisement_details_image");
389
  Object.assign(detailImg.style, {
390
  maxWidth: "100%",
391
  borderRadius: "8px"
 
409
  wordWrap: "break-word"
410
  });
411
  // 支持HTML内容
412
+ detailEl.replaceChildren(sanitizeBannerDetailContent(activeConfig.detailContent));
413
  content.appendChild(detailEl);
414
  }
415
 
前端页面/core/侧边栏主程序.js CHANGED
@@ -11,16 +11,17 @@ import { createPublishTaskView } from "../task/发布任务组件.js"; // 🎯
11
  import { createPublishPostView } from "../post/发布帖子组件.js"; // 🎯 新增:帖子发布
12
  import { createPublishPromptView } from "../prompt/发布提示词组件.js"; // 🎯 新增:提示词发布
13
  import { createTopNav } from "../components/顶部导航组件.js";
14
- import { loadSidebarContent } from "./侧边栏数据引擎.js";
15
  import { createItemDetailView } from "../market/资源详情页面组件.js";
16
  import { showToast } from "../components/UI交互提示组件.js";
17
- import { api } from "./网络请求API.js"; // 🔴 编辑模式需调用详情API获取完整数据
18
- import { CACHE, getBackgroundKey, IS_WEB_MODE } from "./全局配置.js";
19
  import { debounce } from "../components/性能优化工具.js";
 
20
  import { cleanupImageSandbox } from "../components/图片沙盒组件.js"; // 🔧 P3优化:导入清理函数
21
  import { getVersionConfig, formatVersionString } from "../components/关于插件组件.js"; // 🏷️ 动态版本号
22
  // 🎯 P2 用户体验增强
23
- import { initUXEnhancements, t } from "../components/用户体验增强.js";
24
 
25
  // 初始化 UX 增强
26
  try {
@@ -29,16 +30,14 @@ try {
29
  console.warn('🎯 UX 增强初始化失败:', e);
30
  }
31
 
32
- // 🚫 注入 Tab 文案样式(模块加载时执行一次)
33
- // Tab 文案用伪元素 content 渲染而非文本节点,使其不存在于 DOM 树中,
34
- // 从而既避开 ComfyUI 翻译插件(只改写文本节点 / innerText / title / option.text),
35
- // 也避开浏览器翻译扩展;否则英文界面下 Tools 会被译成“工具”
36
- (function _injectTabLabelStyle() {
37
- if (document.getElementById('hub-tab-label-style')) return;
38
- const style = document.createElement('style');
39
- style.id = 'hub-tab-label-style';
40
- style.textContent = '.hub-tab-btn::before { content: var(--tab-label); }';
41
- document.head.appendChild(style);
42
  })();
43
 
44
  // 🚫 筛选框选项防翻译:原生 <option> 不支持伪元素,无法用上面那套方案,
@@ -72,7 +71,18 @@ const BackgroundStore = {
72
 
73
  // 🔧 P1修复:window 监听注册表(语言切换会重建 buildSidebarDOM,需先移除旧监听防止累积泄漏)
74
  let _sidebarWindowListeners = [];
 
 
 
75
  function _cleanupSidebarWindowListeners() {
 
 
 
 
 
 
 
 
76
  for (const { event, handler } of _sidebarWindowListeners) {
77
  window.removeEventListener(event, handler);
78
  }
@@ -88,6 +98,7 @@ export function buildSidebarDOM() {
88
  _cleanupSidebarWindowListeners();
89
 
90
  const container = document.createElement("div");
 
91
 
92
  // 加载本地背景图
93
  const savedBg = BackgroundStore.load();
@@ -95,16 +106,17 @@ export function buildSidebarDOM() {
95
  ? `background-image: url(${savedBg}); background-size: cover; background-position: center;`
96
  : `background-color: var(--comfy-menu-bg);`;
97
 
98
- Object.assign(container.style, {
99
- display: "flex", flexDirection: "column", height: "100%", width: "100%",
100
- color: "var(--fg-color, #fff)", fontFamily: "sans-serif"
101
- });
102
  container.style.cssText += bgStyle;
103
 
104
  const topNav = createTopNav();
 
105
 
106
  const tabsContainer = document.createElement("div");
107
- Object.assign(tabsContainer.style, { display: "flex", borderBottom: "1px solid #444", padding: "10px 10px 0 10px" });
 
 
 
 
108
  // 🌐 多语言支持:Tab 名称使用翻译函数
109
  const tabs = [
110
  { id: "tools", label: t('nav.tools') },
@@ -118,10 +130,10 @@ export function buildSidebarDOM() {
118
  ];
119
 
120
  const sortContainer = document.createElement("div");
121
- Object.assign(sortContainer.style, { padding: "10px", display: "flex", gap: "8px", alignItems: "center", width: "100%", boxSizing: "border-box" });
122
  sortContainer.innerHTML = `
123
  <!-- 通用排序选择框(工具/应用/推荐/创作者) -->
124
- <select id="hub-sort-select" style="background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
125
  <option value="time">${noTr(t('market.latest'))}</option>
126
  <option value="downloads">${noTr(t('market.downloads'))}</option>
127
  <option value="likes">${noTr(t('market.like'))}</option>
@@ -132,7 +144,7 @@ export function buildSidebarDOM() {
132
  <option value="rating">${noTr(t('market.rating'))}</option>
133
  </select>
134
  <!-- 任务榜筛选控件(状态+排序) -->
135
- <select id="task-status-filter" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 100px; flex-shrink: 0;">
136
  <option value="">${noTr(t('task.filter_all'))}</option>
137
  <option value="open">${noTr(t('task.filter_open'))}</option>
138
  <option value="in_progress">${noTr(t('task.filter_in_progress'))}</option>
@@ -140,7 +152,7 @@ export function buildSidebarDOM() {
140
  <option value="completed">${noTr(t('task.filter_completed'))}</option>
141
  <option value="disputed">${noTr(t('task.filter_disputed'))}</option>
142
  </select>
143
- <select id="task-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 100px; flex-shrink: 0;">
144
  <option value="latest">${noTr(t('task.sort_latest'))}</option>
145
  <option value="price">${noTr(t('task.sort_price'))}</option>
146
  <option value="deadline">${noTr(t('task.sort_deadline'))}</option>
@@ -150,7 +162,7 @@ export function buildSidebarDOM() {
150
  <option value="favorites">${noTr(t('task.sort_favorites'))}</option>
151
  </select>
152
  <!-- 🎯 讨论区排序控件(专用) -->
153
- <select id="posts-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
154
  <option value="latest">${noTr(t('post.sort_latest'))}</option>
155
  <option value="likes">${noTr(t('post.sort_likes'))}</option>
156
  <option value="favorites">${noTr(t('post.sort_favorites'))}</option>
@@ -160,17 +172,29 @@ export function buildSidebarDOM() {
160
  <option value="rating">${noTr(t('post.sort_rating'))}</option>
161
  </select>
162
  <!-- 🧩 提示词排序控件(专用) -->
163
- <select id="prompts-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
164
  <option value="latest">${noTr(t('prompt.sort_latest'))}</option>
165
  <option value="likes">${noTr(t('prompt.sort_likes'))}</option>
166
  <option value="favorites">${noTr(t('prompt.sort_favorites'))}</option>
167
  <option value="views">${noTr(t('prompt.sort_views'))}</option>
168
  <option value="daily_views">${noTr(t('prompt.sort_daily_views'))}</option>
169
  </select>
170
- <input type="text" id="hub-search-input" autocomplete="off" placeholder="🔍 ${t('common.search')}..." style="flex: 1; padding: 6px 10px; border-radius: 4px; border: 1px solid #555; background: #222; color: white; outline: none;">
171
- <button id="btn-open-publish" style="background: #4CAF50; color: white; border: none; padding: 6px 12px; border-radius: 4px; font-size: 12px; font-weight: bold; cursor: pointer; flex-shrink: 0; box-shadow: 0 2px 5px rgba(0,0,0,0.3);">➕ ${t('market.publish')}</button>
172
  `;
173
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  // 📱 Web 模式:隐藏发布入口(网页版不支持发布族功能,编辑路由监听处另有中央兜底)
175
  if (IS_WEB_MODE) {
176
  const publishBtn = sortContainer.querySelector("#btn-open-publish");
@@ -178,43 +202,34 @@ export function buildSidebarDOM() {
178
  }
179
 
180
  const contentBoxWrapper = document.createElement("div");
181
- Object.assign(contentBoxWrapper.style, { flex: "1", minHeight: "0", padding: "0 10px 10px 10px", display: "flex", flexDirection: "column" });
182
 
183
  const contentArea = document.createElement("div");
184
  contentArea.className = "sidebar-scroll-container";
 
 
 
185
  // 🔧 自适应高度:flex 填充剩余空间,内容少时随内容收缩,避免容器框与底部页脚间出现空白
186
- Object.assign(contentArea.style, {
187
- flex: "1 1 auto", minHeight: "0", overflowY: "auto", padding: "10px",
188
- backgroundColor: "#1c1c1c", border: "1px solid #444", borderRadius: "8px",
189
- boxShadow: "inset 0 4px 10px rgba(0,0,0,0.3)"
190
- });
191
  contentBoxWrapper.appendChild(contentArea);
192
 
193
  // =========================================================================
194
  // 【新增】底部专业版权与合作伙伴信息区域 (Footer)
195
  // =========================================================================
196
  const footerContainer = document.createElement("div");
197
- Object.assign(footerContainer.style, {
198
- padding: "15px 10px 20px",
199
- textAlign: "center",
200
- fontSize: "12px",
201
- color: "#666",
202
- flexShrink: "0",
203
- background: "transparent"
204
- });
205
 
206
  footerContainer.innerHTML = `
207
- <div style="display: flex; justify-content: center; align-items: center; gap: 16px; margin-bottom: 8px; flex-wrap: wrap;">
208
- <a href="https://github.com/a63976659/ComfyUI-Ranking" target="_blank" style="color: #888; text-decoration: none; transition: color 0.2s;" onmouseover="this.style.color='#4CAF50'" onmouseout="this.style.color='#888'">🌍 ComfyUI精选社区</a>
209
- <a href="https://github.com/a63976659" target="_blank" style="color: #888; text-decoration: none; transition: color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#888'">🐙 GitHub</a>
210
- <a href="https://huggingface.co/ZHIWEI666" target="_blank" style="color: #888; text-decoration: none; transition: color 0.2s;" onmouseover="this.style.color='#FFD21E'" onmouseout="this.style.color='#888'">🤗 Hugging Face</a>
211
- <a href="#" target="_blank" style="color: #888; text-decoration: none; transition: color 0.2s;" onmouseover="this.style.color='#2196F3'" onmouseout="this.style.color='#888'">🏢 砚影科技</a>
212
- <a href="https://www.bilibili.com/video/BV1x4XzBXEk9" target="_blank" style="color: #888; text-decoration: none; transition: color 0.2s;" onmouseover="this.style.color='#00A1D6'" onmouseout="this.style.color='#888'">📺 Bilibili</a>
213
  </div>
214
- <div style="display: flex; justify-content: center; align-items: center; gap: 10px; color: #555;">
215
- <span>MIT License Copyright (c) 2026 <a href="#" id="easter-egg-trigger" style="color: #888; text-decoration: none; cursor: pointer; transition: color 0.2s;" onmouseover="this.style.color='#ffb0c0'" onmouseout="this.style.color='#888'">猪的飞行梦</a></span>
216
- <span style="color: #444;">|</span>
217
- <span class="footer-version-label">加载中...</span>
218
  </div>
219
  `;
220
 
@@ -223,7 +238,14 @@ export function buildSidebarDOM() {
223
  if (easterEggLink) {
224
  easterEggLink.addEventListener('click', (e) => {
225
  e.preventDefault();
226
- import('../components/彩蛋动画引擎.js').then(mod => mod.openEasterEggPage());
 
 
 
 
 
 
 
227
  });
228
  }
229
 
@@ -235,7 +257,7 @@ export function buildSidebarDOM() {
235
  }
236
  }).catch(() => {
237
  const versionLabel = footerContainer.querySelector('.footer-version-label');
238
- if (versionLabel) versionLabel.textContent = 'V2.0.0 Beta';
239
  });
240
 
241
  // 监听从设置页面触发的背景更新事件
@@ -253,13 +275,26 @@ export function buildSidebarDOM() {
253
  });
254
  // =========================================================================
255
 
256
- let activeInlineView = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  const showInlineView = (viewDOM) => {
259
- if (activeInlineView) {
260
- if (activeInlineView._cleanup) activeInlineView._cleanup();
261
- activeInlineView.remove();
262
- }
263
  tabsContainer.style.display = "none";
264
  sortContainer.style.display = "none";
265
  contentBoxWrapper.style.display = "none";
@@ -269,14 +304,8 @@ export function buildSidebarDOM() {
269
  };
270
 
271
  const hideInlineView = () => {
272
- if (activeInlineView) {
273
- // 🔧 P3优化:在移除视图前清理事件监听器
274
- if (activeInlineView._cleanup) activeInlineView._cleanup();
275
- cleanupImageSandbox(activeInlineView);
276
-
277
- activeInlineView.remove();
278
- activeInlineView = null;
279
- }
280
  tabsContainer.style.display = "flex";
281
  sortContainer.style.display = "flex";
282
  contentBoxWrapper.style.display = "flex";
@@ -303,6 +332,8 @@ export function buildSidebarDOM() {
303
  // 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
304
  if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
305
  const { itemData, currentUser } = e.detail;
 
 
306
 
307
  // 先展示加载状态,避免用户感知延迟
308
  const loadingView = document.createElement("div");
@@ -310,17 +341,23 @@ export function buildSidebarDOM() {
310
  loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
311
  showInlineView(loadingView);
312
 
313
- // 从详情API获取完整数据(确保 has_private_token、netdisk_password 等字段不缺失)
314
- let enrichedItemData = itemData;
 
315
  try {
316
- const detailRes = await api.getItemById(itemData.id);
317
- if (detailRes?.status === "success" && detailRes?.data) {
318
- // 合并详情数据,保留列表中的图片URL等已有字段
319
- enrichedItemData = { ...itemData, ...detailRes.data };
320
- }
321
- } catch (err) {
322
- console.warn("获取编辑详情失败,使用列表数据:", err);
 
 
 
 
323
  }
 
324
 
325
  const publishView = createPublishView(currentUser,
326
  () => hideInlineView(),
@@ -360,27 +397,31 @@ export function buildSidebarDOM() {
360
 
361
  // 🔔 通知跳转:切换到对应Tab并展开指定卡片
362
  _onSidebarWindow("comfy-route-to-item", async (e) => {
363
- const { itemId, itemType } = e.detail;
364
- if (!itemId) return;
 
 
 
365
 
366
  // 1. 根据 itemType 确定 Tab
367
  let targetTab;
368
  if (itemType === "tool") targetTab = "tools";
369
  else if (itemType === "skill") targetTab = "skills";
370
  else if (itemType === "app") targetTab = "apps";
371
- else if (itemType.startsWith("recommend")) targetTab = "recommends";
372
  else targetTab = "tools"; // 兜底
373
 
374
  // 【问题2修复】如果已经在目标 Tab,先尝试在当前 DOM 中查找卡片
375
  if (currentTab === targetTab) {
376
- const existingCard = contentArea.querySelector(`[data-item-id="${itemId}"]`);
 
377
  if (existingCard) {
378
  // 关闭可能存在的通知中心等全屏视图
379
  if (activeInlineView) hideInlineView();
380
 
381
  const summary = existingCard.querySelector('.item-summary') || existingCard.firstElementChild;
382
  // 先确保卡片是折叠状态再展开(避免点击已展开的卡片把它收起来)
383
- existingCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
384
  // 检查详情区域是否已展开
385
  const detailView = existingCard.children[1]; // detailView 是第二个子元素
386
  if (detailView && detailView.style.display === 'none' && summary) {
@@ -395,23 +436,8 @@ export function buildSidebarDOM() {
395
  currentTab = targetTab;
396
  Store.save("activeTab", currentTab);
397
 
398
- // 3. 更新 Tab 按钮的选中状态
399
- const tabBtns = tabsContainer.querySelectorAll('button');
400
- tabBtns.forEach((btn, index) => {
401
- const tabId = tabs[index].id;
402
- const tabColor = tabColors[tabId];
403
- if (tabId === currentTab) {
404
- btn.style.background = "rgba(0,0,0,0.3)";
405
- btn.style.color = tabColor.active;
406
- btn.style.borderBottom = `2px solid ${tabColor.active}`;
407
- btn.style.textShadow = getTextShadow(tabColor.active, true);
408
- } else {
409
- btn.style.background = "rgba(0,0,0,0.15)";
410
- btn.style.color = tabColor.inactive;
411
- btn.style.borderBottom = "none";
412
- btn.style.textShadow = getTextShadow(tabColor.active, false);
413
- }
414
- });
415
 
416
  // 4. 更新筛选控件可见性
417
  updateFilterVisibility(currentTab);
@@ -430,15 +456,33 @@ export function buildSidebarDOM() {
430
  currentUser: topNav.getCurrentUser(),
431
  renderToken: currentRenderToken,
432
  getRenderToken: getRenderToken,
 
433
  force: false,
434
  expandItemId: itemId // 新增:要展开的卡片ID
435
  });
436
  });
437
 
438
- let currentTab = Store.load("activeTab", "tools");
 
439
  let currentSort = Store.load("activeSort", "time");
440
  let currentRenderToken = 0;
441
  const getRenderToken = () => currentRenderToken;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
  const triggerLoad = (forceRefresh = false) => {
444
  currentRenderToken++;
@@ -447,6 +491,7 @@ export function buildSidebarDOM() {
447
  keyword: sortContainer.querySelector("#hub-search-input").value.trim().toLowerCase(),
448
  contentArea: contentArea, currentUser: topNav.getCurrentUser(),
449
  renderToken: currentRenderToken, getRenderToken: getRenderToken,
 
450
  force: forceRefresh
451
  });
452
  };
@@ -461,7 +506,7 @@ export function buildSidebarDOM() {
461
 
462
  sortContainer.querySelector("#btn-open-publish").onclick = () => {
463
  const currentUser = topNav.getCurrentUser();
464
- if (!currentUser) return showToast("⚠️ 请先登录您的社区账号后再进行发布!", "warning");
465
 
466
  // 🎯 根据当前Tab打开对应的发布界面
467
  if (currentTab === "tasks") {
@@ -488,69 +533,40 @@ export function buildSidebarDOM() {
488
  }
489
  };
490
 
491
- // Tab 颜色配置:按色环渐变顺序排列(绿→青→蓝→紫→粉→红→橙→黄),相邻 Tab 色相平滑过渡
492
- const tabColors = {
493
- tools: { active: "#4CAF50", inactive: "#81C784" }, // 插件榜 - 绿(渐变起点)
494
- skills: { active: "#00BCD4", inactive: "#4DD0E1" }, // Skill - 青
495
- apps: { active: "#2196F3", inactive: "#64B5F6" }, // 工作流 - 蓝
496
- recommends: { active: "#7C4DFF", inactive: "#B388FF" }, // 推荐榜 - 紫
497
- creators: { active: "#E91E63", inactive: "#F06292" }, // 创作者 - 粉
498
- tasks: { active: "#F44336", inactive: "#E57373" }, // 任务榜 - 红
499
- posts: { active: "#FF9800", inactive: "#FFB74D" }, // 讨论区 - 橙
500
- prompts: { active: "#FFC107", inactive: "#FFD54F" } // 提示词 - 黄(渐变终点)
501
- };
502
-
503
- // 文字阴影效果:多层阴影确保任何背景下都清晰可见
504
- const getTextShadow = (color, isActive) => {
505
- const baseShadow = "0 1px 2px rgba(0,0,0,0.8), 0 0 4px rgba(0,0,0,0.6), 1px 1px 3px rgba(0,0,0,0.5)";
506
- return isActive
507
- ? `${baseShadow}, 0 0 10px ${color}, 0 0 20px ${color}`
508
- : baseShadow;
509
- };
510
-
511
  tabs.forEach(tab => {
512
  const btn = document.createElement("button");
513
- // 文案交由 ::before 渲染(见文件顶部 _injectTabLabelStyle),不产生文本节点;
514
- // 伪元素内容不参与可访问名计算,用 aria-label 补回无障碍名称
515
  btn.className = "hub-tab-btn";
516
- btn.style.setProperty("--tab-label", JSON.stringify(tab.label));
 
 
 
 
517
  btn.setAttribute("aria-label", tab.label);
518
- const isActive = currentTab === tab.id;
519
- const color = tabColors[tab.id];
520
- Object.assign(btn.style, {
521
- flex: "1",
522
- padding: "8px",
523
- cursor: "pointer",
524
- background: isActive ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.15)",
525
- color: isActive ? color.active : color.inactive,
526
- border: "none",
527
- borderBottom: isActive ? `2px solid ${color.active}` : "none",
528
- fontWeight: "bold",
529
- textShadow: getTextShadow(color.active, isActive),
530
- transition: "all 0.2s ease",
531
- fontSize: "13px"
532
- });
533
  btn.onclick = () => {
534
  currentTab = tab.id; Store.save("activeTab", currentTab);
535
- Array.from(tabsContainer.children).forEach((c, i) => {
536
- const tabId = tabs[i].id;
537
- const tabColor = tabColors[tabId];
538
- c.style.background = "rgba(0,0,0,0.15)";
539
- c.style.color = tabColor.inactive;
540
- c.style.borderBottom = "none";
541
- c.style.textShadow = getTextShadow(tabColor.active, false);
542
- });
543
- btn.style.background = "rgba(0,0,0,0.3)";
544
- btn.style.color = color.active;
545
- btn.style.borderBottom = `2px solid ${color.active}`;
546
- btn.style.textShadow = getTextShadow(color.active, true);
547
  sortContainer.querySelector("#hub-search-input").value = "";
548
  // 🎯 根据 Tab 切换筛选控件显示
549
  updateFilterVisibility(currentTab);
 
550
  triggerLoad();
551
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  tabsContainer.appendChild(btn);
553
  });
 
554
 
555
  // 🎯 筛选控件显示切换函数
556
  const updateFilterVisibility = (tabId) => {
 
11
  import { createPublishPostView } from "../post/发布帖子组件.js"; // 🎯 新增:帖子发布
12
  import { createPublishPromptView } from "../prompt/发布提示词组件.js"; // 🎯 新增:提示词发布
13
  import { createTopNav } from "../components/顶部导航组件.js";
14
+ import { loadSidebarContent, disposeSidebarContent } from "./侧边栏数据引擎.js";
15
  import { createItemDetailView } from "../market/资源详情页面组件.js";
16
  import { showToast } from "../components/UI交互提示组件.js";
17
+ import { api, captureRequestSession } from "./网络请求API.js"; // 🔴 编辑模式需调用详情API获取完整数据
18
+ import { CACHE, UI, getBackgroundKey, IS_WEB_MODE } from "./全局配置.js";
19
  import { debounce } from "../components/性能优化工具.js";
20
+ import { scrollCardIntoView } from "../components/动画音效引擎.js";
21
  import { cleanupImageSandbox } from "../components/图片沙盒组件.js"; // 🔧 P3优化:导入清理函数
22
  import { getVersionConfig, formatVersionString } from "../components/关于插件组件.js"; // 🏷️ 动态版本号
23
  // 🎯 P2 用户体验增强
24
+ import { initUXEnhancements, t, getLanguage } from "../components/用户体验增强.js";
25
 
26
  // 初始化 UX 增强
27
  try {
 
30
  console.warn('🎯 UX 增强初始化失败:', e);
31
  }
32
 
33
+ // 统一设计 tokens 与键盘焦点样式;样式表只挂载一次,保持 ComfyUI 主题变量适配。
34
+ (function _injectSharedThemeStyles() {
35
+ if (document.getElementById('hub-theme-style')) return;
36
+ const link = document.createElement('link');
37
+ link.id = 'hub-theme-style';
38
+ link.rel = 'stylesheet';
39
+ link.href = new URL('../styles/主题基础.css', import.meta.url).href;
40
+ document.head.appendChild(link);
 
 
41
  })();
42
 
43
  // 🚫 筛选框选项防翻译:原生 <option> 不支持伪元素,无法用上面那套方案,
 
71
 
72
  // 🔧 P1修复:window 监听注册表(语言切换会重建 buildSidebarDOM,需先移除旧监听防止累积泄漏)
73
  let _sidebarWindowListeners = [];
74
+ let _sidebarInstanceSequence = 0;
75
+ let _sidebarInlineViewCleanup = null;
76
+ let _sidebarListCleanup = null;
77
  function _cleanupSidebarWindowListeners() {
78
+ const cleanupList = _sidebarListCleanup;
79
+ const cleanupInline = _sidebarInlineViewCleanup;
80
+ _sidebarListCleanup = null;
81
+ _sidebarInlineViewCleanup = null;
82
+ for (const cleanup of [cleanupList, cleanupInline]) {
83
+ try { cleanup?.(); }
84
+ catch { console.warn('Sidebar cleanup failed'); }
85
+ }
86
  for (const { event, handler } of _sidebarWindowListeners) {
87
  window.removeEventListener(event, handler);
88
  }
 
98
  _cleanupSidebarWindowListeners();
99
 
100
  const container = document.createElement("div");
101
+ container.className = "comfy-ranking-root";
102
 
103
  // 加载本地背景图
104
  const savedBg = BackgroundStore.load();
 
106
  ? `background-image: url(${savedBg}); background-size: cover; background-position: center;`
107
  : `background-color: var(--comfy-menu-bg);`;
108
 
 
 
 
 
109
  container.style.cssText += bgStyle;
110
 
111
  const topNav = createTopNav();
112
+ const sidebarInstanceId = `comfy-ranking-${++_sidebarInstanceSequence}`;
113
 
114
  const tabsContainer = document.createElement("div");
115
+ tabsContainer.id = `${sidebarInstanceId}-tablist`;
116
+ tabsContainer.className = "hub-tablist";
117
+ tabsContainer.setAttribute("role", "tablist");
118
+ tabsContainer.setAttribute("aria-label", t("nav.board_navigation"));
119
+ tabsContainer.setAttribute("aria-orientation", "horizontal");
120
  // 🌐 多语言支持:Tab 名称使用翻译函数
121
  const tabs = [
122
  { id: "tools", label: t('nav.tools') },
 
130
  ];
131
 
132
  const sortContainer = document.createElement("div");
133
+ sortContainer.className = "hub-controls";
134
  sortContainer.innerHTML = `
135
  <!-- 通用排序选择框(工具/应用/推荐/创作者) -->
136
+ <select id="hub-sort-select" class="hub-control-select hub-control-select--wide">
137
  <option value="time">${noTr(t('market.latest'))}</option>
138
  <option value="downloads">${noTr(t('market.downloads'))}</option>
139
  <option value="likes">${noTr(t('market.like'))}</option>
 
144
  <option value="rating">${noTr(t('market.rating'))}</option>
145
  </select>
146
  <!-- 任务榜筛选控件(状态+排序) -->
147
+ <select id="task-status-filter" class="hub-control-select hub-control-select--compact" style="display: none;">
148
  <option value="">${noTr(t('task.filter_all'))}</option>
149
  <option value="open">${noTr(t('task.filter_open'))}</option>
150
  <option value="in_progress">${noTr(t('task.filter_in_progress'))}</option>
 
152
  <option value="completed">${noTr(t('task.filter_completed'))}</option>
153
  <option value="disputed">${noTr(t('task.filter_disputed'))}</option>
154
  </select>
155
+ <select id="task-sort-select" class="hub-control-select hub-control-select--compact" style="display: none;">
156
  <option value="latest">${noTr(t('task.sort_latest'))}</option>
157
  <option value="price">${noTr(t('task.sort_price'))}</option>
158
  <option value="deadline">${noTr(t('task.sort_deadline'))}</option>
 
162
  <option value="favorites">${noTr(t('task.sort_favorites'))}</option>
163
  </select>
164
  <!-- 🎯 讨论区排序控件(专用) -->
165
+ <select id="posts-sort-select" class="hub-control-select hub-control-select--wide" style="display: none;">
166
  <option value="latest">${noTr(t('post.sort_latest'))}</option>
167
  <option value="likes">${noTr(t('post.sort_likes'))}</option>
168
  <option value="favorites">${noTr(t('post.sort_favorites'))}</option>
 
172
  <option value="rating">${noTr(t('post.sort_rating'))}</option>
173
  </select>
174
  <!-- 🧩 提示词排序控件(专用) -->
175
+ <select id="prompts-sort-select" class="hub-control-select hub-control-select--wide" style="display: none;">
176
  <option value="latest">${noTr(t('prompt.sort_latest'))}</option>
177
  <option value="likes">${noTr(t('prompt.sort_likes'))}</option>
178
  <option value="favorites">${noTr(t('prompt.sort_favorites'))}</option>
179
  <option value="views">${noTr(t('prompt.sort_views'))}</option>
180
  <option value="daily_views">${noTr(t('prompt.sort_daily_views'))}</option>
181
  </select>
182
+ <input type="search" id="hub-search-input" class="hub-search-input" autocomplete="off" placeholder="🔍 ${t('common.search')}...">
183
+ <button id="btn-open-publish" class="hub-publish-button" type="button">➕ ${t('market.publish')}</button>
184
  `;
185
 
186
+ const filterLabels = {
187
+ "hub-sort-select": "common.sort_items",
188
+ "task-status-filter": "common.filter_task_status",
189
+ "task-sort-select": "common.sort_tasks",
190
+ "posts-sort-select": "common.sort_posts",
191
+ "prompts-sort-select": "common.sort_prompts",
192
+ "hub-search-input": "common.search"
193
+ };
194
+ for (const [id, labelKey] of Object.entries(filterLabels)) {
195
+ sortContainer.querySelector(`#${id}`)?.setAttribute("aria-label", t(labelKey));
196
+ }
197
+
198
  // 📱 Web 模式:隐藏发布入口(网页版不支持发布族功能,编辑路由监听处另有中央兜底)
199
  if (IS_WEB_MODE) {
200
  const publishBtn = sortContainer.querySelector("#btn-open-publish");
 
202
  }
203
 
204
  const contentBoxWrapper = document.createElement("div");
205
+ contentBoxWrapper.className = "sidebar-content-wrapper";
206
 
207
  const contentArea = document.createElement("div");
208
  contentArea.className = "sidebar-scroll-container";
209
+ contentArea.id = `${sidebarInstanceId}-tabpanel`;
210
+ contentArea.setAttribute("role", "tabpanel");
211
+ contentArea.setAttribute("tabindex", "0");
212
  // 🔧 自适应高度:flex 填充剩余空间,内容少时随内容收缩,避免容器框与底部页脚间出现空白
 
 
 
 
 
213
  contentBoxWrapper.appendChild(contentArea);
214
 
215
  // =========================================================================
216
  // 【新增】底部专业版权与合作伙伴信息区域 (Footer)
217
  // =========================================================================
218
  const footerContainer = document.createElement("div");
219
+ footerContainer.className = "sidebar-footer";
 
 
 
 
 
 
 
220
 
221
  footerContainer.innerHTML = `
222
+ <div class="sidebar-footer__links">
223
+ <a href="https://github.com/a63976659/ComfyUI-Ranking" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--community">🌍 ${t('footer.community_name')}</a>
224
+ <a href="https://github.com/a63976659" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--github">🐙 GitHub</a>
225
+ <a href="https://huggingface.co/ZHIWEI666" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--huggingface">🤗 Hugging Face</a>
226
+ <a href="#" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--company">🏢 砚影科技</a>
227
+ <a href="https://www.bilibili.com/video/BV1x4XzBXEk9" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--bilibili">📺 Bilibili</a>
228
  </div>
229
+ <div class="sidebar-footer__meta">
230
+ <span>MIT License Copyright (c) 2026 <a href="#" id="easter-egg-trigger" class="sidebar-footer__link sidebar-footer__link--easter-egg">猪的飞行梦</a></span>
231
+ <span class="sidebar-footer__separator" aria-hidden="true">|</span>
232
+ <span class="footer-version-label">${t('common.loading')}</span>
233
  </div>
234
  `;
235
 
 
238
  if (easterEggLink) {
239
  easterEggLink.addEventListener('click', (e) => {
240
  e.preventDefault();
241
+ const popup = window.open('', '_blank');
242
+ if (!popup) return;
243
+ import('../components/彩蛋动画引擎.js').then(mod => mod.openEasterEggPage(popup, {
244
+ language: getLanguage(),
245
+ title: t('easter.title'),
246
+ hint: t('easter.hint'),
247
+ speedLabel: t('easter.speed')
248
+ })).catch(() => popup.close());
249
  });
250
  }
251
 
 
257
  }
258
  }).catch(() => {
259
  const versionLabel = footerContainer.querySelector('.footer-version-label');
260
+ if (versionLabel) versionLabel.textContent = formatVersionString(UI.DEFAULT_PROJECT_VERSION);
261
  });
262
 
263
  // 监听从设置页面触发的背景更新事件
 
275
  });
276
  // =========================================================================
277
 
278
+ let activeInlineView = null;
279
+ let listNavigationGeneration = 0;
280
+ const captureListNavigation = () => {
281
+ const generation = listNavigationGeneration;
282
+ return () => !activeInlineView && generation === listNavigationGeneration;
283
+ };
284
+ const releaseInlineView = () => {
285
+ if (!activeInlineView) return;
286
+ const view = activeInlineView;
287
+ activeInlineView = null;
288
+ try { view._cleanup?.(); }
289
+ catch { console.warn('Sidebar inline view cleanup failed'); }
290
+ try { cleanupImageSandbox(view); }
291
+ finally { view.remove(); }
292
+ };
293
+ _sidebarInlineViewCleanup = releaseInlineView;
294
 
295
  const showInlineView = (viewDOM) => {
296
+ listNavigationGeneration++;
297
+ releaseInlineView();
 
 
298
  tabsContainer.style.display = "none";
299
  sortContainer.style.display = "none";
300
  contentBoxWrapper.style.display = "none";
 
304
  };
305
 
306
  const hideInlineView = () => {
307
+ listNavigationGeneration++;
308
+ releaseInlineView();
 
 
 
 
 
 
309
  tabsContainer.style.display = "flex";
310
  sortContainer.style.display = "flex";
311
  contentBoxWrapper.style.display = "flex";
 
332
  // 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
333
  if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
334
  const { itemData, currentUser } = e.detail;
335
+ const matchesSession = captureRequestSession();
336
+ if (!currentUser?.account || topNav.getCurrentUser()?.account !== currentUser.account) return;
337
 
338
  // 先展示加载状态,避免用户感知延迟
339
  const loadingView = document.createElement("div");
 
341
  loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
342
  showInlineView(loadingView);
343
 
344
+ const isCurrent = () => matchesSession() && topNav.getCurrentUser()?.account === currentUser.account
345
+ && activeInlineView === loadingView && loadingView.isConnected;
346
+ let enrichedItemData;
347
  try {
348
+ const detailRes = await api.getItemById(itemData.id, { noStore: true });
349
+ if (!isCurrent()) return;
350
+ const data = detailRes?.data, revision = data?.edit_revision === undefined ? 0 : data.edit_revision;
351
+ if (detailRes?.status !== 'success' || data?.id !== itemData.id || data?.author !== currentUser.account
352
+ || !Number.isSafeInteger(revision) || revision < 0) throw new Error('Invalid edit detail');
353
+ enrichedItemData = { ...data, edit_revision: revision };
354
+ } catch (_) {
355
+ if (!isCurrent()) return;
356
+ loadingView.textContent = t('common.operation_failed');
357
+ loadingView.setAttribute('role', 'alert');
358
+ return;
359
  }
360
+ if (!isCurrent()) return;
361
 
362
  const publishView = createPublishView(currentUser,
363
  () => hideInlineView(),
 
397
 
398
  // 🔔 通知跳转:切换到对应Tab并展开指定卡片
399
  _onSidebarWindow("comfy-route-to-item", async (e) => {
400
+ const detail = e.detail;
401
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return;
402
+ const { itemId, itemType } = detail;
403
+ if (!(typeof itemId === 'string' && itemId.trim()) &&
404
+ !(Number.isSafeInteger(itemId) && itemId > 0)) return;
405
 
406
  // 1. 根据 itemType 确定 Tab
407
  let targetTab;
408
  if (itemType === "tool") targetTab = "tools";
409
  else if (itemType === "skill") targetTab = "skills";
410
  else if (itemType === "app") targetTab = "apps";
411
+ else if (typeof itemType === 'string' && itemType.startsWith("recommend")) targetTab = "recommends";
412
  else targetTab = "tools"; // 兜底
413
 
414
  // 【问题2修复】如果已经在目标 Tab,先尝试在当前 DOM 中查找卡片
415
  if (currentTab === targetTab) {
416
+ const existingCard = Array.from(contentArea.querySelectorAll('[data-item-id]'))
417
+ .find(card => card.getAttribute('data-item-id') === String(itemId));
418
  if (existingCard) {
419
  // 关闭可能存在的通知中心等全屏视图
420
  if (activeInlineView) hideInlineView();
421
 
422
  const summary = existingCard.querySelector('.item-summary') || existingCard.firstElementChild;
423
  // 先确保卡片是折叠状态再展开(避免点击已展开的卡片把它收起来)
424
+ scrollCardIntoView(existingCard);
425
  // 检查详情区域是否已展开
426
  const detailView = existingCard.children[1]; // detailView 是第二个子元素
427
  if (detailView && detailView.style.display === 'none' && summary) {
 
436
  currentTab = targetTab;
437
  Store.save("activeTab", currentTab);
438
 
439
+ // 3. 同步选中态;视觉状态由共享主题 CSS 根据 aria-selected 绘制
440
+ syncTabsA11y();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
442
  // 4. 更新筛选控件可见性
443
  updateFilterVisibility(currentTab);
 
456
  currentUser: topNav.getCurrentUser(),
457
  renderToken: currentRenderToken,
458
  getRenderToken: getRenderToken,
459
+ isNavigationCurrent: captureListNavigation(),
460
  force: false,
461
  expandItemId: itemId // 新增:要展开的卡片ID
462
  });
463
  });
464
 
465
+ const savedTab = Store.load("activeTab", "tools");
466
+ let currentTab = tabs.some(tab => tab.id === savedTab) ? savedTab : tabs[0].id;
467
  let currentSort = Store.load("activeSort", "time");
468
  let currentRenderToken = 0;
469
  const getRenderToken = () => currentRenderToken;
470
+ _sidebarListCleanup = () => {
471
+ currentRenderToken++;
472
+ try { disposeSidebarContent(contentArea); }
473
+ finally { cleanupImageSandbox(contentArea); }
474
+ };
475
+ const syncTabsA11y = () => {
476
+ const selectedTab = tabs.find(tab => tab.id === currentTab) || tabs[0];
477
+ for (const tab of tabs) {
478
+ const button = tabsContainer.querySelector(`[data-tab-id="${tab.id}"]`);
479
+ if (!button) continue;
480
+ const selected = tab.id === selectedTab.id;
481
+ button.setAttribute("aria-selected", String(selected));
482
+ button.tabIndex = selected ? 0 : -1;
483
+ }
484
+ contentArea.setAttribute("aria-labelledby", `${sidebarInstanceId}-tab-${selectedTab.id}`);
485
+ };
486
 
487
  const triggerLoad = (forceRefresh = false) => {
488
  currentRenderToken++;
 
491
  keyword: sortContainer.querySelector("#hub-search-input").value.trim().toLowerCase(),
492
  contentArea: contentArea, currentUser: topNav.getCurrentUser(),
493
  renderToken: currentRenderToken, getRenderToken: getRenderToken,
494
+ isNavigationCurrent: captureListNavigation(),
495
  force: forceRefresh
496
  });
497
  };
 
506
 
507
  sortContainer.querySelector("#btn-open-publish").onclick = () => {
508
  const currentUser = topNav.getCurrentUser();
509
+ if (!currentUser) return showToast(t('auth.login_required'), "warning");
510
 
511
  // 🎯 根据当前Tab打开对应的发布界面
512
  if (currentTab === "tasks") {
 
533
  }
534
  };
535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  tabs.forEach(tab => {
537
  const btn = document.createElement("button");
538
+ // 共享主题 CSS 从 aria-label 绘制伪元素文案,避免翻译插件改写标签。
 
539
  btn.className = "hub-tab-btn";
540
+ btn.type = "button";
541
+ btn.id = `${sidebarInstanceId}-tab-${tab.id}`;
542
+ btn.dataset.tabId = tab.id;
543
+ btn.setAttribute("role", "tab");
544
+ btn.setAttribute("aria-controls", contentArea.id);
545
  btn.setAttribute("aria-label", tab.label);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  btn.onclick = () => {
547
  currentTab = tab.id; Store.save("activeTab", currentTab);
 
 
 
 
 
 
 
 
 
 
 
 
548
  sortContainer.querySelector("#hub-search-input").value = "";
549
  // 🎯 根据 Tab 切换筛选控件显示
550
  updateFilterVisibility(currentTab);
551
+ syncTabsA11y();
552
  triggerLoad();
553
  };
554
+ btn.addEventListener("keydown", (event) => {
555
+ const currentIndex = tabs.findIndex(item => item.id === tab.id);
556
+ let nextIndex = currentIndex;
557
+ if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % tabs.length;
558
+ else if (event.key === "ArrowLeft") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
559
+ else if (event.key === "Home") nextIndex = 0;
560
+ else if (event.key === "End") nextIndex = tabs.length - 1;
561
+ else return;
562
+ event.preventDefault();
563
+ const nextButton = tabsContainer.querySelector(`[data-tab-id="${tabs[nextIndex].id}"]`);
564
+ nextButton?.focus();
565
+ nextButton?.click();
566
+ });
567
  tabsContainer.appendChild(btn);
568
  });
569
+ syncTabsA11y();
570
 
571
  // 🎯 筛选控件显示切换函数
572
  const updateFilterVisibility = (tabId) => {
前端页面/core/侧边栏入口注册.js CHANGED
@@ -63,7 +63,16 @@ app.registerExtension({
63
  container.appendChild(globalSidebarDOM);
64
  } catch (e) {
65
  console.error('侧边栏功能加载失败:', e);
66
- container.innerHTML = '<div style="text-align:center; padding:40px; color:#F44336;">⚠️ 加载失败,请刷新重试</div>';
 
 
 
 
 
 
 
 
 
67
  }
68
  }
69
  });
 
63
  container.appendChild(globalSidebarDOM);
64
  } catch (e) {
65
  console.error('侧边栏功能加载失败:', e);
66
+ const failure = document.createElement("div");
67
+ failure.setAttribute("role", "alert");
68
+ failure.style.cssText = "text-align:center; padding:40px; color:#F44336;";
69
+ try {
70
+ const { t } = await import("../components/用户体验增强.js");
71
+ failure.textContent = `⚠️ ${t('common.sidebar_load_failed')}`;
72
+ } catch {
73
+ failure.textContent = "⚠️";
74
+ }
75
+ container.replaceChildren(failure);
76
  }
77
  }
78
  });
前端页面/core/侧边栏数据引擎.js CHANGED
@@ -16,21 +16,24 @@
16
  // - 增强缓存策略
17
  // ==========================================
18
 
 
 
 
 
 
 
 
19
  import { api } from "./网络请求API.js";
20
  import { proxyImages } from "./网络请求API.js";
21
- // 🔍 搜索时撤销上一次在途请求所需的取消管理器与分组 ID
22
- import { requestCancelManager } from "./网络请求API.js";
23
- import { API } from "./全局配置.js";
24
  import { createItemCard } from "../market/列表卡片组件.js";
25
  import { createCreatorCard } from "../market/创作者卡片组件.js";
26
- import {
27
- createPaginationLoader,
28
- createSkeleton,
29
- setCache,
30
- getCacheWithMeta,
31
- lazyLoadImages
32
  } from "../components/性能优化工具.js";
33
- import { applyViewportAnimations, getAnimationTypeForTab } from "../components/动画音效引擎.js";
34
  import { t } from "../components/用户体验增强.js";
35
  import { showToast } from "../components/UI交互提示组件.js"; // 🔧 降级提示改走统一组件(原为手写 div)
36
 
@@ -88,276 +91,90 @@ function getCacheExpireTime() {
88
  }
89
 
90
  // ==========================================
91
- // 🔄 本地排序函数
92
  // ==========================================
93
- let _lastSortInput = null;
94
- let _lastSortKey = '';
95
- let _lastSortResult = null;
96
-
97
- function sortDataLocally(data, tab, sort) {
98
- const sortKey = `${tab}_${sort}`;
99
- // 如果源数据引用相同且排序参数相同,返回缓存结果(避免重复创建数组拷贝)
100
- if (data === _lastSortInput && sortKey === _lastSortKey && _lastSortResult) {
101
- return _lastSortResult;
102
- }
103
-
104
- const sorted = [...data]; // 不修改原数组
105
-
106
- if (tab === "creators") {
107
- // 创作者排序(与后端 get_creators 一致)
108
- switch (sort) {
109
- case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
110
- case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
111
- case "downloads": sorted.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)); break;
112
- case "tips": sorted.sort((a, b) => (b.recent_tips || 0) - (a.recent_tips || 0)); break;
113
- case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
114
- case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
115
- default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
116
- }
117
- } else {
118
- // 工具/Skill/应用/推荐排序(与后端 get_items 一致)
119
- switch (sort) {
120
- case "downloads": sorted.sort((a, b) => (b.uses || 0) - (a.uses || 0)); break;
121
- case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
122
- case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
123
- case "tips":
124
- // 后端使用 tip_history 的当月数据排序
125
- sorted.sort((a, b) => {
126
- const now = new Date();
127
- const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
128
- const tipA = (a.tip_history || {})[currentMonth] || 0;
129
- const tipB = (b.tip_history || {})[currentMonth] || 0;
130
- return tipB - tipA;
131
- });
132
- break;
133
- case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
134
- case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
135
- case "rating":
136
- sorted.sort((a, b) => {
137
- const ratingA = a.rating_avg || 0;
138
- const ratingB = b.rating_avg || 0;
139
- if (ratingA === ratingB) {
140
- return (b.rating_count || 0) - (a.rating_count || 0);
141
- }
142
- return ratingB - ratingA;
143
- });
144
- break;
145
- default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
146
- }
147
- }
148
-
149
- // 缓存本次排序结果
150
- _lastSortInput = data;
151
- _lastSortKey = sortKey;
152
- _lastSortResult = sorted;
153
- return sorted;
154
- }
155
-
156
  /**
157
  * 从同 tab 的任意排序状态中获取已有数据
158
  */
159
- function findExistingTabData(tab) {
160
- for (const [key, state] of paginationStates) {
161
- if (key.startsWith(tab + "_") && state.allData.length > 0 && state.isFullyLoaded) {
162
- return state.allData;
163
- }
164
- }
165
- return null;
166
- }
167
-
168
- /**
169
- * 从 sessionStorage 读取创作者缓存
170
- */
171
- function getCreatorsFromSessionStorage() {
172
- try {
173
- const data = sessionStorage.getItem('ComfyRanking_CreatorsCache');
174
- if (data) {
175
- return JSON.parse(data);
176
- }
177
- } catch (e) {
178
- console.warn('读取创作者 sessionStorage 缓存失败:', e);
179
- }
180
- return null;
181
- }
182
-
183
- /**
184
- * 将创作者数据保存到 sessionStorage
185
- */
186
- function saveCreatorsToSessionStorage(data) {
187
- try {
188
- sessionStorage.setItem('ComfyRanking_CreatorsCache', JSON.stringify(data));
189
- } catch (e) {
190
- console.warn('创作者数据写入 sessionStorage 失败:', e);
191
- }
192
- }
193
-
194
- /**
195
- * 本地搜索创作者数据
196
- * @param {string} keyword - 搜索关键词
197
- * @param {Array} data - 创作者数据数组
198
- * @returns {Array} 匹配的数据
199
- */
200
- /**
201
- * 按关键词过滤列表数据(工具/应用/推荐Tab本地搜索)
202
- * @param {Array} items - 数据数组
203
- * @param {string} keyword - 搜索关键词
204
- * @returns {Array} 过滤后的数据
205
- */
206
- function _filterBySearch(items, keyword) {
207
- const lowerKeyword = keyword.toLowerCase();
208
- return items.filter(item => {
209
- const textStr = `${item.title||''} ${item.shortDesc||''} ${item.name||''} ${item.account||''}`.toLowerCase();
210
- return textStr.includes(lowerKeyword);
211
- });
212
- }
213
-
214
- /**
215
- * 本地搜索创作者数据
216
- * @param {string} keyword - 搜索关键词
217
- * @param {Array} data - 创作者数据数组
218
- * @returns {Array} 匹配的数据
219
- */
220
- function searchCreatorsLocally(keyword, data) {
221
- const lowerKeyword = keyword.toLowerCase();
222
- return data.filter(item => {
223
- const name = (item.name || '').toLowerCase();
224
- const account = (item.account || '').toLowerCase();
225
- const shortDesc = (item.shortDesc || '').toLowerCase();
226
- return name.includes(lowerKeyword) ||
227
- account.includes(lowerKeyword) ||
228
- shortDesc.includes(lowerKeyword);
229
- });
230
- }
231
-
232
 
233
  // ==========================================
234
  // 📦 分页状态管理
235
  // ==========================================
236
  // 每个标签页独立的分页状态
237
- const paginationStates = new Map();
 
238
 
239
- /**
240
- * 获取或创建分页状态
241
- */
242
- function getPaginationState(tab, sort) {
243
- const key = `${tab}_${sort}`;
244
- if (!paginationStates.has(key)) {
245
- paginationStates.set(key, {
246
- allData: [], // 全量数据(从缓存或网络加载)
247
- displayedCount: 0, // 已渲染数量
248
- loader: null, // 分页加载器实例
249
- isFullyLoaded: false, // 是否已加载完所有数据
250
- isSearchResult: false // 标记是否为搜索结果(用于区分正常数据和搜索结果)
251
- });
252
  }
253
- return paginationStates.get(key);
254
  }
255
 
256
 
257
  // ==========================================
258
  // 🚀 主加载函数
259
  // ==========================================
260
- export async function loadSidebarContent({
261
- tab,
262
- sort,
263
- keyword,
264
- contentArea,
265
- currentUser,
266
- renderToken,
267
  getRenderToken,
268
  force = false,
269
- expandItemId = null // 新增:要自动展开的卡片ID
 
270
  }) {
271
- // ========== 💬 讨论区特殊处理 ==========
272
- if (tab === "posts") {
273
- try {
274
- const postsModule = await getPostsView();
275
- const postsView = postsModule.createPostsView(currentUser, keyword);
276
- // 🔧 清理旧视图事件监听器
277
- Array.from(contentArea.children).forEach(child => {
278
- if (child._cleanup) child._cleanup();
279
- });
280
- contentArea.innerHTML = "";
281
- contentArea.appendChild(postsView);
282
- // 触发帖子列表加载
283
- if (postsModule.loadPosts) {
284
- postsModule.loadPosts();
285
- }
286
- } catch (error) {
287
- console.error("讨论区加载失败:", error);
288
- // 使用安全的DOM操作替代innerHTML,防止XSS
289
- contentArea.innerHTML = '';
290
- const errorDiv = document.createElement('div');
291
- errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
292
- errorDiv.textContent = `❌ 讨论区加载失败: ${error.message}`;
293
- contentArea.appendChild(errorDiv);
294
- }
295
- return;
296
- }
297
-
298
- // ========== 📝 任务榜特殊处理 ==========
299
- if (tab === "tasks") {
300
- try {
301
- const tasksModule = await getTasksView();
302
- const tasksView = tasksModule.createTasksView(currentUser, keyword);
303
- // 🔧 清理旧视图事件监听器
304
- Array.from(contentArea.children).forEach(child => {
305
- if (child._cleanup) child._cleanup();
306
- });
307
- contentArea.innerHTML = "";
308
- contentArea.appendChild(tasksView);
309
- } catch (error) {
310
- console.error("任务榜加载失败:", error);
311
- // 使用安全的DOM操作替代innerHTML,防止XSS
312
- contentArea.innerHTML = '';
313
- const errorDiv = document.createElement('div');
314
- errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
315
- errorDiv.textContent = `❌ 任务榜加载失败: ${error.message}`;
316
- contentArea.appendChild(errorDiv);
317
- }
318
- return;
319
- }
320
-
321
- // ========== 🧩 提示词特殊处理 ==========
322
- if (tab === "prompts") {
323
- try {
324
- const promptsModule = await getPromptsView();
325
- const promptsView = promptsModule.createPromptsView(currentUser, keyword);
326
- // 🔧 清理旧视图事件监听器
327
- Array.from(contentArea.children).forEach(child => {
328
- if (child._cleanup) child._cleanup();
329
- });
330
- contentArea.innerHTML = "";
331
- contentArea.appendChild(promptsView);
332
- } catch (error) {
333
- console.error("提示词加载失败:", error);
334
- // 使用安全的DOM操作替代innerHTML,防止XSS
335
- contentArea.innerHTML = '';
336
- const errorDiv = document.createElement('div');
337
- errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
338
- errorDiv.textContent = `❌ 提示词加载失败: ${error.message}`;
339
- contentArea.appendChild(errorDiv);
340
- }
341
  return;
342
  }
343
-
344
  const cacheKey = `ListCache_${tab}_${sort}`;
345
  const state = getPaginationState(tab, sort);
346
  const pageSize = tab === "creators" ? CREATORS_PAGE_SIZE : PAGE_SIZE;
347
-
 
 
 
 
348
  // ========== 🔄 强制刷新:清除当前 tab/sort 的内存缓存 ==========
349
  if (force) {
350
  state.allData = [];
351
  state.displayedCount = 0;
352
  state.isFullyLoaded = false;
 
 
 
 
353
  }
354
-
355
  // 停止之前的分页加载器
356
  if (state.loader) {
357
  state.loader.stop();
358
  state.loader = null; // 清除引用,防止旧分页器状态残留
359
  }
360
-
361
  // ========== 🔍 搜索过滤统一入口 ==========
362
  // 🔧 修复:过滤职责原先分裂在三处且口径不一致 —— loadMoreData 与分页器的
363
  // getTotalDataCount 都是「先过滤再切片」(正确),而各首屏分支���「先切 20 条再过滤」,
@@ -368,38 +185,38 @@ export async function loadSidebarContent({
368
  // 创作者Tab的 keyword 由后端搜索接口过滤,此处不做本地过滤(与原口径一致)。
369
  const _applySearchScope = (dataArray) => {
370
  if (!keyword || tab === "creators") return dataArray;
371
- return _filterBySearch(dataArray, keyword);
372
  };
373
-
374
  // ========== 渲染函数:渲染一批数据(只渲染,过滤由 _applySearchScope 统一负责)==========
375
  const renderBatch = (dataArray, append = false) => {
376
  if (renderToken !== getRenderToken()) return;
377
-
378
  // 🔧 数据已由调用方经 _applySearchScope 过滤,本函数只负责渲染(不再二次过滤)
379
  const displayData = dataArray;
380
-
381
  // 首次渲染清空容器
382
  if (!append) {
383
  contentArea.innerHTML = "";
384
  state.displayedCount = 0;
385
  }
386
-
387
  // 空数据处理
388
  if (displayData.length === 0 && state.displayedCount === 0) {
389
- contentArea.innerHTML = `
390
- <div style='text-align:center; padding: 40px 20px; color:#888;'>
391
- ${keyword ? '🔍 没有搜索到相关内容' : '📭 暂无数据,快来抢沙发吧!'}
392
- </div>
393
- `;
394
  return 0;
395
  }
396
-
397
  // 渲染卡片
398
  const fragment = document.createDocumentFragment();
399
  const animationType = getAnimationTypeForTab(tab);
400
  const cards = [];
401
  let targetCard = null; // 用于保存需要展开的目标卡片
402
-
403
  if (tab === "tools" || tab === "skills" || tab === "apps" || tab === "recommends") {
404
  displayData.forEach(data => {
405
  const card = createItemCard(data, currentUser);
@@ -417,64 +234,73 @@ export async function loadSidebarContent({
417
  fragment.appendChild(card);
418
  });
419
  }
420
-
421
  contentArea.appendChild(fragment);
422
-
423
  // ✨ 应用视口感知动画:可见卡片依次错开载入,滚动/分页进入视口的卡片自动补播动画
424
  applyViewportAnimations(contentArea, cards, animationType, !append);
425
-
426
  // 对新渲染的图片启用懒加载
427
  lazyLoadImages(contentArea, "img:not(.lazy-loaded):not(.lazy-loading)");
428
-
429
  // 🔔 自动展开目标卡片(如果有)
430
  if (targetCard && !append) {
431
  requestAnimationFrame(() => {
 
432
  // 滚动到卡片位置
433
- targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
434
  // 模拟点击摘要区域展开卡片
435
  const summary = targetCard.querySelector('.item-summary') || targetCard.firstElementChild;
436
  if (summary) summary.click();
437
  });
438
  }
439
-
440
  // 【问题1修复】首屏未找到目标卡片,fallback到独立详情页
441
- if (expandItemId && !targetCard && !append) {
442
  (async () => {
443
  try {
444
  const res = await api.getItemById(expandItemId);
 
445
  if (res.status === "success" && res.data) {
446
  const module = await import("../market/资源详情页面组件.js");
 
447
  const view = module.createItemDetailView(res.data, currentUser);
 
 
 
 
448
  window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
449
  }
450
  } catch (e) {
451
- console.error("展开卡片 fallback 失败:", e);
 
452
  }
453
  })();
454
  }
455
-
456
  state.displayedCount += displayData.length;
457
  return displayData.length;
458
  };
459
-
460
  // ========== 分页加载更多函数 ==========
461
- const loadMoreData = async (page, size) => {
 
462
  // 计算要显示的数据范围
463
  const start = (page - 1) * size;
464
  const end = start + size;
465
-
466
  // 从全量数据中截取(统一走 _applySearchScope,与首屏处于同一数据空间)
467
  const dataSlice = _applySearchScope(state.allData);
468
-
469
  const batch = dataSlice.slice(start, end);
470
-
471
  if (batch.length > 0) {
472
  renderBatch(batch, true);
473
  }
474
-
475
  return batch;
476
  };
477
-
478
  // ========== 尝试从缓存加载 ==========
479
  // 📌 本处的缓存口径与 任务榜/讨论区/提示词(统一走 性能优化工具.js 的
480
  // readListCache,命中后一律调 silentRefresh 后台补新)**刻意不同**,不是历史遗留,勿顺手归一:
@@ -483,8 +309,9 @@ export async function loadSidebarContent({
483
  // - 那三个列表只缓存第一页,命中后数据必然不完整,故一律后台 silentRefresh 补新。
484
  // 若强行统一成「总是后台刷新」,插件榜会在每次 Tab 切换时多发一次 200 条的全量请求。
485
  // 获取完整缓存信息(包含过期状态)
486
- const { value: cachedData, expired: isCacheExpired, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
487
-
 
488
  // 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
489
  // 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
490
  // 🔧 修复:原实现还把「刚清空搜索框」也列为跳过缓存的理由,但 cacheKey 不含 keyword、
@@ -497,46 +324,41 @@ export async function loadSidebarContent({
497
  state.allData = proxiedData;
498
  state.isFullyLoaded = true;
499
  // 缓存是全量列表而非搜索结果,清除搜索标记,避免下次加载仍被当作搜索结果处理
500
- if (tab === "creators" && !keyword) state.isSearchResult = false;
501
-
 
 
 
502
  // 首屏渲染(仅第一页)—— 先过滤再切片,与 loadMoreData 同一数据空间
503
  const scopedData = _applySearchScope(proxiedData);
504
  renderBatch(scopedData.slice(0, pageSize), false);
505
-
506
  // 如果有更多数据,启动分页加载器
507
  if (scopedData.length > pageSize) {
508
- _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
509
  }
510
-
511
  // 【新增】Tab 切换后台静默刷新 —— 仅缓存过期时触发
512
  if (isCacheExpired) {
513
  console.log(`🔄 缓存已过期,${tab}_${sort} 启动后台静默刷新...`);
514
-
515
  setTimeout(async () => {
516
  // 保存当前上下文快照,防止网络延迟期间用户切换Tab导致竞态
517
  const savedToken = renderToken;
518
  const savedTab = tab;
519
  const savedSort = sort;
520
-
521
  // 防竞态检查1:回调开始执行时验证
522
  if (savedToken !== getRenderToken()) return;
523
-
524
  try {
525
- let newData;
526
- if (savedTab === "tools" || savedTab === "skills" || savedTab === "apps" || savedTab === "recommends") {
527
- const itemType = savedTab === "tools" ? "tool" : (savedTab === "skills" ? "skill" : (savedTab === "apps" ? "app" : "recommend"));
528
- const response = await api.getItems(itemType, savedSort, 200);
529
- newData = proxyImages(response.data || []);
530
- } else if (savedTab === "creators") {
531
- const response = await api.getCreators(savedSort, 500);
532
- newData = proxyImages(response.data || []);
533
- } else {
534
- return; // posts/tasks 不走这个路径
535
- }
536
-
537
  // 防竞态检查2:网络请求完成后再次验证
538
  if (savedToken !== getRenderToken()) return;
539
-
540
  // 🛡️ 空响应防护:云端返回空列表时不回写缓存、也不清屏重渲染。
541
  // 本分支由「缓存过期」触发,是切 Tab 时最常走到的路径,若不��,
542
  // 下方 state.allData = newData + contentArea.innerHTML = "" 会把
@@ -545,7 +367,7 @@ export async function loadSidebarContent({
545
  console.warn(`🛡️ ${savedTab}_${savedSort} 云端返回空列表,保留本地数据,跳过缓存回写`);
546
  return;
547
  }
548
-
549
  // 只有token匹配时才同时更新state.allData和setCache
550
  if (_shouldUpdateData(state.allData, newData)) {
551
  console.log(`✅ ${savedTab}_${savedSort} 检测到新数据,执行静默更新`);
@@ -553,21 +375,21 @@ export async function loadSidebarContent({
553
  state.displayedCount = 0; // 重置分页计数
554
  // 📴 持久化到 localStorage,保证重启后离线仍可展示
555
  setCache(cacheKey, newData, getCacheExpireTime(), true);
556
-
557
  // 更新创作者 sessionStorage 缓存
558
  if (savedTab === "creators") {
559
  saveCreatorsToSessionStorage(newData);
560
  }
561
-
562
  // 完整重新渲染(替代之前只更新数字的方案)
563
  contentArea.innerHTML = "";
564
  // 🔧 先过滤再切片:搜索场景下静默刷新同样会触发,
565
  // 原实现在 newData 前 20 条里过滤,会漏掉靠后的匹配项
566
  const scopedNewData = _applySearchScope(newData);
567
  renderBatch(scopedNewData.slice(0, pageSize), false);
568
-
569
  if (scopedNewData.length > pageSize) {
570
- _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab);
571
  }
572
  } else {
573
  // 关键字段无变化 → 无需重渲染。但 newData 才是刚从云端取回的权威数据,
@@ -579,14 +401,15 @@ export async function loadSidebarContent({
579
  setCache(cacheKey, newData, getCacheExpireTime(), true);
580
  }
581
  } catch (err) {
582
- console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败:`, err);
 
583
  }
584
  }, 0);
585
  }
586
-
587
  return;
588
  }
589
-
590
  // ========== 🔄 本地排序优先:同 tab 已有数据时直接排序渲染(强制刷新时跳过)==========
591
  // 🔍 创作者Tab有keyword时不走本地排序优先,必须调用后端搜索API
592
  // 🔍 创作者Tab清空搜索框后也不走本地排序优先,避免使用搜索时的部分结果
@@ -601,32 +424,34 @@ export async function loadSidebarContent({
601
  state.allData = locallySorted;
602
  state.isFullyLoaded = true;
603
  state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
604
-
605
  // 首屏渲染(先过滤再切片)
606
  const scopedSorted = _applySearchScope(locallySorted);
607
  renderBatch(scopedSorted.slice(0, pageSize), false);
608
-
609
  // 启动分页加载器
610
  if (scopedSorted.length > pageSize) {
611
- _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
612
  }
613
-
614
  // 存入当前排序的缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线可展示)
615
  setCache(cacheKey, locallySorted, getCacheExpireTime(), true);
616
-
617
  return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
618
  }
619
-
620
  // ========== 🚀 缓存先行:阻塞前先用本地已有数据上屏 ==========
621
  // 走到这里说明上面两条快路径都没命中:要么是 force 强制刷新(刻意跳过缓存),
622
  // 要么是本次会话还没有该 tab 的完整数据。弱网下一次列表请求最长要烧满整个重试预算,
623
  // 期间用户只能盯骨架屏;此刻若本地已有可用数据,就先渲染首屏,网络请求照常往下走,
624
  // 回来后按 renderToken + _shouldUpdateData 决定是否重渲染(与上方静默刷新同口径)。
625
- // 🔍 创作者搜索场景不参与:renderBatch 对创作者不做本地过滤,先上屏全量列表会混入非搜索结果
 
626
  let renderedInstant = false;
627
- if (!isCreatorSearching) {
628
  // 可立即上屏的数据:当前排序的列表缓存(force 时被跳过)→ 创作者 sessionStorage 降级副本
629
- let instantData = (hasCacheData && Array.isArray(cachedData) && cachedData.length > 0) ? cachedData : null;
 
630
  if (!instantData && tab === "creators") {
631
  const sessionData = getCreatorsFromSessionStorage();
632
  if (Array.isArray(sessionData) && sessionData.length > 0) instantData = sessionData;
@@ -639,55 +464,36 @@ export async function loadSidebarContent({
639
  const sortedData = sortDataLocally(proxyImages(instantData), tab, sort);
640
  state.allData = sortedData;
641
  state.isFullyLoaded = true;
642
- state.isSearchResult = false;
 
643
  // 🔧 先过滤再切片(tools/skills/apps/recommends 带 keyword 时同样会进入本分支)
644
  const scopedInstant = _applySearchScope(sortedData);
645
  renderBatch(scopedInstant.slice(0, pageSize), false);
646
  if (scopedInstant.length > pageSize) {
647
- _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
648
  }
649
  renderedInstant = true;
650
  }
651
  }
652
-
653
  // ========== 显示加载骨架屏(已有本地数据上屏时跳过,避免闪屏)==========
654
  if (!renderedInstant) {
655
  contentArea.innerHTML = "";
656
  const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
657
  contentArea.appendChild(skeleton);
658
  }
659
-
660
  // ========== 从网络加载数据 ==========
661
  try {
662
- let response, realData;
663
-
664
- if (tab === "tools" || tab === "skills" || tab === "apps" || tab === "recommends") {
665
- const itemType = tab === "tools" ? "tool" : (tab === "skills" ? "skill" : (tab === "apps" ? "app" : "recommend"));
666
- response = await api.getItems(itemType, sort, 200); // 获取较多数据
667
- realData = response.data || [];
668
- realData = proxyImages(realData); // 确保图片走本地缓存代理
669
- } else if (tab === "creators") {
670
- // 🔍 创作者搜索:有 keyword 时调用后端搜索API,否则获取列表
671
- if (keyword) {
672
- // 🔍 先撤销上一次仍在途的搜索请求:用户继续输入后,上一次的关键词已经作废,
673
- // 但它的请求还会继续占用并发额度(全局最多 6 个)与最长 10 秒的超时预算,
674
- // 把最新这次搜索挤到后面排队,弱网下表现为「一直在转圈」。
675
- // 只撤销搜索分组,不影响列表、详情、点赞等任何其它请求
676
- requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
677
- response = await api.searchCreators(keyword, sort);
678
- realData = response.data || [];
679
- realData = proxyImages(realData); // 确保图片走本地缓存代理
680
- // 标记为搜索结果,不存入缓存,避免污染正常数据
681
- state.isSearchResult = true;
682
- } else {
683
- response = await api.getCreators(sort, 500);
684
- realData = response.data || [];
685
- realData = proxyImages(realData); // 确保图片走本地缓存代理
686
- // 正常数据,清除搜索标记
687
- state.isSearchResult = false;
688
- }
689
  }
690
-
691
  // 存入缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线仍可展示;搜索结果不存入缓存)
692
  const isCreatorSearch = tab === "creators" && keyword;
693
  // 🛡️ 空响应防护:创作者搜索命中 0 条是有效答案(下方要渲染「没有搜索到相关内容」),
@@ -698,39 +504,39 @@ export async function loadSidebarContent({
698
  } else if (!isCreatorSearch) {
699
  setCache(cacheKey, realData, getCacheExpireTime(), true);
700
  }
701
-
702
  // 创作者非搜索数据存入 sessionStorage(用于离线降级)
703
  if (tab === "creators" && !isCreatorSearch && !isEmptyOverwrite) {
704
  saveCreatorsToSessionStorage(realData);
705
  }
706
-
707
  state.isFullyLoaded = true;
708
-
709
  // 🛡️ 空响应且本地数据已上屏:保持当前画面,不用空列表清屏(与缓存同口径不回写)
710
  if (isEmptyOverwrite && renderedInstant) {
711
  return;
712
  }
713
-
714
  // 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
715
  // 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
716
  // 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
717
  if (renderedInstant && !_shouldUpdateData(state.allData, realData)) {
718
  return;
719
  }
720
-
721
  // 更新状态
722
  state.allData = realData;
723
-
724
  // 渲染首屏(先过滤再切片:tools/skills/apps/recommends 的 keyword 是本地搜索,
725
  // realData 是未过滤的全量数据,原实现在前 20 条里过滤会漏掉靠后的匹配项)
726
  const scopedReal = _applySearchScope(realData);
727
  renderBatch(scopedReal.slice(0, pageSize), false);
728
-
729
  // 启动分页加载器
730
  if (scopedReal.length > pageSize) {
731
- _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
732
  }
733
-
734
  } catch (error) {
735
  // 🔍 防竞态:本次加载已被更新的加载取代(切Tab、换排序、继续输入关键词)时直接退出。
736
  // renderBatch 内部本来就有这道守卫,但它之外的降级分支会直接写 contentArea.innerHTML
@@ -745,16 +551,17 @@ export async function loadSidebarContent({
745
  console.log(`⏹️ ${tab}_${sort} 搜索请求已被新关键词取代`);
746
  return;
747
  }
748
- console.error("数据加载失败:", error);
749
-
750
  // 🚀 缓存先行场景:首屏已经用本地数据上屏,网络失败时保持当前画面即可,不再走下面的
751
  // 降级分支(否则会把同一批数据重渲染一遍、重播卡片动画)。与上方后台静默刷新失败一致:
752
  // 只记录日志,不额外打扰用户
753
  if (renderedInstant) {
754
  console.warn(`📴 ${tab}_${sort} 网络刷新失败,保持已渲染的本地数据`);
 
755
  return;
756
  }
757
-
758
  // 🚀 回退到任何可用缓存(包括过期的)
759
  // 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
760
  // 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
@@ -765,7 +572,7 @@ export async function loadSidebarContent({
765
  // 带 keyword 时会进来;renderBatch 不再自行过滤,此处必须显式过滤
766
  const scopedCached = _applySearchScope(cachedData);
767
  renderBatch(scopedCached.slice(0, pageSize), false);
768
-
769
  // 如果是过期缓存,显示提示
770
  // 🔧 改用统一提示组件 + 词典(原为手写 div、无动画不排队,且文案硬编码中文)
771
  if (isCacheExpired) {
@@ -773,13 +580,14 @@ export async function loadSidebarContent({
773
  }
774
  return;
775
  }
776
-
777
  // 🚀 创作者Tab:尝试从 sessionStorage 或内存中降级
778
  if (tab === "creators") {
779
  let fallbackData = null;
780
-
781
  // 1. 优先从当前内存状态获取
782
- if (state.allData && state.allData.length > 0) {
 
783
  fallbackData = state.allData;
784
  }
785
  // 2. 从本地列表缓存获取(含过期缓存,搜索结果不入库故不会污染)
@@ -794,26 +602,26 @@ export async function loadSidebarContent({
794
  if (!fallbackData) {
795
  fallbackData = findExistingTabData("creators");
796
  }
797
-
798
  if (fallbackData && fallbackData.length > 0) {
799
  let displayData = fallbackData;
800
-
801
  // 搜索场景:执行本地搜索
802
  if (keyword) {
803
  displayData = searchCreatorsLocally(keyword, fallbackData);
804
  if (displayData.length === 0) {
805
- contentArea.innerHTML = `
806
- <div style='text-align:center; padding: 40px 20px; color:#888;'>
807
- 🔍 没有搜索到相关内容
808
- </div>
809
- `;
810
-
811
  // 🔧 改用统一提示组件 + 词典(原为手写 div 且文案硬编码中文)
812
  showToast(t('feedback.search_local_result'), 'warning');
813
  return;
814
  }
815
  }
816
-
817
  // 按当前排序排序
818
  displayData = sortDataLocally(displayData, tab, sort);
819
  state.allData = displayData;
@@ -821,143 +629,30 @@ export async function loadSidebarContent({
821
  // searchCreatorsLocally 过滤),保证各渲染入口口径完全一致
822
  const scopedFallback = _applySearchScope(displayData);
823
  renderBatch(scopedFallback.slice(0, pageSize), false);
824
-
825
  // 启动分页加载器
826
  if (scopedFallback.length > pageSize) {
827
- _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
828
  }
829
-
830
  // 🔧 改用统一提示组件 + 词典(原为手写 div 且两条文案均硬编码中文)
831
  showToast(keyword ? t('feedback.search_local_result') : t('feedback.cache_fallback'), 'warning');
832
  return;
833
  } else if (keyword) {
834
- contentArea.innerHTML = '';
835
- const emptyDiv = document.createElement('div');
836
- emptyDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#888;';
837
- emptyDiv.textContent = `🔌 ${t('common.network_error_retry')}`;
838
- contentArea.appendChild(emptyDiv);
839
  return;
840
  }
841
  }
842
-
843
- // 无任何缓存,显示原有的错误信息
844
- // 使用安全的DOM操作替代innerHTML,防止XSS
845
- contentArea.innerHTML = '';
846
- const errorDiv = document.createElement('div');
847
- errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
848
- const errorText = document.createElement('div');
849
- errorText.textContent = `❌ 数据加载失败: ${error.message}`;
850
- errorDiv.appendChild(errorText);
851
- const br = document.createElement('br');
852
- errorDiv.appendChild(br);
853
- const retryBtn = document.createElement('button');
854
- retryBtn.style.cssText = 'padding:8px 16px; background:#2196F3; color:white; border:none; border-radius:4px; cursor:pointer; margin-top:16px;';
855
- retryBtn.textContent = '🔄 点击重试';
856
- retryBtn.onclick = () => location.reload();
857
- errorDiv.appendChild(retryBtn);
858
- contentArea.appendChild(errorDiv);
859
- }
860
- }
861
 
862
-
863
- // ==========================================
864
- // 📜 设置分页加载器
865
- // ==========================================
866
- function _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab) {
867
- // 安全清理旧分页器,防止状态残留和重复事件监听
868
- if (state.loader) {
869
- state.loader.stop();
870
  }
871
- state.loader = null;
872
-
873
- // 获取滚动容器(侧边栏主容器)
874
- const scrollContainer = contentArea.closest(".sidebar-scroll-container") || contentArea.parentElement;
875
-
876
- if (!scrollContainer) {
877
- console.warn("⚠️ 未找到滚动容器,分页加载无法启动");
878
-
879
- // 添加手动加载按钮作为 fallback
880
- let nextPage = 2;
881
- const loadMoreBtn = document.createElement('button');
882
- loadMoreBtn.textContent = t('task.load_more');
883
- loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
884
- loadMoreBtn.onclick = async () => {
885
- loadMoreBtn.disabled = true;
886
- loadMoreBtn.textContent = t('common.loading');
887
- try {
888
- const result = await loadMoreData(nextPage, pageSize);
889
- if (result && result.length > 0) {
890
- nextPage++;
891
- loadMoreBtn.textContent = t('task.load_more');
892
- } else {
893
- loadMoreBtn.textContent = t('common.no_more');
894
- loadMoreBtn.style.cursor = 'default';
895
- loadMoreBtn.onclick = null;
896
- }
897
- } catch (err) {
898
- console.error("手动加载更多失败:", err);
899
- loadMoreBtn.textContent = t('task.load_failed');
900
- }
901
- loadMoreBtn.disabled = false;
902
- };
903
- contentArea.appendChild(loadMoreBtn);
904
- return;
905
- }
906
-
907
- // 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
908
- // 📌 口径必须与 loadSidebarContent 内的 _applySearchScope 一致(先过滤再计数);
909
- // 本函数是模块级、拿不到那个闭包,故保留等价实现,改动其一务必同步另一处
910
- const getTotalDataCount = () => {
911
- if (!keyword) return state.allData.length;
912
- // 创作者Tab后端搜索已过滤,直接返回全部数据
913
- if (tab === "creators") return state.allData.length;
914
- return _filterBySearch(state.allData, keyword).length;
915
- };
916
-
917
- // 创建分页加载器
918
- const loader = createPaginationLoader({
919
- container: scrollContainer,
920
- pageSize: pageSize,
921
- threshold: 300,
922
- loadMore: async (page, size) => {
923
- const totalCount = getTotalDataCount();
924
-
925
- // 如果已显示完所有数据,返回空数组停止加载
926
- if (state.displayedCount >= totalCount) {
927
- return [];
928
- }
929
-
930
- return loadMoreData(page, size);
931
- },
932
- onEnd: () => {
933
- // 显示"已经到底了"提示
934
- const totalCount = getTotalDataCount();
935
- // 只有在有数据且显示数量 >= 总数量时才显示
936
- if (totalCount > 0 && state.displayedCount >= totalCount) {
937
- _showEndIndicator(contentArea);
938
- }
939
- }
940
- });
941
-
942
- state.loader = loader;
943
- loader.start();
944
- }
945
-
946
- // ==========================================
947
- // 🏁 显示"已经到底了"提示
948
- // ==========================================
949
- function _showEndIndicator(contentArea) {
950
- // 检查是否已存在
951
- if (contentArea.querySelector('.end-indicator')) return;
952
-
953
- const endIndicator = document.createElement('div');
954
- endIndicator.className = 'end-indicator';
955
- endIndicator.innerHTML = `
956
- <div style="text-align: center; padding: 20px; color: #666; font-size: 13px;">
957
- — 已经到底了 —
958
- </div>
959
- `;
960
- contentArea.appendChild(endIndicator);
961
  }
962
 
963
 
@@ -985,7 +680,7 @@ function _isEmptyOverwrite(newData, ...localData) {
985
  function _shouldUpdateData(oldData, newData) {
986
  if (!oldData || !newData) return true;
987
  if (oldData.length !== newData.length) return true;
988
-
989
  // 检查ID顺序是否一致(检测排序变化、新增/删除)
990
  // 比对条数覆盖首屏渲染量(PAGE_SIZE),避免首屏可见卡片(第 11~20 条)的版本/数字更新被漏检
991
  const checkCount = Math.min(PAGE_SIZE, oldData.length);
@@ -993,7 +688,7 @@ function _shouldUpdateData(oldData, newData) {
993
  const oldId = oldData[i].id || oldData[i].account;
994
  const newId = newData[i].id || newData[i].account;
995
  if (oldId !== newId) return true;
996
-
997
  // 检查关键数字字段
998
  const keyFields = ['likes', 'downloads', 'uses', 'views', 'daily_views', 'recent_tips', 'favorites', 'rating_avg', 'rating_count'];
999
  for (const field of keyFields) {
 
16
  // - 增强缓存策略
17
  // ==========================================
18
 
19
+ import { fetchSidebarList, readSidebarListCache, readCreatorsSessionCache as getCreatorsFromSessionStorage,
20
+ writeCreatorsSessionCache as saveCreatorsToSessionStorage } from "./榜单数据来源.js";
21
+ import { mountSidebarDomainView } from "./榜单视图切换.js";
22
+ import { setupSidebarPagination as _setupPaginationLoader } from "./榜单分页控制.js";
23
+ import { createSidebarPaginationStore } from "./榜单分页状态.js";
24
+ import { sortDataLocally, filterItemsByKeyword, searchCreatorsLocally } from "./榜单本地查询.js";
25
+
26
  import { api } from "./网络请求API.js";
27
  import { proxyImages } from "./网络请求API.js";
 
 
 
28
  import { createItemCard } from "../market/列表卡片组件.js";
29
  import { createCreatorCard } from "../market/创作者卡片组件.js";
30
+ import {
31
+ createSkeleton,
32
+ setCache,
33
+ lazyLoadImages,
34
+ renderListLoadError
 
35
  } from "../components/性能优化工具.js";
36
+ import { applyViewportAnimations, getAnimationTypeForTab, scrollCardIntoView, stopViewportAnimations } from "../components/动画音效引擎.js";
37
  import { t } from "../components/用户体验增强.js";
38
  import { showToast } from "../components/UI交互提示组件.js"; // 🔧 降级提示改走统一组件(原为手写 div)
39
 
 
91
  }
92
 
93
  // ==========================================
94
+ // 🔄 同榜单数据与创作者会话缓存
95
  // ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  /**
97
  * 从同 tab 的任意排序状态中获取已有数据
98
  */
99
+ const findExistingTabData = tab => paginationStore.findCompleteData(tab);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  // ==========================================
102
  // 📦 分页状态管理
103
  // ==========================================
104
  // 每个标签页独立的分页状态
105
+ const paginationStore = createSidebarPaginationStore();
106
+ const getPaginationState = (tab, sort) => paginationStore.get(tab, sort);
107
 
108
+ export function disposeSidebarContent(contentArea) {
109
+ stopViewportAnimations(contentArea);
110
+ try { paginationStore.stopLoaders(); }
111
+ catch { console.warn('Sidebar pagination cleanup failed'); }
112
+ for (const child of Array.from(contentArea.children)) {
113
+ const cleanup = child._cleanup;
114
+ child._cleanup = null;
115
+ try { cleanup?.call(child); }
116
+ catch { console.warn('Sidebar list view cleanup failed'); }
 
 
 
 
117
  }
 
118
  }
119
 
120
 
121
  // ==========================================
122
  // 🚀 主加载函数
123
  // ==========================================
124
+ export async function loadSidebarContent({
125
+ tab,
126
+ sort,
127
+ keyword,
128
+ contentArea,
129
+ currentUser,
130
+ renderToken,
131
  getRenderToken,
132
  force = false,
133
+ expandItemId = null, // 新增:要自动展开的卡片ID
134
+ isNavigationCurrent = () => true
135
  }) {
136
+ if (renderToken !== getRenderToken()) return;
137
+ const canNavigate = () => renderToken === getRenderToken() && isNavigationCurrent();
138
+ // 新榜单取得视图所有权时停止所有旧分页器,保留各榜单已加载数据。
139
+ paginationStore.stopLoaders();
140
+
141
+ const domainViews = {
142
+ posts: { loadModule: getPostsView, factory: 'createPostsView', errorKey: 'feedback.load_posts_failed',
143
+ afterMount: module => { if (module.loadPosts) module.loadPosts(); } },
144
+ tasks: { loadModule: getTasksView, factory: 'createTasksView', errorKey: 'feedback.load_tasks_failed' },
145
+ prompts: { loadModule: getPromptsView, factory: 'createPromptsView', errorKey: 'feedback.load_prompts_failed' },
146
+ };
147
+ if (Object.hasOwn(domainViews, tab)) {
148
+ await mountSidebarDomainView({ contentArea, currentUser, keyword, renderToken, getRenderToken,
149
+ ...domainViews[tab] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return;
151
  }
152
+
153
  const cacheKey = `ListCache_${tab}_${sort}`;
154
  const state = getPaginationState(tab, sort);
155
  const pageSize = tab === "creators" ? CREATORS_PAGE_SIZE : PAGE_SIZE;
156
+ const sameCreatorSearchData = tab === "creators" && keyword && state.isSearchResult &&
157
+ state.searchKeyword === keyword && Array.isArray(state.allData) && state.allData.length > 0
158
+ ? state.allData
159
+ : null;
160
+
161
  // ========== 🔄 强制刷新:清除当前 tab/sort 的内存缓存 ==========
162
  if (force) {
163
  state.allData = [];
164
  state.displayedCount = 0;
165
  state.isFullyLoaded = false;
166
+ if (!sameCreatorSearchData) {
167
+ state.isSearchResult = false;
168
+ state.searchKeyword = "";
169
+ }
170
  }
171
+
172
  // 停止之前的分页加载器
173
  if (state.loader) {
174
  state.loader.stop();
175
  state.loader = null; // 清除引用,防止旧分页器状态残留
176
  }
177
+
178
  // ========== 🔍 搜索过滤统一入口 ==========
179
  // 🔧 修复:过滤职责原先分裂在三处且口径不一致 —— loadMoreData 与分页器的
180
  // getTotalDataCount 都是「先过滤再切片」(正确),而各首屏分支���「先切 20 条再过滤」,
 
185
  // 创作者Tab的 keyword 由后端搜索接口过滤,此处不做本地过滤(与原口径一致)。
186
  const _applySearchScope = (dataArray) => {
187
  if (!keyword || tab === "creators") return dataArray;
188
+ return filterItemsByKeyword(dataArray, keyword);
189
  };
190
+
191
  // ========== 渲染函数:渲染一批数据(只渲染,过滤由 _applySearchScope 统一负责)==========
192
  const renderBatch = (dataArray, append = false) => {
193
  if (renderToken !== getRenderToken()) return;
194
+
195
  // 🔧 数据已由调用方经 _applySearchScope 过滤,本函数只负责渲染(不再二次过滤)
196
  const displayData = dataArray;
197
+
198
  // 首次渲染清空容器
199
  if (!append) {
200
  contentArea.innerHTML = "";
201
  state.displayedCount = 0;
202
  }
203
+
204
  // 空数据处理
205
  if (displayData.length === 0 && state.displayedCount === 0) {
206
+ const emptyState = document.createElement('div');
207
+ emptyState.style.cssText = 'text-align:center; padding:40px 20px; color:#888;';
208
+ emptyState.textContent = t(keyword ? 'feedback.no_search_results' : 'feedback.no_list_data');
209
+ emptyState.setAttribute('role', 'status');
210
+ contentArea.replaceChildren(emptyState);
211
  return 0;
212
  }
213
+
214
  // 渲染卡片
215
  const fragment = document.createDocumentFragment();
216
  const animationType = getAnimationTypeForTab(tab);
217
  const cards = [];
218
  let targetCard = null; // 用于保存需要展开的目标卡片
219
+
220
  if (tab === "tools" || tab === "skills" || tab === "apps" || tab === "recommends") {
221
  displayData.forEach(data => {
222
  const card = createItemCard(data, currentUser);
 
234
  fragment.appendChild(card);
235
  });
236
  }
237
+
238
  contentArea.appendChild(fragment);
239
+
240
  // ✨ 应用视口感知动画:可见卡片依次错开载入,滚动/分页进入视口的卡片自动补播动画
241
  applyViewportAnimations(contentArea, cards, animationType, !append);
242
+
243
  // 对新渲染的图片启用懒加载
244
  lazyLoadImages(contentArea, "img:not(.lazy-loaded):not(.lazy-loading)");
245
+
246
  // 🔔 自动展开目标卡片(如果有)
247
  if (targetCard && !append) {
248
  requestAnimationFrame(() => {
249
+ if (!canNavigate()) return;
250
  // 滚动到卡片位置
251
+ scrollCardIntoView(targetCard);
252
  // 模拟点击摘要区域展开卡片
253
  const summary = targetCard.querySelector('.item-summary') || targetCard.firstElementChild;
254
  if (summary) summary.click();
255
  });
256
  }
257
+
258
  // 【问题1修复】首屏未找到目标卡片,fallback到独立详情页
259
+ if (expandItemId && !targetCard && !append && canNavigate()) {
260
  (async () => {
261
  try {
262
  const res = await api.getItemById(expandItemId);
263
+ if (!canNavigate()) return;
264
  if (res.status === "success" && res.data) {
265
  const module = await import("../market/资源详情页面组件.js");
266
+ if (!canNavigate()) return;
267
  const view = module.createItemDetailView(res.data, currentUser);
268
+ if (!canNavigate()) {
269
+ view._cleanup?.();
270
+ return;
271
+ }
272
  window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
273
  }
274
  } catch (e) {
275
+ if (!canNavigate()) return;
276
+ console.warn("展开卡片详情失败");
277
  }
278
  })();
279
  }
280
+
281
  state.displayedCount += displayData.length;
282
  return displayData.length;
283
  };
284
+
285
  // ========== 分页加载更多函数 ==========
286
+ const loadMoreData = async (page, size, { isCurrent = () => true } = {}) => {
287
+ if (!isCurrent()) return [];
288
  // 计算要显示的数据范围
289
  const start = (page - 1) * size;
290
  const end = start + size;
291
+
292
  // 从全量数据中截取(统一走 _applySearchScope,与首屏处于同一数据空间)
293
  const dataSlice = _applySearchScope(state.allData);
294
+
295
  const batch = dataSlice.slice(start, end);
296
+
297
  if (batch.length > 0) {
298
  renderBatch(batch, true);
299
  }
300
+
301
  return batch;
302
  };
303
+
304
  // ========== 尝试从缓存加载 ==========
305
  // 📌 本处的缓存口径与 任务榜/讨论区/提示词(统一走 性能优化工具.js 的
306
  // readListCache,命中后一律调 silentRefresh 后台补新)**刻意不同**,不是历史遗留,勿顺手归一:
 
309
  // - 那三个列表只缓存第一页,命中后数据必然不完整,故一律后台 silentRefresh 补新。
310
  // 若强行统一成「总是后台刷新」,插件榜会在每次 Tab 切换时多发一次 200 条的全量请求。
311
  // 获取完整缓存信息(包含过期状态)
312
+ const { value: cachedData, expired: isCacheExpired, found: hasCacheData } =
313
+ readSidebarListCache(cacheKey, { creators: tab === 'creators' });
314
+
315
  // 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
316
  // 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
317
  // 🔧 修复:原实现还把「刚清空搜索框」也列为跳过缓存的理由,但 cacheKey 不含 keyword、
 
324
  state.allData = proxiedData;
325
  state.isFullyLoaded = true;
326
  // 缓存是全量列表而非搜索结果,清除搜索标记,避免下次加载仍被当作搜索结果处理
327
+ if (tab === "creators" && !keyword) {
328
+ state.isSearchResult = false;
329
+ state.searchKeyword = "";
330
+ }
331
+
332
  // 首屏渲染(仅第一页)—— 先过滤再切片,与 loadMoreData 同一数据空间
333
  const scopedData = _applySearchScope(proxiedData);
334
  renderBatch(scopedData.slice(0, pageSize), false);
335
+
336
  // 如果有更多数据,启动分页加载器
337
  if (scopedData.length > pageSize) {
338
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
339
  }
340
+
341
  // 【新增】Tab 切换后台静默刷新 —— 仅缓存过期时触发
342
  if (isCacheExpired) {
343
  console.log(`🔄 缓存已过期,${tab}_${sort} 启动后台静默刷新...`);
344
+
345
  setTimeout(async () => {
346
  // 保存当前上下文快照,防止网络延迟期间用户切换Tab导致竞态
347
  const savedToken = renderToken;
348
  const savedTab = tab;
349
  const savedSort = sort;
350
+
351
  // 防竞态检查1:回调开始执行时验证
352
  if (savedToken !== getRenderToken()) return;
353
+
354
  try {
355
+ const rawData = await fetchSidebarList({ tab: savedTab, sort: savedSort, background: true });
356
+ if (savedToken !== getRenderToken()) return;
357
+ const newData = proxyImages(rawData);
358
+
 
 
 
 
 
 
 
 
359
  // 防竞态检查2:网络请求完成后再次验证
360
  if (savedToken !== getRenderToken()) return;
361
+
362
  // 🛡️ 空响应防护:云端返回空列表时不回写缓存、也不清屏重渲染。
363
  // 本分支由「缓存过期」触发,是切 Tab 时最常走到的路径,若不��,
364
  // 下方 state.allData = newData + contentArea.innerHTML = "" 会把
 
367
  console.warn(`🛡️ ${savedTab}_${savedSort} 云端返回空列表,保留本地数据,跳过缓存回写`);
368
  return;
369
  }
370
+
371
  // 只有token匹配时才同时更新state.allData和setCache
372
  if (_shouldUpdateData(state.allData, newData)) {
373
  console.log(`✅ ${savedTab}_${savedSort} 检测到新数据,执行静默更新`);
 
375
  state.displayedCount = 0; // 重置分页计数
376
  // 📴 持久化到 localStorage,保证重启后离线仍可展示
377
  setCache(cacheKey, newData, getCacheExpireTime(), true);
378
+
379
  // 更新创作者 sessionStorage 缓存
380
  if (savedTab === "creators") {
381
  saveCreatorsToSessionStorage(newData);
382
  }
383
+
384
  // 完整重新渲染(替代之前只更新数字的方案)
385
  contentArea.innerHTML = "";
386
  // 🔧 先过滤再切片:搜索场景下静默刷新同样会触发,
387
  // 原实现在 newData 前 20 条里过滤,会漏掉靠后的匹配项
388
  const scopedNewData = _applySearchScope(newData);
389
  renderBatch(scopedNewData.slice(0, pageSize), false);
390
+
391
  if (scopedNewData.length > pageSize) {
392
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab, savedToken, getRenderToken);
393
  }
394
  } else {
395
  // 关键字段无变化 → 无需重渲染。但 newData 才是刚从云端取回的权威数据,
 
401
  setCache(cacheKey, newData, getCacheExpireTime(), true);
402
  }
403
  } catch (err) {
404
+ if (savedToken !== getRenderToken()) return;
405
+ console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败`);
406
  }
407
  }, 0);
408
  }
409
+
410
  return;
411
  }
412
+
413
  // ========== 🔄 本地排序优先:同 tab 已有数据时直接排序渲染(强制刷新时跳过)==========
414
  // 🔍 创作者Tab有keyword时不走本地排序优先,必须调用后端搜索API
415
  // 🔍 创作者Tab清空搜索框后也不走本地排序优先,避免使用搜索时的部分结果
 
424
  state.allData = locallySorted;
425
  state.isFullyLoaded = true;
426
  state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
427
+
428
  // 首屏渲染(先过滤再切片)
429
  const scopedSorted = _applySearchScope(locallySorted);
430
  renderBatch(scopedSorted.slice(0, pageSize), false);
431
+
432
  // 启动分页加载器
433
  if (scopedSorted.length > pageSize) {
434
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
435
  }
436
+
437
  // 存入当前排序的缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线可展示)
438
  setCache(cacheKey, locallySorted, getCacheExpireTime(), true);
439
+
440
  return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
441
  }
442
+
443
  // ========== 🚀 缓存先行:阻塞前先用本地已有数据上屏 ==========
444
  // 走到这里说明上面两条快路径都没命中:要么是 force 强制刷新(刻意跳过缓存),
445
  // 要么是本次会话还没有该 tab 的完整数据。弱网下一次列表请求最长要烧满整个重试预算,
446
  // 期间用户只能盯骨架屏;此刻若本地已有可用数据,就先渲染首屏,网络请求照常往下走,
447
  // 回来后按 renderToken + _shouldUpdateData 决定是否重渲染(与上方静默刷新同口径)。
448
+ // 🔍 新关键词的创作者搜索不参与缓存先行,避免显示其它查询结果;只��强制刷新当前同一关键词时,
449
+ // 才临时复用精确匹配的上一批搜索结果(state.searchKeyword),失败时继续保留它。
450
  let renderedInstant = false;
451
+ if (!isCreatorSearching || sameCreatorSearchData) {
452
  // 可立即上屏的数据:当前排序的列表缓存(force 时被跳过)→ 创作者 sessionStorage 降级副本
453
+ let instantData = sameCreatorSearchData ||
454
+ ((hasCacheData && Array.isArray(cachedData) && cachedData.length > 0) ? cachedData : null);
455
  if (!instantData && tab === "creators") {
456
  const sessionData = getCreatorsFromSessionStorage();
457
  if (Array.isArray(sessionData) && sessionData.length > 0) instantData = sessionData;
 
464
  const sortedData = sortDataLocally(proxyImages(instantData), tab, sort);
465
  state.allData = sortedData;
466
  state.isFullyLoaded = true;
467
+ state.isSearchResult = !!sameCreatorSearchData;
468
+ state.searchKeyword = sameCreatorSearchData ? keyword : "";
469
  // 🔧 先过滤再切片(tools/skills/apps/recommends 带 keyword 时同样会进入本分支)
470
  const scopedInstant = _applySearchScope(sortedData);
471
  renderBatch(scopedInstant.slice(0, pageSize), false);
472
  if (scopedInstant.length > pageSize) {
473
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
474
  }
475
  renderedInstant = true;
476
  }
477
  }
478
+
479
  // ========== 显示加载骨架屏(已有本地数据上屏时跳过,避免闪屏)==========
480
  if (!renderedInstant) {
481
  contentArea.innerHTML = "";
482
  const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
483
  contentArea.appendChild(skeleton);
484
  }
485
+
486
  // ========== 从网络加载数据 ==========
487
  try {
488
+ const rawData = await fetchSidebarList({ tab, sort, keyword, force });
489
+ if (renderToken !== getRenderToken()) return;
490
+ const realData = proxyImages(rawData);
491
+ if (renderToken !== getRenderToken()) return;
492
+ if (tab === 'creators') {
493
+ state.isSearchResult = !!keyword;
494
+ state.searchKeyword = keyword || '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  }
496
+
497
  // 存入缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线仍可展示;搜索结果不存入缓存)
498
  const isCreatorSearch = tab === "creators" && keyword;
499
  // 🛡️ 空响应防护:创作者搜索命中 0 条是有效答案(下方要渲染「没有搜索到相关内容」),
 
504
  } else if (!isCreatorSearch) {
505
  setCache(cacheKey, realData, getCacheExpireTime(), true);
506
  }
507
+
508
  // 创作者非搜索数据存入 sessionStorage(用于离线降级)
509
  if (tab === "creators" && !isCreatorSearch && !isEmptyOverwrite) {
510
  saveCreatorsToSessionStorage(realData);
511
  }
512
+
513
  state.isFullyLoaded = true;
514
+
515
  // 🛡️ 空响应且本地数据已上屏:保持当前画面,不用空列表清屏(与缓存同口径不回写)
516
  if (isEmptyOverwrite && renderedInstant) {
517
  return;
518
  }
519
+
520
  // 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
521
  // 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
522
  // 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
523
  if (renderedInstant && !_shouldUpdateData(state.allData, realData)) {
524
  return;
525
  }
526
+
527
  // 更新状态
528
  state.allData = realData;
529
+
530
  // 渲染首屏(先过滤再切片:tools/skills/apps/recommends 的 keyword 是本地搜索,
531
  // realData 是未过滤的全量数据,原实现在前 20 条里过滤会漏掉靠后的匹配项)
532
  const scopedReal = _applySearchScope(realData);
533
  renderBatch(scopedReal.slice(0, pageSize), false);
534
+
535
  // 启动分页加载器
536
  if (scopedReal.length > pageSize) {
537
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
538
  }
539
+
540
  } catch (error) {
541
  // 🔍 防竞态:本次加载已被更新的加载取代(切Tab、换排序、继续输入关键词)时直接退出。
542
  // renderBatch 内部本来就有这道守卫,但它之外的降级分支会直接写 contentArea.innerHTML
 
551
  console.log(`⏹️ ${tab}_${sort} 搜索请求已被新关键词取代`);
552
  return;
553
  }
554
+ console.error("数据加载失败");
555
+
556
  // 🚀 缓存先行场景:首屏已经用本地数据上屏,网络失败时保持当前画面即可,不再走下面的
557
  // 降级分支(否则会把同一批数据重渲染一遍、重播卡片动画)。与上方后台静默刷新失败一致:
558
  // 只记录日志,不额外打扰用户
559
  if (renderedInstant) {
560
  console.warn(`📴 ${tab}_${sort} 网络刷新失败,保持已渲染的本地数据`);
561
+ if (force) showToast(t('feedback.refresh_failed_cached'), 'warning');
562
  return;
563
  }
564
+
565
  // 🚀 回退到任何可用缓存(包括过期的)
566
  // 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
567
  // 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
 
572
  // 带 keyword 时会进来;renderBatch 不再自行过滤,此处必须显式过滤
573
  const scopedCached = _applySearchScope(cachedData);
574
  renderBatch(scopedCached.slice(0, pageSize), false);
575
+
576
  // 如果是过期缓存,显示提示
577
  // 🔧 改用统一提示组件 + 词典(原为手写 div、无动画不排队,且文案硬编码中文)
578
  if (isCacheExpired) {
 
580
  }
581
  return;
582
  }
583
+
584
  // 🚀 创作者Tab:尝试从 sessionStorage 或内存中降级
585
  if (tab === "creators") {
586
  let fallbackData = null;
587
+
588
  // 1. 优先从当前内存状态获取
589
+ if (state.allData && state.allData.length > 0 &&
590
+ (!state.isSearchResult || state.searchKeyword === keyword)) {
591
  fallbackData = state.allData;
592
  }
593
  // 2. 从本地列表缓存获取(含过期缓存,搜索结果不入库故不会污染)
 
602
  if (!fallbackData) {
603
  fallbackData = findExistingTabData("creators");
604
  }
605
+
606
  if (fallbackData && fallbackData.length > 0) {
607
  let displayData = fallbackData;
608
+
609
  // 搜索场景:执行本地搜索
610
  if (keyword) {
611
  displayData = searchCreatorsLocally(keyword, fallbackData);
612
  if (displayData.length === 0) {
613
+ const emptyState = document.createElement('div');
614
+ emptyState.style.cssText = 'text-align:center; padding:40px 20px; color:#888;';
615
+ emptyState.textContent = t('feedback.no_search_results');
616
+ emptyState.setAttribute('role', 'status');
617
+ contentArea.replaceChildren(emptyState);
618
+
619
  // 🔧 改用统一提示组件 + 词典(原为手写 div 且文案硬编码中文)
620
  showToast(t('feedback.search_local_result'), 'warning');
621
  return;
622
  }
623
  }
624
+
625
  // 按当前排序排序
626
  displayData = sortDataLocally(displayData, tab, sort);
627
  state.allData = displayData;
 
629
  // searchCreatorsLocally 过滤),保证各渲染入口口径完全一致
630
  const scopedFallback = _applySearchScope(displayData);
631
  renderBatch(scopedFallback.slice(0, pageSize), false);
632
+
633
  // 启动分页加载器
634
  if (scopedFallback.length > pageSize) {
635
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
636
  }
637
+
638
  // 🔧 改用统一提示组件 + 词典(原为手写 div 且两条文案均硬编码中文)
639
  showToast(keyword ? t('feedback.search_local_result') : t('feedback.cache_fallback'), 'warning');
640
  return;
641
  } else if (keyword) {
642
+ renderListLoadError(contentArea, t('common.network_error_retry'), () => loadSidebarContent({
643
+ tab, sort, keyword, contentArea, currentUser, renderToken, getRenderToken,
644
+ force: true, expandItemId, isNavigationCurrent
645
+ }), t);
 
646
  return;
647
  }
648
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
 
650
+ // 无本地数据时提供原位重试,保留当前 ComfyUI 页面与榜单上下文。
651
+ renderListLoadError(contentArea, t('common.network_error_retry'), () => loadSidebarContent({
652
+ tab, sort, keyword, contentArea, currentUser, renderToken, getRenderToken,
653
+ force: true, expandItemId, isNavigationCurrent
654
+ }), t);
 
 
 
655
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  }
657
 
658
 
 
680
  function _shouldUpdateData(oldData, newData) {
681
  if (!oldData || !newData) return true;
682
  if (oldData.length !== newData.length) return true;
683
+
684
  // 检查ID顺序是否一致(检测排序变化、新增/删除)
685
  // 比对条数覆盖首屏渲染量(PAGE_SIZE),避免首屏可见卡片(第 11~20 条)的版本/数字更新被漏检
686
  const checkCount = Math.min(PAGE_SIZE, oldData.length);
 
688
  const oldId = oldData[i].id || oldData[i].account;
689
  const newId = newData[i].id || newData[i].account;
690
  if (oldId !== newId) return true;
691
+
692
  // 检查关键数字字段
693
  const keyFields = ['likes', 'downloads', 'uses', 'views', 'daily_views', 'recent_tips', 'favorites', 'rating_avg', 'rating_count'];
694
  for (const field of keyFields) {
前端页面/core/全局配置.js CHANGED
@@ -18,22 +18,28 @@
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string }} */
22
  export const API = {
 
 
 
 
23
  // 云端 API 基础地址
24
  BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
25
 
26
  // 请求超时时间(毫秒)
27
  TIMEOUT: 30000,
28
-
 
 
29
  // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
30
  // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
31
  // 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
32
  MAX_RETRIES: 2,
33
-
34
  // 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
35
  RETRY_DELAY: 1000,
36
-
37
  // 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
38
  // 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
39
  // 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
@@ -43,6 +49,7 @@ export const API = {
43
  // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
44
  SEARCH_TIMEOUT: 10000,
45
  SEARCH_RETRIES: 1,
 
46
 
47
  // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
48
  // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
@@ -50,6 +57,7 @@ export const API = {
50
  // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
51
  // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
52
  BACKGROUND_TIMEOUT: 10000,
 
53
  BACKGROUND_RETRIES: 0,
54
 
55
  // ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
@@ -59,15 +67,48 @@ export const API = {
59
  // 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
60
  // 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
61
  CLOUD_DOWN_COOLDOWN: 60 * 1000,
62
-
63
  // 🔍 创作者搜索请求的取消分组 ID
64
  // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
65
  // 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
66
  // 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
67
- SEARCH_COMPONENT_ID: "sidebar-search"
 
 
 
 
 
 
 
 
68
  };
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  // ==========================================
72
  // 🔐 管理员配置 (P0安全修复:管理员账号配置化)
73
  // ==========================================
@@ -88,6 +129,9 @@ export function isAdmin(account) {
88
  // ==========================================
89
  // 💾 缓存配置
90
  // ==========================================
 
 
 
91
  export const CACHE = {
92
  // 缓存键前缀
93
  PREFIX: "ComfyRanking_",
@@ -107,8 +151,14 @@ export const CACHE = {
107
 
108
  // 内存缓存最大条目数
109
  MAX_MEMORY_ITEMS: 100,
 
 
 
110
 
111
  // localStorage 键名(旧版兼容)
 
 
 
112
  LEGACY_KEYS: {
113
  USER: "ComfyCommunity_User",
114
  TOKEN: "ComfyCommunity_Token",
@@ -119,9 +169,14 @@ export const CACHE = {
119
 
120
  // 本地背景存储键(不上传云端)
121
  LOCAL_KEYS: {
 
 
122
  SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
123
  PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
124
  ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
 
 
 
125
  // 🚀 新增:私信和通知本地缓存
126
  NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
127
  CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
@@ -130,6 +185,12 @@ export const CACHE = {
130
  CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
131
  },
132
 
 
 
 
 
 
 
133
  // 🚀 新增:消息轮询配置
134
  MESSAGE_POLL: {
135
  INTERVAL: 30 * 1000, // 轮询间隔:30秒
@@ -147,12 +208,18 @@ export const PAGINATION = {
147
 
148
  // 创作者每页数量
149
  CREATORS_PAGE_SIZE: 12,
 
 
 
 
 
150
 
151
  // 评论每页数量
152
  COMMENTS_PAGE_SIZE: 10,
153
 
154
  // 消息每页数量
155
  MESSAGES_PAGE_SIZE: 20,
 
156
 
157
  // 滚动加载触发阈值(距底部像素)
158
  SCROLL_THRESHOLD: 300,
@@ -206,6 +273,19 @@ export const TIP_LEVELS = {
206
  // 🎨 UI 配置
207
  // ==========================================
208
  export const UI = {
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  // Toast 消息配置
210
  TOAST: {
211
  DURATION: 3000, // 显示时长(毫秒)
@@ -390,7 +470,7 @@ export function getCurrentAccount() {
390
  return userData?.user?.account || null;
391
  }
392
  } catch (e) {
393
- console.warn("获取当前账号失败:", e);
394
  }
395
  return null;
396
  }
@@ -456,8 +536,9 @@ export function getTipLevelConfig() {
456
  // 缓存键:ComfyCommunity_ProfileCache_{account}
457
 
458
  const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
459
- const _pendingProfileRequests = new Map(); // 防止 N+1 并发请求
460
  const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
 
461
 
462
  // ==========================================
463
  // 🔒 HTML 转义(底层公共工具)
@@ -488,6 +569,19 @@ function _mapAvatarField(profile) {
488
  }
489
  }
490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  /**
492
  * 🚀 获取缓存的用户资料(同步)
493
  * @param {string} account - 用户账号
@@ -499,8 +593,14 @@ export function getCachedProfile(account) {
499
  const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
500
  if (!cached) return null;
501
  const profile = JSON.parse(cached);
502
- _mapAvatarField(profile);
503
- return profile;
 
 
 
 
 
 
504
  } catch {
505
  return null;
506
  }
@@ -512,9 +612,9 @@ export function getCachedProfile(account) {
512
  * @param {Object} profile - 用户资料
513
  */
514
  export function setCachedProfile(account, profile) {
515
- if (!account || !profile) return;
516
  try {
517
- localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(profile));
518
  } catch {}
519
  }
520
 
@@ -531,27 +631,32 @@ export function getProfileWithSWR(account, apiFn, onUpdate) {
531
  // 第一轨:同步返回缓存
532
  const cached = getCachedProfile(account);
533
 
534
- // 第二轨:异步静默校对(防止 N+1 并发)
535
- if (!_pendingProfileRequests.has(account)) {
536
- const promise = apiFn(account).then(res => {
537
- const profile = res.data || res;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  _mapAvatarField(profile);
539
  setCachedProfile(account, profile);
540
- if (onUpdate && typeof onUpdate === 'function') {
541
- onUpdate(profile);
542
- }
543
- return profile;
544
- }).catch(() => null).finally(() => {
545
- _pendingProfileRequests.delete(account);
546
- });
547
- _pendingProfileRequests.set(account, promise);
548
- // ⏱️ 超时自动清理:防止请求永不完成导致内存泄漏
549
- setTimeout(() => {
550
- if (_pendingProfileRequests.has(account)) {
551
- _pendingProfileRequests.delete(account);
552
  }
553
- }, PENDING_TIMEOUT);
554
  }
 
555
 
556
  return cached;
557
  }
@@ -569,6 +674,9 @@ export function getProfileWithSWR(account, apiFn, onUpdate) {
569
  */
570
  export function renderAvatarWithSWR(options) {
571
  const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
 
 
 
572
 
573
  // 从缓存获取
574
  const cached = getCachedProfile(account);
@@ -578,15 +686,28 @@ export function renderAvatarWithSWR(options) {
578
  // 后台静默校对并更新 DOM
579
  if (apiFn && containerId) {
580
  setTimeout(() => {
 
 
581
  getProfileWithSWR(account, apiFn, (profile) => {
582
- const container = document.getElementById(containerId);
583
- if (!container) return;
584
 
585
  const avatarImg = container.querySelector('.swr-avatar');
586
  const nameSpan = container.querySelector('.swr-name');
587
 
588
  if (avatarImg && profile.avatar) {
589
- avatarImg.src = profile.avatar;
 
 
 
 
 
 
 
 
 
 
 
590
  }
591
  if (nameSpan && profile.name) {
592
  nameSpan.textContent = profile.name;
@@ -598,12 +719,12 @@ export function renderAvatarWithSWR(options) {
598
  // 生成初始 HTML(无头像时显示首字母背景)
599
  // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
600
  // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
601
- const initial = (name || account || 'U')[0].toUpperCase();
602
  const avatarHtml = avatar
603
- ? `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
604
- : `<div class="swr-avatar" style="width: ${avatarSize}px; height: ${avatarSize}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(avatarSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${escapeHtml(initial)}</div>`;
605
 
606
- return `<span id="${escapeHtml(containerId)}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
607
  }
608
 
609
 
@@ -611,6 +732,7 @@ export function renderAvatarWithSWR(options) {
611
  // 📦 导出默认配置对象
612
  // ==========================================
613
  export default {
 
614
  API,
615
  CACHE,
616
  PAGINATION,
@@ -632,3 +754,10 @@ export default {
632
  getProfileWithSWR,
633
  renderAvatarWithSWR
634
  };
 
 
 
 
 
 
 
 
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp }} */
22
  export const API = {
23
+ // 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
24
+ PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
25
+ CONDITIONAL_ETAG_PATTERN: /^W\/"rk-[a-f0-9]{64}"$/,
26
+
27
  // 云端 API 基础地址
28
  BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
29
 
30
  // 请求超时时间(毫秒)
31
  TIMEOUT: 30000,
32
+ VIDEO_UPLOAD_TIMEOUT: 300000,
33
+ VIDEO_UPLOAD_RESPONSE_MAX_CHARS: 65536,
34
+
35
  // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
36
  // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
37
  // 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
38
  MAX_RETRIES: 2,
39
+
40
  // 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
41
  RETRY_DELAY: 1000,
42
+
43
  // 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
44
  // 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
45
  // 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
 
49
  // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
50
  SEARCH_TIMEOUT: 10000,
51
  SEARCH_RETRIES: 1,
52
+ PROFILE_RESOURCE_TIMEOUT: 5000,
53
 
54
  // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
55
  // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
 
57
  // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
58
  // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
59
  BACKGROUND_TIMEOUT: 10000,
60
+ WORKFLOW_DOWNLOAD_TIMEOUT: 120000, // 工作流流式下载预算(2 分钟)
61
  BACKGROUND_RETRIES: 0,
62
 
63
  // ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
 
67
  // 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
68
  // 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
69
  CLOUD_DOWN_COOLDOWN: 60 * 1000,
70
+
71
  // 🔍 创作者搜索请求的取消分组 ID
72
  // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
73
  // 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
74
  // 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
75
+ SEARCH_COMPONENT_ID: "sidebar-search",
76
+ // 上传相对路径的云端图片读取入口。
77
+ IMAGE_PROXY_PATH: "/api/image_proxy",
78
+ // 身份相关或私有 GET 禁止通用响应缓存;公开列表仍使用原缓存策略。
79
+ PRIVATE_GET_PATTERNS: [
80
+ /^\/api\/(wallet|messages|chats|admin|disputes|users)(?:\/|$)/,
81
+ /^\/api\/my-(posts|prompts|tasks)(?:\/|$)/,
82
+ /^\/api\/(tasks|prompts)\/[^/]+$/
83
+ ]
84
  };
85
 
86
 
87
+ // 发布视频的本地读取预算,不改变云端上传额度。
88
+ export const PUBLISH_MEDIA_LIMITS = Object.freeze({
89
+ MAX_IMAGES: 9,
90
+ IMAGE_MAX_EDGE: 1920,
91
+ IMAGE_QUALITY: 0.85,
92
+ VIDEO_MAX_BYTES: 100 * 1024 * 1024,
93
+ VIDEO_MAX_SECONDS: 180,
94
+ VIDEO_METADATA_TIMEOUT: 15000
95
+ });
96
+
97
+ // 上传前处理预算;浏览器解码本身仍由宿主管理。
98
+ export const IMAGE_PROCESS = {
99
+ HEADER_BYTES: 256 * 1024,
100
+ MAX_INPUT_BYTES: 50 * 1024 * 1024,
101
+ MAX_PIXELS: 40 * 1024 * 1024,
102
+ MAX_SOURCE_EDGE: 16384,
103
+ MAX_OUTPUT_EDGE: 4096,
104
+ MAX_OUTPUT_BYTES: 5 * 1024 * 1024,
105
+ TIMEOUT: 15000,
106
+ MIN_QUALITY: 0.3,
107
+ QUALITY_STEP: 0.1,
108
+ AVATAR_MAX_BYTES: 2 * 1024 * 1024,
109
+ AVATAR_SIZE: 512
110
+ };
111
+
112
  // ==========================================
113
  // 🔐 管理员配置 (P0安全修复:管理员账号配置化)
114
  // ==========================================
 
129
  // ==========================================
130
  // 💾 缓存配置
131
  // ==========================================
132
+ const RESOURCE_LIST_PREFIXES = [
133
+ "ListCache_tools_", "ListCache_skills_", "ListCache_apps_", "ListCache_recommends_"
134
+ ];
135
  export const CACHE = {
136
  // 缓存键前缀
137
  PREFIX: "ComfyRanking_",
 
151
 
152
  // 内存缓存最大条目数
153
  MAX_MEMORY_ITEMS: 100,
154
+
155
+ // 本插件 localStorage 持久缓存的估算容量上限(不影响用户/会话等其它键)
156
+ MAX_STORAGE_BYTES: 2 * 1024 * 1024,
157
 
158
  // localStorage 键名(旧版兼容)
159
+ SESSION_REMEMBER_TTL: 30 * 24 * 60 * 60 * 1000,
160
+ INSTALLED_VERSION_UNCONFIRMED: "__installed_unconfirmed__", // 安装成功但没有实际版本证明
161
+ SESSION_TEMP_TTL: 24 * 60 * 60 * 1000,
162
  LEGACY_KEYS: {
163
  USER: "ComfyCommunity_User",
164
  TOKEN: "ComfyCommunity_Token",
 
169
 
170
  // 本地背景存储键(不上传云端)
171
  LOCAL_KEYS: {
172
+ BANNER_CONFIG: "ComfyRanking_BannerConfig", // 广告配置持久缓存
173
+ CREATORS_SESSION: "ComfyRanking_CreatorsCache", // 沿用创作者临时缓存键
174
  SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
175
  PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
176
  ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
177
+ PROFILE_LIST_PREFIX: "ProfileList_", // 兼容旧个人列表业务键
178
+ INSTALLED_VERSION_PREFIX: "ComfyCommunity_LocalVer_", // 沿用既有安装版本键
179
+ ITEM_OWNERSHIP_PREFIX: "ItemOwnership_", // 缓存管理器业务键前缀
180
  // 🚀 新增:私信和通知本地缓存
181
  NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
182
  CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
 
185
  CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
186
  },
187
 
188
+ REFUND_CACHE_PREFIXES: [
189
+ "api_/api/items", "api_/api/creators", "api_/api/wallet",
190
+ ...RESOURCE_LIST_PREFIXES
191
+ ],
192
+ RESOURCE_LIST_PREFIXES,
193
+
194
  // 🚀 新增:消息轮询配置
195
  MESSAGE_POLL: {
196
  INTERVAL: 30 * 1000, // 轮询间隔:30秒
 
208
 
209
  // 创作者每页数量
210
  CREATORS_PAGE_SIZE: 12,
211
+ SIDEBAR_ITEM_FETCH_LIMIT: 200,
212
+ SIDEBAR_CREATOR_FETCH_LIMIT: 500,
213
+ SIDEBAR_CREATOR_SEARCH_LIMIT: 50,
214
+ PROFILE_USERS_PAGE_SIZE: 20,
215
+ PROFILE_RESOURCE_FETCH_LIMIT: 200,
216
 
217
  // 评论每页数量
218
  COMMENTS_PAGE_SIZE: 10,
219
 
220
  // 消息每页数量
221
  MESSAGES_PAGE_SIZE: 20,
222
+ CHAT_MESSAGES_PAGE_SIZE: 50,
223
 
224
  // 滚动加载触发阈值(距底部像素)
225
  SCROLL_THRESHOLD: 300,
 
273
  // 🎨 UI 配置
274
  // ==========================================
275
  export const UI = {
276
+ CHAT_MAX_MSG_LENGTH: 1000,
277
+ CHAT_TIME_GAP_SECONDS: 5 * 60,
278
+ MAX_DISPUTE_EVIDENCE: 6,
279
+ RECHARGE_POLL_INTERVAL: 3000,
280
+ RECHARGE_POLL_LIMIT: 100,
281
+ RECHARGE_QR_MAX_BYTES: 2048,
282
+ PROFILE_BANNER_CACHE_BYTES: 512 * 1024,
283
+ // 与云端公开版本接口的缺省值一致;用于网络不可用时的本地展示。
284
+ DEFAULT_PROJECT_VERSION: Object.freeze({ stage: "beta", major: 2, minor: 0, patch: 0 }),
285
+ ABOUT: {
286
+ AUTHOR_URL: "https://space.bilibili.com/2114638644",
287
+ AUTHOR_BADGE_URL: "https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white"
288
+ },
289
  // Toast 消息配置
290
  TOAST: {
291
  DURATION: 3000, // 显示时长(毫秒)
 
470
  return userData?.user?.account || null;
471
  }
472
  } catch (e) {
473
+ console.warn("获取当前账号失败。");
474
  }
475
  return null;
476
  }
 
536
  // 缓存键:ComfyCommunity_ProfileCache_{account}
537
 
538
  const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
539
+ const _pendingProfileRequests = new Map(); // 每账号共享请求与全部订阅者
540
  const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
541
+ let nextAvatarRenderId = 0;
542
 
543
  // ==========================================
544
  // 🔒 HTML 转义(底层公共工具)
 
569
  }
570
  }
571
 
572
+ const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "intro", "gender", "age",
573
+ "country", "region", "created_at", "followers", "following", "privacy", "is_admin", "bannerUrl",
574
+ "receivedLikes", "receivedFavorites", "receivedUses"];
575
+ function _publicProfile(profile) {
576
+ return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
577
+ .map(key => [key, profile[key]]));
578
+ }
579
+
580
+ function _validProfile(profile) {
581
+ return !!profile && typeof profile === 'object' && !Array.isArray(profile)
582
+ && ['name', 'avatar', 'avatarDataUrl'].every(field => profile[field] == null || typeof profile[field] === 'string');
583
+ }
584
+
585
  /**
586
  * 🚀 获取缓存的用户资料(同步)
587
  * @param {string} account - 用户账号
 
593
  const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
594
  if (!cached) return null;
595
  const profile = JSON.parse(cached);
596
+ if (!_validProfile(profile)) return null;
597
+ const publicProfile = _publicProfile(profile);
598
+ _mapAvatarField(publicProfile);
599
+ // 旧资料缓存可能含联系方式;读取返回过滤副本并尝试迁移,不因迁移失败隐藏公开内容。
600
+ if (JSON.stringify(publicProfile) !== cached) {
601
+ try { localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(publicProfile)); } catch (_) {}
602
+ }
603
+ return publicProfile;
604
  } catch {
605
  return null;
606
  }
 
612
  * @param {Object} profile - 用户资料
613
  */
614
  export function setCachedProfile(account, profile) {
615
+ if (!account || !_validProfile(profile)) return;
616
  try {
617
+ localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(_publicProfile(profile)));
618
  } catch {}
619
  }
620
 
 
631
  // 第一轨:同步返回缓存
632
  const cached = getCachedProfile(account);
633
 
634
+ // 第二轨:同账号只取一次,但每个视图都订阅结果。
635
+ let entry = _pendingProfileRequests.get(account);
636
+ if (!entry && typeof apiFn === 'function') {
637
+ entry = { listeners: new Set(), timer: null };
638
+ _pendingProfileRequests.set(account, entry);
639
+ const release = () => {
640
+ if (_pendingProfileRequests.get(account) === entry) _pendingProfileRequests.delete(account);
641
+ clearTimeout(entry.timer);
642
+ entry.listeners.clear();
643
+ };
644
+ entry.timer = setTimeout(release, PENDING_TIMEOUT);
645
+ Promise.resolve().then(() => apiFn(account)).then(res => {
646
+ // 超时后的旧结果不能覆盖新请求,也不能重新写入旧资料。
647
+ if (_pendingProfileRequests.get(account) !== entry) return;
648
+ if (res?.status && res.status !== 'success') return;
649
+ const data = res && Object.hasOwn(res, 'data') ? res.data : res;
650
+ if (!_validProfile(data)) return;
651
+ const profile = _publicProfile(data);
652
  _mapAvatarField(profile);
653
  setCachedProfile(account, profile);
654
+ for (const listener of Array.from(entry.listeners)) {
655
+ try { listener(profile); } catch { /* 单个视图异常不阻断其它订阅者 */ }
 
 
 
 
 
 
 
 
 
 
656
  }
657
+ }).catch(() => {}).finally(release);
658
  }
659
+ if (entry && typeof onUpdate === 'function') entry.listeners.add(onUpdate);
660
 
661
  return cached;
662
  }
 
674
  */
675
  export function renderAvatarWithSWR(options) {
676
  const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
677
+ const renderId = String(++nextAvatarRenderId);
678
+ const size = typeof avatarSize === 'number' || typeof avatarSize === 'string' ? Number(avatarSize) : NaN;
679
+ const safeSize = Number.isFinite(size) && size > 0 ? size : 24;
680
 
681
  // 从缓存获取
682
  const cached = getCachedProfile(account);
 
686
  // 后台静默校对并更新 DOM
687
  if (apiFn && containerId) {
688
  setTimeout(() => {
689
+ const container = document.getElementById(containerId);
690
+ if (!container || container.dataset.profileRender !== renderId) return;
691
  getProfileWithSWR(account, apiFn, (profile) => {
692
+ if (!container.isConnected || document.getElementById(containerId) !== container
693
+ || container.dataset.profileRender !== renderId) return;
694
 
695
  const avatarImg = container.querySelector('.swr-avatar');
696
  const nameSpan = container.querySelector('.swr-name');
697
 
698
  if (avatarImg && profile.avatar) {
699
+ if (avatarImg.tagName === 'IMG') avatarImg.src = profile.avatar;
700
+ else {
701
+ const image = document.createElement('img');
702
+ image.className = 'swr-avatar';
703
+ image.alt = '';
704
+ image.style.cssText = avatarImg.style.cssText;
705
+ image.style.objectFit = 'cover';
706
+ image.src = profile.avatar;
707
+ avatarImg.replaceWith(image);
708
+ }
709
+ } else if (avatarImg && avatarImg.tagName !== 'IMG' && profile.name) {
710
+ avatarImg.textContent = Array.from(profile.name)[0]?.toUpperCase() || 'U';
711
  }
712
  if (nameSpan && profile.name) {
713
  nameSpan.textContent = profile.name;
 
719
  // 生成初始 HTML(无头像时显示首字母背景)
720
  // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
721
  // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
722
+ const initial = Array.from(String(name || account || 'U'))[0].toUpperCase();
723
  const avatarHtml = avatar
724
+ ? `<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;">`
725
+ : `<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>`;
726
 
727
+ 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>`;
728
  }
729
 
730
 
 
732
  // 📦 导出默认配置对象
733
  // ==========================================
734
  export default {
735
+ IMAGE_PROCESS,
736
  API,
737
  CACHE,
738
  PAGINATION,
 
754
  getProfileWithSWR,
755
  renderAvatarWithSWR
756
  };
757
+
758
+
759
+ // 提示词定价输入边界,与云端 PromptCreate/PromptUpdate 对应。
760
+ export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
761
+
762
+ // 任务定价边界,与云端 task_publication/task_terms 对应。
763
+ export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]) });
前端页面/core/榜单分页控制.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 侧边栏分页控制:滚动加载与无滚动容器时的手动降级。
2
+ import { createPaginationLoader } from "../components/性能优化工具.js";
3
+ import { filterItemsByKeyword } from "./榜单本地查询.js";
4
+ import { t } from "../components/用户体验增强.js";
5
+
6
+ // ==========================================
7
+ // 📜 设置分页加载器
8
+ // ==========================================
9
+ export function setupSidebarPagination(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken) {
10
+ if (renderToken !== getRenderToken()) return;
11
+ // 安全清理旧分页器,防止状态残留和重复事件监听
12
+ if (state.loader) {
13
+ state.loader.stop();
14
+ }
15
+ state.loader = null;
16
+
17
+ // 获取滚动容器(侧边栏主容器)
18
+ const scrollContainer = contentArea.closest(".sidebar-scroll-container") || contentArea.parentElement;
19
+
20
+ if (!scrollContainer) {
21
+ console.warn("⚠️ 未找到滚动容器,分页加载无法启动");
22
+
23
+ // 添加手动加载按钮作为 fallback
24
+ let nextPage = 2;
25
+ let stopped = false;
26
+ const isCurrent = () => !stopped && renderToken === getRenderToken();
27
+ const loadMoreBtn = document.createElement('button');
28
+ loadMoreBtn.textContent = t('task.load_more');
29
+ loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
30
+ state.loader = { stop() {
31
+ stopped = true;
32
+ loadMoreBtn.onclick = null;
33
+ loadMoreBtn.disabled = true;
34
+ } };
35
+ loadMoreBtn.onclick = async () => {
36
+ if (!isCurrent()) return;
37
+ loadMoreBtn.disabled = true;
38
+ loadMoreBtn.textContent = t('common.loading');
39
+ try {
40
+ const result = await loadMoreData(nextPage, pageSize, { isCurrent });
41
+ if (!isCurrent()) return;
42
+ if (result && result.length > 0) {
43
+ nextPage++;
44
+ loadMoreBtn.textContent = t('task.load_more');
45
+ } else {
46
+ loadMoreBtn.textContent = t('common.no_more');
47
+ loadMoreBtn.style.cursor = 'default';
48
+ loadMoreBtn.onclick = null;
49
+ }
50
+ } catch (err) {
51
+ if (!isCurrent()) return;
52
+ console.error("手动加载更多失败");
53
+ loadMoreBtn.textContent = t('task.load_failed');
54
+ }
55
+ loadMoreBtn.disabled = false;
56
+ };
57
+ contentArea.appendChild(loadMoreBtn);
58
+ return;
59
+ }
60
+
61
+ // 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
62
+ // 📌 口径必须与 loadSidebarContent 内的 _applySearchScope 一致(先过滤再计数);
63
+ // 本函数是模块级、拿不到那个闭包,故保留等价实现,改动其一务必同步另一处
64
+ const getTotalDataCount = () => {
65
+ if (!keyword) return state.allData.length;
66
+ // 创作者Tab后端搜索已过滤,直接返回全部数据
67
+ if (tab === "creators") return state.allData.length;
68
+ return filterItemsByKeyword(state.allData, keyword).length;
69
+ };
70
+
71
+ // 创建分页加载器
72
+ const loader = createPaginationLoader({
73
+ container: scrollContainer,
74
+ pageSize: pageSize,
75
+ threshold: 300,
76
+ t,
77
+ loadMore: async (page, size, { isCurrent: isLoaderCurrent = () => true } = {}) => {
78
+ const isCurrent = () => renderToken === getRenderToken() &&
79
+ state.loader === loader && isLoaderCurrent();
80
+ if (!isCurrent()) return [];
81
+ const totalCount = getTotalDataCount();
82
+
83
+ // 如果已显示完所有数据,返回空数组停止加载
84
+ if (state.displayedCount >= totalCount) {
85
+ return [];
86
+ }
87
+
88
+ return loadMoreData(page, size, { isCurrent });
89
+ },
90
+ });
91
+
92
+ state.loader = loader;
93
+ loader.start();
94
+ }
95
+
前端页面/core/榜单分页状态.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 仅管理榜单内存状态与分页器所有权,不读取存储或访问宿主。
2
+ export function createSidebarPaginationStore() {
3
+ const states = new Map();
4
+ return {
5
+ get(tab, sort) {
6
+ const key = `${tab}_${sort}`;
7
+ if (!states.has(key)) {
8
+ states.set(key, {
9
+ allData: [],
10
+ displayedCount: 0,
11
+ loader: null,
12
+ isFullyLoaded: false,
13
+ isSearchResult: false,
14
+ searchKeyword: '',
15
+ });
16
+ }
17
+ return states.get(key);
18
+ },
19
+ findCompleteData(tab) {
20
+ for (const [key, state] of states) {
21
+ if (key.startsWith(`${tab}_`) && state.allData.length > 0 && state.isFullyLoaded) {
22
+ return state.allData;
23
+ }
24
+ }
25
+ return null;
26
+ },
27
+ stopLoaders() {
28
+ let failure;
29
+ for (const state of states.values()) {
30
+ const loader = state.loader;
31
+ state.loader = null;
32
+ try { loader?.stop(); }
33
+ catch (error) { failure ??= error; }
34
+ }
35
+ // 单个停止失败也必须继续清理其它分页器,随后交回调用方处理。
36
+ if (failure) throw failure;
37
+ },
38
+ };
39
+ }
前端页面/core/榜单数据来源.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api, requestCancelManager } from './网络请求API.js';
2
+ import { API, CACHE, PAGINATION } from './全局配置.js';
3
+ import { requireListResponseData, getCacheWithMeta, removeCache } from '../components/性能优化工具.js';
4
+
5
+ const itemTypes = Object.freeze({ tools: 'tool', skills: 'skill', apps: 'app', recommends: 'recommend' });
6
+
7
+ // 只读取与校验,不提交视图状态或缓存;提交时由调用方核查界面代次。
8
+ export async function fetchSidebarList({ tab, sort, keyword = '', force = false, background = false }) {
9
+ const options = background
10
+ ? { noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }
11
+ : (force ? { noCache: true } : {});
12
+ let response;
13
+ if (Object.hasOwn(itemTypes, tab)) {
14
+ response = await api.getItems(itemTypes[tab], sort, PAGINATION.SIDEBAR_ITEM_FETCH_LIMIT, options);
15
+ } else if (tab === 'creators') {
16
+ if (keyword) {
17
+ requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
18
+ response = await api.searchCreators(keyword, sort, PAGINATION.SIDEBAR_CREATOR_SEARCH_LIMIT, options);
19
+ } else {
20
+ response = await api.getCreators(sort, PAGINATION.SIDEBAR_CREATOR_FETCH_LIMIT, options);
21
+ }
22
+ } else {
23
+ throw new TypeError('Unsupported sidebar list');
24
+ }
25
+ return requireListResponseData(response);
26
+ }
27
+
28
+ function validCreatorList(data) {
29
+ try { requireListResponseData({ status: 'success', data }); }
30
+ catch { return false; }
31
+ return data.every(item => ['name', 'account', 'shortDesc'].every(key =>
32
+ item[key] == null || typeof item[key] === 'string'));
33
+ }
34
+
35
+ export function readSidebarListCache(key, { creators = false } = {}) {
36
+ const miss = { found: false, expired: true, value: null };
37
+ let metadata;
38
+ try {
39
+ metadata = getCacheWithMeta(key, true); // 过期副本仍可显示。
40
+ if (!metadata.found) return metadata;
41
+ } catch { return miss; } // 读取失败不代表数据损坏,不删除未知副本。
42
+ try {
43
+ requireListResponseData({ status: 'success', data: metadata.value });
44
+ if (!creators || validCreatorList(metadata.value)) return metadata;
45
+ } catch { /* 确定无效的列表不进入代理、排序或视图状态。 */ }
46
+ try { removeCache(key); }
47
+ catch { /* 删除失败仍按未命中处理,避免损坏内容阻断界面。 */ }
48
+ return miss;
49
+ }
50
+
51
+ export function readCreatorsSessionCache() {
52
+ let raw;
53
+ try { raw = sessionStorage.getItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
54
+ catch { return null; } // 无法读取不等于损坏,保留未知内容。
55
+ if (raw === null) return null;
56
+ try {
57
+ const data = JSON.parse(raw);
58
+ if (validCreatorList(data)) return data;
59
+ } catch { /* 仅已读到的损坏记录可尝试删除。 */ }
60
+ try { sessionStorage.removeItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
61
+ catch { /* 无法删除不阻断界面。 */ }
62
+ return null;
63
+ }
64
+
65
+ export function writeCreatorsSessionCache(data) {
66
+ if (!validCreatorList(data)) return false;
67
+ try {
68
+ sessionStorage.setItem(CACHE.LOCAL_KEYS.CREATORS_SESSION, JSON.stringify(data));
69
+ return true;
70
+ } catch { return false; }
71
+ }
前端页面/core/榜单本地查询.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 本地榜单查询:不依赖 DOM、存储、网络或图片代理。
2
+ // 保留数据引擎原有排序/搜索规则;仅缓存最近一次排序的数组引用。
3
+
4
+ let _lastSortInput = null;
5
+ let _lastSortKey = '';
6
+ let _lastSortResult = null;
7
+
8
+ export function sortDataLocally(data, tab, sort) {
9
+ const sortKey = `${tab}_${sort}`;
10
+ // 如果源数据引用相同且排序参数相同,返回缓存结果(避免重复创建数组拷贝)
11
+ if (data === _lastSortInput && sortKey === _lastSortKey && _lastSortResult) {
12
+ return _lastSortResult;
13
+ }
14
+
15
+ const sorted = [...data]; // 不修改原数组
16
+
17
+ if (tab === "creators") {
18
+ // 创作者排序(与后端 get_creators 一致)
19
+ switch (sort) {
20
+ case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
21
+ case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
22
+ case "downloads": sorted.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)); break;
23
+ case "tips": sorted.sort((a, b) => (b.recent_tips || 0) - (a.recent_tips || 0)); break;
24
+ case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
25
+ case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
26
+ default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
27
+ }
28
+ } else {
29
+ // 工具/Skill/应用/推荐排序(与后端 get_items 一致)
30
+ switch (sort) {
31
+ case "downloads": sorted.sort((a, b) => (b.uses || 0) - (a.uses || 0)); break;
32
+ case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
33
+ case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
34
+ case "tips":
35
+ // 后端使用 tip_history 的当月数据排序
36
+ sorted.sort((a, b) => {
37
+ const now = new Date();
38
+ const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
39
+ const tipA = (a.tip_history || {})[currentMonth] || 0;
40
+ const tipB = (b.tip_history || {})[currentMonth] || 0;
41
+ return tipB - tipA;
42
+ });
43
+ break;
44
+ case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
45
+ case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
46
+ case "rating":
47
+ sorted.sort((a, b) => {
48
+ const ratingA = a.rating_avg || 0;
49
+ const ratingB = b.rating_avg || 0;
50
+ if (ratingA === ratingB) {
51
+ return (b.rating_count || 0) - (a.rating_count || 0);
52
+ }
53
+ return ratingB - ratingA;
54
+ });
55
+ break;
56
+ default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
57
+ }
58
+ }
59
+
60
+ // 缓存本次排序结果
61
+ _lastSortInput = data;
62
+ _lastSortKey = sortKey;
63
+ _lastSortResult = sorted;
64
+ return sorted;
65
+ }
66
+
67
+ /**
68
+ * 按关键词过滤列表数据(工具/应用/推荐Tab本地搜索)
69
+ * @param {Array} items - 数据数组
70
+ * @param {string} keyword - 搜索关键词
71
+ * @returns {Array} 过滤后的数据
72
+ */
73
+ export function filterItemsByKeyword(items, keyword) {
74
+ const lowerKeyword = keyword.toLowerCase();
75
+ return items.filter(item => {
76
+ const textStr = `${item.title||''} ${item.shortDesc||''} ${item.name||''} ${item.account||''}`.toLowerCase();
77
+ return textStr.includes(lowerKeyword);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * 本地搜索创作者数据
83
+ * @param {string} keyword - 搜索关键词
84
+ * @param {Array} data - 创作者数据数组
85
+ * @returns {Array} 匹配的数据
86
+ */
87
+ export function searchCreatorsLocally(keyword, data) {
88
+ const lowerKeyword = keyword.toLowerCase();
89
+ return data.filter(item => {
90
+ const name = (item.name || '').toLowerCase();
91
+ const account = (item.account || '').toLowerCase();
92
+ const shortDesc = (item.shortDesc || '').toLowerCase();
93
+ return name.includes(lowerKeyword) ||
94
+ account.includes(lowerKeyword) ||
95
+ shortDesc.includes(lowerKeyword);
96
+ });
97
+ }
98
+
99
+
前端页面/core/榜单视图切换.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { t } from '../components/用户体验增强.js';
2
+
3
+ function cleanupView(view) {
4
+ try { view._cleanup?.(); }
5
+ catch { console.warn('Sidebar view cleanup failed'); }
6
+ }
7
+
8
+ // 业务视图仍负责自己的请求/事件;此边界负责原容器的挂载与代次。
9
+ export async function mountSidebarDomainView({
10
+ contentArea, currentUser, keyword, renderToken, getRenderToken,
11
+ loadModule, factory, errorKey, afterMount,
12
+ }) {
13
+ const isCurrent = () => renderToken === getRenderToken();
14
+ if (!isCurrent()) return;
15
+ let cleaned = false;
16
+ let view;
17
+ const cleanupOld = () => {
18
+ if (cleaned) return;
19
+ cleaned = true;
20
+ Array.from(contentArea.children).forEach(cleanupView);
21
+ };
22
+ try {
23
+ const module = await loadModule();
24
+ if (!isCurrent()) return;
25
+ cleanupOld();
26
+ if (!isCurrent()) return;
27
+ view = module[factory](currentUser, keyword);
28
+ if (!isCurrent()) {
29
+ cleanupView(view);
30
+ return;
31
+ }
32
+ contentArea.innerHTML = '';
33
+ contentArea.appendChild(view);
34
+ afterMount?.(module);
35
+ } catch {
36
+ if (!isCurrent()) return;
37
+ cleanupOld();
38
+ if (view) cleanupView(view);
39
+ if (!isCurrent()) return;
40
+ // 不输出异常原文,可能包含远端正文、路径或凭据。
41
+ console.error('Sidebar view load failed');
42
+ contentArea.innerHTML = '';
43
+ const error = document.createElement('div');
44
+ error.setAttribute('role', 'alert');
45
+ error.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
46
+ error.textContent = `❌ ${t(errorKey)}`;
47
+ contentArea.appendChild(error);
48
+ }
49
+ }
前端页面/core/状态管理.js CHANGED
@@ -21,9 +21,20 @@
21
  * @typedef {import('./类型定义.js').WalletData} WalletData
22
  */
23
 
 
24
  import { API, CACHE } from "./全局配置.js";
25
  import { clearSensitiveCache, clearAllCache } from "../components/性能优化工具.js";
26
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  // ==========================================
29
  // 📦 状态存储
@@ -124,7 +135,7 @@ const eventBus = {
124
  try {
125
  callback(data);
126
  } catch (error) {
127
- console.error(`事件处理错误 [${event}]:`, error);
128
  }
129
  });
130
  }
@@ -203,28 +214,14 @@ function _createUserState(userData, token) {
203
  /**
204
  * 初始化用户状态(从本地存储恢复)
205
  */
 
 
 
 
206
  export function initUserState() {
207
- try {
208
- // 尝试从 localStorage 恢复
209
- const userStr = localStorage.getItem(CACHE.LEGACY_KEYS.USER);
210
- const token = localStorage.getItem(CACHE.LEGACY_KEYS.TOKEN);
211
-
212
- if (userStr && token) {
213
- const userData = JSON.parse(userStr);
214
- state.user = _createUserState(userData, token);
215
- } else {
216
- // 尝试从 sessionStorage 恢复
217
- const sessionUserStr = sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
218
- const sessionToken = sessionStorage.getItem(CACHE.LEGACY_KEYS.TOKEN);
219
-
220
- if (sessionUserStr && sessionToken) {
221
- const userData = JSON.parse(sessionUserStr);
222
- state.user = _createUserState(userData, sessionToken);
223
- }
224
- }
225
- } catch (error) {
226
- console.warn("用户状态恢复失败:", error);
227
- }
228
  }
229
 
230
  /**
@@ -234,15 +231,14 @@ export function initUserState() {
234
  * @param {boolean} remember - 是否记住登录
235
  */
236
  export function setUser(userData, token, remember = true) {
237
- state.user = _createUserState(userData, token);
238
-
239
- // 持久化存储
240
- const storage = remember ? localStorage : sessionStorage;
241
- storage.setItem(CACHE.LEGACY_KEYS.USER, JSON.stringify(userData));
242
- storage.setItem(CACHE.LEGACY_KEYS.TOKEN, token);
243
-
244
- // 触发事件
245
  eventBus.emit(EVENTS.USER_LOGIN, state.user);
 
246
  }
247
 
248
  /**
@@ -274,20 +270,19 @@ export function getToken() {
274
  * @param {Object} updates - 更新的字段
275
  */
276
  export function updateUser(updates) {
277
- Object.assign(state.user, updates);
278
-
279
- // 更新持久化存储
280
- const userStr = localStorage.getItem(CACHE.LEGACY_KEYS.USER) ||
281
- sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
282
- if (userStr) {
 
283
  try {
284
- const userData = JSON.parse(userStr);
285
- Object.assign(userData, updates);
286
- const storage = localStorage.getItem(CACHE.LEGACY_KEYS.USER) ? localStorage : sessionStorage;
287
- storage.setItem(CACHE.LEGACY_KEYS.USER, JSON.stringify(userData));
288
- } catch {}
289
  }
290
-
291
  eventBus.emit(EVENTS.USER_UPDATE, state.user);
292
  }
293
 
@@ -310,11 +305,16 @@ export function logout() {
310
  // 清除敏感数据内存缓存
311
  clearSensitiveCache();
312
 
313
- // 清除存储
314
- localStorage.removeItem(CACHE.LEGACY_KEYS.USER);
315
- localStorage.removeItem(CACHE.LEGACY_KEYS.TOKEN);
316
- sessionStorage.removeItem(CACHE.LEGACY_KEYS.USER);
317
- sessionStorage.removeItem(CACHE.LEGACY_KEYS.TOKEN);
 
 
 
 
 
318
 
319
  // 清除资源安装版本戳(ComfyCommunity_LocalVer_*)
320
  // 避免切换账号后残留的版本戳导致详情页按钮状态错误
@@ -327,10 +327,12 @@ export function logout() {
327
  }
328
  }
329
  keysToRemove.forEach(key => localStorage.removeItem(key));
330
- } catch (e) {
331
- console.warn('清除版本戳缓存失败:', e);
332
  }
333
 
 
 
334
  eventBus.emit(EVENTS.USER_LOGOUT);
335
  }
336
 
@@ -343,17 +345,21 @@ export function logout() {
343
  export function logoutAndClearUserData(account = null) {
344
  // 清除当前账号的 Profile 级缓存
345
  if (account) {
346
- localStorage.removeItem(`ComfyCommunity_ProfileCache_${account}`);
347
- localStorage.removeItem(`ComfyRanking_SidebarBackground_${account}`);
348
- localStorage.removeItem(`ComfyRanking_ProfileBannerCache_${account}`);
349
  }
350
  // 清除通用用户数据缓存(列表/聊天记录/通知)
351
- localStorage.removeItem("ComfyCommunity_ListCache");
352
- localStorage.removeItem("ComfyCommunity_ChatHistory");
353
- localStorage.removeItem("ComfyRanking_Notifications");
354
- localStorage.removeItem("ComfyRanking_ChatList");
355
  // 清除所有 ComfyRanking_ 前缀的数据缓存与内存敏感数据
356
- clearAllCache();
 
 
 
 
357
  // logout() 内部已含 clearSensitiveCache 与 Token/User/版本戳清理
358
  logout();
359
  }
@@ -465,7 +471,7 @@ export function setNetworkStatus(online) {
465
 
466
  /**
467
  * 检查是否在线
468
- *
469
  * 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
470
  * 云端可达性请一律改问 isCloudReachable()(见下方段落)。
471
  * @returns {boolean}
@@ -505,14 +511,14 @@ export function isCloudReachable() {
505
 
506
  /**
507
  * 标记云端不可达,进入冷却
508
- * @param {string} [reason] - 仅用于日志定位(如失败的 endpoint)
509
  */
510
  export function markCloudDown(reason = '') {
511
  const wasReachable = isCloudReachable();
512
  state.network.cloudDownUntil = Date.now() + API.CLOUD_DOWN_COOLDOWN;
513
  // 只在「可达 → 不可达」的跃迁时打一条日志:冷却期内的重复失败不再刷屏
514
  if (wasReachable) {
515
- console.warn(`☁️ 云端不可达,${API.CLOUD_DOWN_COOLDOWN / 1000} 秒内不再联网${reason ? `(${reason})` : ''}`);
516
  }
517
  }
518
 
 
21
  * @typedef {import('./类型定义.js').WalletData} WalletData
22
  */
23
 
24
+ import { readUserSession, writeUserSession, sessionProfile } from "./用户会话存储.js";
25
  import { API, CACHE } from "./全局配置.js";
26
  import { clearSensitiveCache, clearAllCache } from "../components/性能优化工具.js";
27
 
28
+ function safeRemoveStorageItem(storageKind, key) {
29
+ try {
30
+ const storage = storageKind === 'session' ? window.sessionStorage : window.localStorage;
31
+ storage.removeItem(key);
32
+ return true;
33
+ } catch (_) {
34
+ return false;
35
+ }
36
+ }
37
+
38
 
39
  // ==========================================
40
  // 📦 状态存储
 
135
  try {
136
  callback(data);
137
  } catch (error) {
138
+ console.error('事件处理错误,继续通知其它订阅者。');
139
  }
140
  });
141
  }
 
214
  /**
215
  * 初始化用户状态(从本地存储恢复)
216
  */
217
+ function sessionStorages() {
218
+ return ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
219
+ }
220
+
221
  export function initUserState() {
222
+ const session = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS);
223
+ state.user = session ? _createUserState(session.user, session.token)
224
+ : { isLoggedIn: false, account: null, name: null, avatar: null, token: null, wallet: null };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }
226
 
227
  /**
 
231
  * @param {boolean} remember - 是否记住登录
232
  */
233
  export function setUser(userData, token, remember = true) {
234
+ let profile;
235
+ try {
236
+ profile = writeUserSession(sessionStorages(), CACHE.LEGACY_KEYS, userData, token, remember,
237
+ Date.now() + (remember ? CACHE.SESSION_REMEMBER_TTL : CACHE.SESSION_TEMP_TTL));
238
+ } catch (error) { logout(); throw error; }
239
+ state.user = _createUserState(profile, token);
 
 
240
  eventBus.emit(EVENTS.USER_LOGIN, state.user);
241
+ return profile;
242
  }
243
 
244
  /**
 
270
  * @param {Object} updates - 更新的字段
271
  */
272
  export function updateUser(updates) {
273
+ if (!state.user.isLoggedIn) return;
274
+ const storages = sessionStorages();
275
+ const session = readUserSession(storages, CACHE.LEGACY_KEYS);
276
+ if (!session || session.user.account !== state.user.account || session.token !== state.user.token) return;
277
+ const profile = sessionProfile({ ...session.user, ...updates, account: session.user.account });
278
+ state.user = _createUserState(profile, session.token);
279
+ for (const storage of storages) {
280
  try {
281
+ if (storage?.getItem(CACHE.LEGACY_KEYS.TOKEN) === session.token) {
282
+ storage.setItem(CACHE.LEGACY_KEYS.USER, JSON.stringify({ user: profile, expireAt: session.expireAt }));
283
+ }
284
+ } catch (_) {}
 
285
  }
 
286
  eventBus.emit(EVENTS.USER_UPDATE, state.user);
287
  }
288
 
 
305
  // 清除敏感数据内存缓存
306
  clearSensitiveCache();
307
 
308
+ // 清除存储;即使浏览器禁用了 Storage,也必须完成内存登出与事件广播。
309
+ let storageCleanupFailed = false;
310
+ for (const [storageKind, key] of [
311
+ ['local', CACHE.LEGACY_KEYS.USER],
312
+ ['local', CACHE.LEGACY_KEYS.TOKEN],
313
+ ['session', CACHE.LEGACY_KEYS.USER],
314
+ ['session', CACHE.LEGACY_KEYS.TOKEN]
315
+ ]) {
316
+ if (!safeRemoveStorageItem(storageKind, key)) storageCleanupFailed = true;
317
+ }
318
 
319
  // 清除资源安装版本戳(ComfyCommunity_LocalVer_*)
320
  // 避免切换账号后残留的版本戳导致详情页按钮状态错误
 
327
  }
328
  }
329
  keysToRemove.forEach(key => localStorage.removeItem(key));
330
+ } catch (_) {
331
+ storageCleanupFailed = true;
332
  }
333
 
334
+ if (storageCleanupFailed) console.warn('浏览器存储不可用,部分登出缓存无法清理;内存会话已清除。');
335
+
336
  eventBus.emit(EVENTS.USER_LOGOUT);
337
  }
338
 
 
345
  export function logoutAndClearUserData(account = null) {
346
  // 清除当前账号的 Profile 级缓存
347
  if (account) {
348
+ safeRemoveStorageItem('local', `ComfyCommunity_ProfileCache_${account}`);
349
+ safeRemoveStorageItem('local', `ComfyRanking_SidebarBackground_${account}`);
350
+ safeRemoveStorageItem('local', `ComfyRanking_ProfileBannerCache_${account}`);
351
  }
352
  // 清除通用用户数据缓存(列表/聊天记录/通知)
353
+ safeRemoveStorageItem('local', "ComfyCommunity_ListCache");
354
+ safeRemoveStorageItem('local', "ComfyCommunity_ChatHistory");
355
+ safeRemoveStorageItem('local', "ComfyRanking_Notifications");
356
+ safeRemoveStorageItem('local', "ComfyRanking_ChatList");
357
  // 清除所有 ComfyRanking_ 前缀的数据缓存与内存敏感数据
358
+ try {
359
+ clearAllCache();
360
+ } catch (error) {
361
+ console.warn('清除持久化缓存失败,继续完成内存登出。');
362
+ }
363
  // logout() 内部已含 clearSensitiveCache 与 Token/User/版本戳清理
364
  logout();
365
  }
 
471
 
472
  /**
473
  * 检查是否在线
474
+ *
475
  * 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
476
  * 云端可达性请一律改问 isCloudReachable()(见下方段落)。
477
  * @returns {boolean}
 
511
 
512
  /**
513
  * 标记云端不可达,进入冷却
514
+ * @param {string} [reason] - 保留调用兼容;不输出可能含私有信息的失败原因
515
  */
516
  export function markCloudDown(reason = '') {
517
  const wasReachable = isCloudReachable();
518
  state.network.cloudDownUntil = Date.now() + API.CLOUD_DOWN_COOLDOWN;
519
  // 只在「可达 → 不可达」的跃迁时打一条日志:冷却期内的重复失败不再刷屏
520
  if (wasReachable) {
521
+ console.warn(`☁️ 云端不可达,${API.CLOUD_DOWN_COOLDOWN / 1000} 秒内不再联网`);
522
  }
523
  }
524
 
前端页面/core/用户会话存储.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { normalizeProfileData } from '../profile/个人资料工具.js';
2
+
3
+ /** 持久化只保留资料字段;钱包、凭证和未知字段不能混入用户记录。 */
4
+ export function sessionProfile(value) {
5
+ if (!value || typeof value.account !== 'string' || !value.account.trim()) throw new Error('Invalid user session');
6
+ const profile = normalizeProfileData(value, value.account, true);
7
+ for (const key of ['balance', 'earn_balance', 'tip_balance', 'task_balance', 'frozen_balance', 'total_withdrawn']) delete profile[key];
8
+ return profile;
9
+ }
10
+
11
+ export function readUserSession(storages, keys, now = Date.now()) {
12
+ for (const storage of storages) {
13
+ try {
14
+ const raw = storage?.getItem(keys.USER), token = storage?.getItem(keys.TOKEN);
15
+ if (!raw || typeof token !== 'string' || !token.trim()) continue;
16
+ const saved = JSON.parse(raw);
17
+ if (saved.expireAt !== undefined && (!Number.isFinite(saved.expireAt) || saved.expireAt <= now)) {
18
+ storage.removeItem(keys.USER); storage.removeItem(keys.TOKEN); continue;
19
+ }
20
+ return { user: sessionProfile(saved.user || saved), token, expireAt: saved.expireAt };
21
+ } catch (_) {}
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export function writeUserSession(storages, keys, user, token, remember, expireAt) {
27
+ const profile = sessionProfile(user);
28
+ if (typeof token !== 'string' || !token.trim() || !Number.isFinite(expireAt)) throw new Error('Invalid user session');
29
+ const selected = storages[remember ? 0 : 1];
30
+ // 清除另一个登录方式的旧凭证,再发布完整的新记录。失败不能继续创建混合身份。
31
+ try {
32
+ for (const storage of storages) {
33
+ if (!storage) throw new Error('Unavailable storage');
34
+ storage.removeItem(keys.USER); storage.removeItem(keys.TOKEN);
35
+ }
36
+ selected.setItem(keys.USER, JSON.stringify({ user: profile, expireAt }));
37
+ selected.setItem(keys.TOKEN, token);
38
+ } catch (_) {
39
+ for (const storage of storages) {
40
+ try { storage?.removeItem(keys.USER); storage?.removeItem(keys.TOKEN); } catch (_) {}
41
+ }
42
+ throw new Error('Session storage unavailable');
43
+ }
44
+ return profile;
45
+ }
前端页面/core/网络请求API.js CHANGED
@@ -13,6 +13,6 @@
13
  // readListCache / writeListCache)不在这里,统一从 components/性能优化工具.js 导入。
14
  // ==========================================
15
 
16
- export { request, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
17
  export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
18
  export { api } from './网络请求_业务API.js';
 
13
  // readListCache / writeListCache)不在这里,统一从 components/性能优化工具.js 导入。
14
  // ==========================================
15
 
16
+ export { request, captureRequestSession, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
17
  export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
18
  export { api } from './网络请求_业务API.js';
前端页面/core/网络请求_业务API.js CHANGED
@@ -9,6 +9,14 @@
9
 
10
  import { request } from "./网络请求_基础设施.js";
11
  import { API } from "./全局配置.js";
 
 
 
 
 
 
 
 
12
 
13
  // ============== 业务 API 导出 ==============
14
  const api = {
@@ -42,35 +50,34 @@ const api = {
42
 
43
  // 4. 🚨 终极拦截门:如果到了这一步数据还是没找齐,直接弹窗指认"内鬼"!
44
  if (!payload.account || !payload.new_password || !payload.verifyContact || !payload.code) {
45
- const errorStr = JSON.stringify(payload, null, 2);
46
- console.warn(`🚨 发现前端传参严重丢失!系统拼命搜刮后只拿到了这些数据: ${errorStr} 请立即检查【顶部导航组件.js】的第 82 行!必须把完整的表单数据传给 API,例如: api.resetPassword({account, password, email, code})`);
47
- throw new Error("被前端参数拦截装甲阻断:数据不完整");
48
  }
49
 
50
  return request("/api/users/reset_password", { method: "POST", body: payload });
51
  },
52
 
53
- async getUserProfile(account) { return request(`/api/users/${account}`); },
54
  async updateUserProfile(account, data) { return request(`/api/users/${account}`, { method: "PUT", body: data }); },
55
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
56
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
57
- async getCreators(sort, limit) { return request(`/api/creators?sort=${sort}&limit=${limit}`); },
58
  // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
59
  // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
60
- async searchCreators(keyword, sort, limit = 50) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID }); },
61
  async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
62
  async uploadFile(file, fileType, timeout = 120000) {
63
  const formData = new FormData();
64
  formData.append("file", file);
65
  formData.append("file_type", fileType);
66
- return request("/api/upload", { method: "POST", body: formData, timeout, retries: 2 });
67
  },
68
  async publishItem(data) { return request("/api/items", { method: "POST", body: data }); },
69
  // NOTE: author 未 encodeURIComponent,与 assignee/account 等编码不一致;
70
  // 但 account 通常为纯字母数字,不影响功能,暂不改动以避免破坏线上缓存匹配
71
  async updateItem(itemId, author, data) { return request(`/api/items/${itemId}?author=${author}`, { method: "PUT", body: data }); },
72
- async getItems(type, sort, limit) { return request(`/api/items?type=${type}&sort=${sort}&limit=${limit}`); },
73
- async getItemById(itemId) { return request(`/api/items/${itemId}`); },
74
  async deleteItem(itemId) { return request(`/api/items/${itemId}`, { method: "DELETE" }); },
75
  async recordItemUse(itemId) { return request(`/api/items/${itemId}/use`, { method: "POST" }); },
76
  async recordItemView(itemId) { return request(`/api/items/${itemId}/view`, { method: "POST" }); },
@@ -93,13 +100,13 @@ const api = {
93
  async toggleInteraction(itemId, userId, actionType, isActive) { return request("/api/interactions/toggle", { method: "POST", body: { item_id: itemId, user_id: userId, action_type: actionType, is_active: isActive } }); },
94
  async postComment(itemId, author, content, replyTo = null, parentId = null) { return request("/api/comments", { method: "POST", body: { item_id: itemId, author, content, reply_to_user: replyTo, parent_id: parentId } }); },
95
  async deleteComment(itemId, commentId) { return request(`/api/comments/${itemId}/${commentId}`, { method: "DELETE" }); },
96
- async getMessages(account) { return request(`/api/messages/${account}`); },
97
  // 🔥 新增:轻量级获取未读消息数量(不标记已读,用于轮询红点)
98
  async getUnreadCount(account) { return request(`/api/messages/${account}?count_only=true`); },
99
  async markMessagesRead(account) { return request(`/api/messages/${account}/read`, { method: "POST" }); },
100
  async sendPrivateMessage(sender, receiver, content) { return request("/api/messages/private", { method: "POST", body: { sender, receiver, content } }); },
101
- async getChatList(account) { return request(`/api/chats/${account}`); },
102
- async getChatHistory(account, targetAccount) { return request(`/api/chats/${account}/${targetAccount}`); },
103
  async getWallet(account, options = {}) { return request(`/api/wallet/${account}`, options); },
104
 
105
  // 🛒 获取用户购买记录(从云端 ownerships 表)
@@ -139,33 +146,35 @@ const api = {
139
  body: { sender_account: senderAccount, target_account: targetAccount, amount: amount, is_anonymous: isAnonymous, item_id: itemId }
140
  });
141
  },
142
- async purchaseItem(account, itemId) {
143
  // 🐛 Bug修复:防御性校验,防止 itemId 为空导致请求 /purchase/undefined
144
  if (!itemId) {
145
  console.warn('⚠️ purchaseItem: itemId 为空,已拦截无效请求');
146
  return { success: false, error: 'itemId 不能为空' };
147
  }
148
- return request("/api/wallet/purchase", { method: "POST", body: { account: account, item_id: itemId } });
 
 
149
  },
150
  async submitWithdraw(data) { return request("/api/wallet/withdraw", { method: "POST", body: data }); },
151
 
152
  // ==========================================
153
  // 💬 讨论区 API
154
  // ==========================================
155
- async getPosts(page = 1, limit = 20, sort = "latest") {
156
- return request(`/api/posts?page=${page}&limit=${limit}&sort=${sort}`);
157
  },
158
- async getPostDetail(postId) {
159
- return request(`/api/posts/${postId}`);
160
  },
161
- async createPost(data) {
162
- return request("/api/posts", { method: "POST", body: data });
163
  },
164
  async updatePost(postId, data) {
165
- return request(`/api/posts/${postId}`, { method: "PUT", body: data });
166
  },
167
  async deletePost(postId) {
168
- return request(`/api/posts/${postId}`, { method: "DELETE" });
169
  },
170
  async togglePostLike(postId) {
171
  return request(`/api/posts/${postId}/like`, { method: "POST" });
@@ -176,8 +185,8 @@ const api = {
176
  async tipPost(postId, amount, isAnon = false) {
177
  return request(`/api/posts/${postId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
178
  },
179
- async getPostComments(postId) {
180
- return request(`/api/posts/${postId}/comments`);
181
  },
182
  async addPostComment(postId, content) {
183
  return request(`/api/posts/${postId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
@@ -189,17 +198,17 @@ const api = {
189
  // ==========================================
190
  // ✨ 提示词市场 API
191
  // ==========================================
192
- async getPrompts(promptType = null, category = null, page = 1, limit = 20, sort = "latest") {
193
  let url = `/api/prompts?page=${page}&limit=${limit}&sort=${sort}`;
194
  if (promptType) url += `&prompt_type=${promptType}`;
195
  if (category) url += `&category=${encodeURIComponent(category)}`;
196
- return request(url);
197
  },
198
- async getPromptDetail(promptId) {
199
- return request(`/api/prompts/${promptId}`);
200
  },
201
- async createPrompt(data) {
202
- return request("/api/prompts", { method: "POST", body: data });
203
  },
204
  async updatePrompt(promptId, data) {
205
  return request(`/api/prompts/${promptId}`, { method: "PUT", body: data });
@@ -216,8 +225,8 @@ const api = {
216
  async tipPrompt(promptId, amount, isAnon = false) {
217
  return request(`/api/prompts/${promptId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
218
  },
219
- async getPromptComments(promptId) {
220
- return request(`/api/prompts/${promptId}/comments`);
221
  },
222
  async addPromptComment(promptId, content) {
223
  return request(`/api/prompts/${promptId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
@@ -225,13 +234,15 @@ const api = {
225
  async recordPromptView(promptId) {
226
  return request(`/api/prompts/${promptId}/view`, { method: "POST" });
227
  },
228
- async purchasePrompt(promptId) {
229
  // 🐛 防御性校验,防止 promptId 为空导致请求 /purchase/undefined
230
  if (!promptId) {
231
  console.warn('⚠️ purchasePrompt: promptId 为空,已拦截无效请求');
232
  return { success: false, error: 'promptId 不能为空' };
233
  }
234
- return request(`/api/prompts/${promptId}/purchase`, { method: "POST" });
 
 
235
  },
236
  async getMyPrompts() {
237
  return request("/api/my-prompts");
@@ -248,13 +259,14 @@ const api = {
248
  // ==========================================
249
  // 📝 任务榜 API
250
  // ==========================================
251
- async getTasks(page = 1, limit = 20, status = null, sort = "latest") {
252
  let url = `/api/tasks?page=${page}&limit=${limit}&sort=${sort}`;
253
  if (status) url += `&status=${status}`;
254
- return request(url);
255
  },
256
- async getTaskDetail(taskId) {
257
- return request(`/api/tasks/${taskId}`);
 
258
  },
259
  async createTask(data) {
260
  return request("/api/tasks", { method: "POST", body: data });
@@ -307,8 +319,8 @@ const api = {
307
  async tipTask(taskId, amount, isAnon = false) {
308
  return request(`/api/tasks/${taskId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
309
  },
310
- async getTaskComments(taskId) {
311
- return request(`/api/tasks/${taskId}/comments`);
312
  },
313
  async addTaskComment(taskId, content) {
314
  return request(`/api/tasks/${taskId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
@@ -317,8 +329,8 @@ const api = {
317
  // ==========================================
318
  // ⚖️ 申诉仲裁 API
319
  // ==========================================
320
- async getDisputeDetail(disputeId) {
321
- return request(`/api/disputes/${disputeId}`);
322
  },
323
  async respondDispute(disputeId, response, evidence = null) {
324
  return request(`/api/disputes/${disputeId}/respond`, {
@@ -330,8 +342,8 @@ const api = {
330
  // ==========================================
331
  // 🔄 P7后悔模式:退款API
332
  // ==========================================
333
- async getPurchaseStatus(account, itemId) {
334
- return request(`/api/wallet/${account}/purchase/${itemId}`);
335
  },
336
  async requestRefund(account, itemId) {
337
  return request(`/api/wallet/refund?account=${encodeURIComponent(account)}&item_id=${encodeURIComponent(itemId)}`, {
 
9
 
10
  import { request } from "./网络请求_基础设施.js";
11
  import { API } from "./全局配置.js";
12
+ import { t } from "../components/用户体验_国际化.js";
13
+ import { removeCache } from "./网络请求_缓存管理.js";
14
+
15
+ function publicationHeaders(options) {
16
+ if (options?.publicationId === undefined) return undefined;
17
+ if (typeof options.publicationId !== 'string' || !/^[0-9a-f]{32}$/.test(options.publicationId)) throw new Error('Invalid publication identity');
18
+ return { 'Idempotency-Key': options.publicationId };
19
+ }
20
 
21
  // ============== 业务 API 导出 ==============
22
  const api = {
 
50
 
51
  // 4. 🚨 终极拦截门:如果到了这一步数据还是没找齐,直接弹窗指认"内鬼"!
52
  if (!payload.account || !payload.new_password || !payload.verifyContact || !payload.code) {
53
+ console.warn("密码重置请求缺少必要字段");
54
+ throw new Error(t('auth.form_data_incomplete'));
 
55
  }
56
 
57
  return request("/api/users/reset_password", { method: "POST", body: payload });
58
  },
59
 
60
+ async getUserProfile(account, options = {}) { return request(`/api/users/${account}`, options); },
61
  async updateUserProfile(account, data) { return request(`/api/users/${account}`, { method: "PUT", body: data }); },
62
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
63
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
64
+ async getCreators(sort, limit, options = {}) { return request(`/api/creators?sort=${sort}&limit=${limit}`, options); },
65
  // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
66
  // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
67
+ async searchCreators(keyword, sort, limit = 50, options = {}) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID, ...options }); },
68
  async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
69
  async uploadFile(file, fileType, timeout = 120000) {
70
  const formData = new FormData();
71
  formData.append("file", file);
72
  formData.append("file_type", fileType);
73
+ return request("/api/upload", { method: "POST", body: formData, timeout, retries: 0 });
74
  },
75
  async publishItem(data) { return request("/api/items", { method: "POST", body: data }); },
76
  // NOTE: author 未 encodeURIComponent,与 assignee/account 等编码不一致;
77
  // 但 account 通常为纯字母数字,不影响功能,暂不改动以避免破坏线上缓存匹配
78
  async updateItem(itemId, author, data) { return request(`/api/items/${itemId}?author=${author}`, { method: "PUT", body: data }); },
79
+ async getItems(type, sort, limit, options = {}) { return request(`/api/items?type=${type}&sort=${sort}&limit=${limit}`, options); },
80
+ async getItemById(itemId, options = {}) { return request(`/api/items/${itemId}`, options); },
81
  async deleteItem(itemId) { return request(`/api/items/${itemId}`, { method: "DELETE" }); },
82
  async recordItemUse(itemId) { return request(`/api/items/${itemId}/use`, { method: "POST" }); },
83
  async recordItemView(itemId) { return request(`/api/items/${itemId}/view`, { method: "POST" }); },
 
100
  async toggleInteraction(itemId, userId, actionType, isActive) { return request("/api/interactions/toggle", { method: "POST", body: { item_id: itemId, user_id: userId, action_type: actionType, is_active: isActive } }); },
101
  async postComment(itemId, author, content, replyTo = null, parentId = null) { return request("/api/comments", { method: "POST", body: { item_id: itemId, author, content, reply_to_user: replyTo, parent_id: parentId } }); },
102
  async deleteComment(itemId, commentId) { return request(`/api/comments/${itemId}/${commentId}`, { method: "DELETE" }); },
103
+ async getMessages(account, options = {}) { return request(`/api/messages/${account}`, options); },
104
  // 🔥 新增:轻量级获取未读消息数量(不标记已读,用于轮询红点)
105
  async getUnreadCount(account) { return request(`/api/messages/${account}?count_only=true`); },
106
  async markMessagesRead(account) { return request(`/api/messages/${account}/read`, { method: "POST" }); },
107
  async sendPrivateMessage(sender, receiver, content) { return request("/api/messages/private", { method: "POST", body: { sender, receiver, content } }); },
108
+ async getChatList(account, options = {}) { return request(`/api/chats/${account}`, options); },
109
+ async getChatHistory(account, targetAccount, options = {}) { return request(`/api/chats/${account}/${targetAccount}`, options); },
110
  async getWallet(account, options = {}) { return request(`/api/wallet/${account}`, options); },
111
 
112
  // 🛒 获取用户购买记录(从云端 ownerships 表)
 
146
  body: { sender_account: senderAccount, target_account: targetAccount, amount: amount, is_anonymous: isAnonymous, item_id: itemId }
147
  });
148
  },
149
+ async purchaseItem(account, itemId, expectedPrice) {
150
  // 🐛 Bug修复:防御性校验,防止 itemId 为空导致请求 /purchase/undefined
151
  if (!itemId) {
152
  console.warn('⚠️ purchaseItem: itemId 为空,已拦截无效请求');
153
  return { success: false, error: 'itemId 不能为空' };
154
  }
155
+ const body = { account, item_id: itemId };
156
+ if (expectedPrice !== undefined) body.expected_price = expectedPrice;
157
+ return request("/api/wallet/purchase", { method: "POST", body });
158
  },
159
  async submitWithdraw(data) { return request("/api/wallet/withdraw", { method: "POST", body: data }); },
160
 
161
  // ==========================================
162
  // 💬 讨论区 API
163
  // ==========================================
164
+ async getPosts(page = 1, limit = 20, sort = "latest", options = {}) {
165
+ return request(`/api/posts?page=${page}&limit=${limit}&sort=${sort}`, options);
166
  },
167
+ async getPostDetail(postId, options = {}) {
168
+ return request(`/api/posts/${postId}`, options);
169
  },
170
+ async createPost(data, options = {}) {
171
+ return request("/api/posts", { method: "POST", body: data, headers: publicationHeaders(options) });
172
  },
173
  async updatePost(postId, data) {
174
+ return request(`/api/posts/${postId}`, { method: "PUT", body: data });
175
  },
176
  async deletePost(postId) {
177
+ return request(`/api/posts/${postId}`, { method: "DELETE" });
178
  },
179
  async togglePostLike(postId) {
180
  return request(`/api/posts/${postId}/like`, { method: "POST" });
 
185
  async tipPost(postId, amount, isAnon = false) {
186
  return request(`/api/posts/${postId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
187
  },
188
+ async getPostComments(postId, options = {}) {
189
+ return request(`/api/posts/${postId}/comments`, options);
190
  },
191
  async addPostComment(postId, content) {
192
  return request(`/api/posts/${postId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
 
198
  // ==========================================
199
  // ✨ 提示词市场 API
200
  // ==========================================
201
+ async getPrompts(promptType = null, category = null, page = 1, limit = 20, sort = "latest", options = {}) {
202
  let url = `/api/prompts?page=${page}&limit=${limit}&sort=${sort}`;
203
  if (promptType) url += `&prompt_type=${promptType}`;
204
  if (category) url += `&category=${encodeURIComponent(category)}`;
205
+ return request(url, options);
206
  },
207
+ async getPromptDetail(promptId, options = {}) {
208
+ return request(`/api/prompts/${promptId}`, options);
209
  },
210
+ async createPrompt(data, options = {}) {
211
+ return request("/api/prompts", { method: "POST", body: data, headers: publicationHeaders(options) });
212
  },
213
  async updatePrompt(promptId, data) {
214
  return request(`/api/prompts/${promptId}`, { method: "PUT", body: data });
 
225
  async tipPrompt(promptId, amount, isAnon = false) {
226
  return request(`/api/prompts/${promptId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
227
  },
228
+ async getPromptComments(promptId, options = {}) {
229
+ return request(`/api/prompts/${promptId}/comments`, options);
230
  },
231
  async addPromptComment(promptId, content) {
232
  return request(`/api/prompts/${promptId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
 
234
  async recordPromptView(promptId) {
235
  return request(`/api/prompts/${promptId}/view`, { method: "POST" });
236
  },
237
+ async purchasePrompt(promptId, expectedPrice) {
238
  // 🐛 防御性校验,防止 promptId 为空导致请求 /purchase/undefined
239
  if (!promptId) {
240
  console.warn('⚠️ purchasePrompt: promptId 为空,已拦截无效请求');
241
  return { success: false, error: 'promptId 不能为空' };
242
  }
243
+ const options = { method: "POST" };
244
+ if (expectedPrice !== undefined) options.body = { expected_price: expectedPrice };
245
+ return request(`/api/prompts/${promptId}/purchase`, options);
246
  },
247
  async getMyPrompts() {
248
  return request("/api/my-prompts");
 
259
  // ==========================================
260
  // 📝 任务榜 API
261
  // ==========================================
262
+ async getTasks(page = 1, limit = 20, status = null, sort = "latest", options = {}) {
263
  let url = `/api/tasks?page=${page}&limit=${limit}&sort=${sort}`;
264
  if (status) url += `&status=${status}`;
265
+ return request(url, options);
266
  },
267
+ async getTaskDetail(taskId, options = {}) {
268
+ removeCache(`api_/api/tasks/${taskId}`);
269
+ return request(`/api/tasks/${taskId}`, { ...options, noStore: true });
270
  },
271
  async createTask(data) {
272
  return request("/api/tasks", { method: "POST", body: data });
 
319
  async tipTask(taskId, amount, isAnon = false) {
320
  return request(`/api/tasks/${taskId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
321
  },
322
+ async getTaskComments(taskId, options = {}) {
323
+ return request(`/api/tasks/${taskId}/comments`, options);
324
  },
325
  async addTaskComment(taskId, content) {
326
  return request(`/api/tasks/${taskId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
 
329
  // ==========================================
330
  // ⚖️ 申诉仲裁 API
331
  // ==========================================
332
+ async getDisputeDetail(disputeId, options = {}) {
333
+ return request(`/api/disputes/${disputeId}`, options);
334
  },
335
  async respondDispute(disputeId, response, evidence = null) {
336
  return request(`/api/disputes/${disputeId}/respond`, {
 
342
  // ==========================================
343
  // 🔄 P7后悔模式:退款API
344
  // ==========================================
345
+ async getPurchaseStatus(account, itemId, options = {}) {
346
+ return request(`/api/wallet/${account}/purchase/${itemId}`, options);
347
  },
348
  async requestRefund(account, itemId) {
349
  return request(`/api/wallet/refund?account=${encodeURIComponent(account)}&item_id=${encodeURIComponent(itemId)}`, {
前端页面/core/网络请求_基础设施.js CHANGED
@@ -11,8 +11,8 @@
11
 
12
  import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
  import { API, CACHE } from "./全局配置.js";
14
- import { logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
15
- import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
16
  import { unproxyImages } from "./网络请求_图片代理.js";
17
  // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
18
  // 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
@@ -42,11 +42,64 @@ const pendingRequests = new Map();
42
  // 本地凭证会导致所有登录态操作反复报「Token 签名无效」。去重窗口:并发请求
43
  // 同时收到 401 时只提示一次。
44
  let _authExpiredAt = 0;
45
- function notifyTokenExpired(serverMsg) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  const now = Date.now();
47
  if (now - _authExpiredAt < 5000) return;
48
  _authExpiredAt = now;
49
- console.warn(`⚠️ 登录凭证已失效(${serverMsg}),本地 Token 已清除,请重新登录`);
50
  // 动态 import UI 组件,与 proxyImages 同样避免模块初始化时序问题
51
  import("../components/UI交互提示组件.js").then(async ({ showToast }) => {
52
  const { t } = await import("../components/用户体验_国际化.js");
@@ -54,21 +107,22 @@ function notifyTokenExpired(serverMsg) {
54
  }).catch(() => {});
55
  }
56
 
57
- function handleAuthExpired(serverMsg, failedToken) {
58
  // 竞态防护:若用户在 401 返回前已重新登录(存储中的 Token 已更换),
59
  // 不清理新凭证;失败请求未携带 Token 时 401 与登录态无关,同样不打扰
60
  if (!failedToken) return false;
61
- const currentToken = localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token");
62
  if (currentToken !== failedToken) return false;
63
  // 清除前取出账号,用于清理账号级 Profile 缓存
64
  let account = null;
65
  try {
66
- const userStr = localStorage.getItem("ComfyCommunity_User") || sessionStorage.getItem("ComfyCommunity_User");
 
67
  if (userStr) account = JSON.parse(userStr)?.user?.account || null;
68
  } catch (e) {}
69
  // 与手动登出同深度的完整清理:Token/User、私有数据缓存、内存缓存与安装版本戳
70
  logoutAndClearUserData(account);
71
- notifyTokenExpired(serverMsg);
72
  return true;
73
  }
74
 
@@ -151,7 +205,8 @@ const requestQueue = {
151
  running: 0,
152
  pending: [],
153
 
154
- async add(fn) {
 
155
  // 如果还有槽位,直接执行
156
  if (this.running < this.maxConcurrent) {
157
  this.running++;
@@ -165,13 +220,22 @@ const requestQueue = {
165
 
166
  // 否则加入队列等待
167
  return new Promise((resolve, reject) => {
168
- this.pending.push(async () => {
 
 
 
 
 
 
169
  try {
 
170
  resolve(await fn());
171
  } catch (e) {
172
  reject(e);
173
  }
174
- });
 
 
175
  });
176
  },
177
 
@@ -192,11 +256,27 @@ const requestQueue = {
192
  };
193
 
194
  async function request(endpoint, options = {}) {
 
 
 
 
 
 
 
195
  const url = `${BASE_URL}${endpoint}`;
196
  const method = (options.method || "GET").toUpperCase();
197
  const headers = { ...options.headers };
198
  if (!(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; }
199
- const token = localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token");
 
 
 
 
 
 
 
 
 
200
  if (token) {
201
  const tokenParts = token.split(".");
202
  if (tokenParts.length === 3) {
@@ -208,12 +288,9 @@ async function request(endpoint, options = {}) {
208
  // 🔧 修复:凭证既已清除,同步广播登出事件重置导航登录态/停止轮询,
209
  // 并给用户明确提示(原实现仅 console.warn 静默清理)
210
  console.warn("⚠️ 检测到无效/已过期的旧版 Token,已自动清除,请重新登录");
211
- localStorage.removeItem("ComfyCommunity_Token");
212
- localStorage.removeItem("ComfyCommunity_User");
213
- sessionStorage.removeItem("ComfyCommunity_Token");
214
- sessionStorage.removeItem("ComfyCommunity_User");
215
  window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
216
- notifyTokenExpired("无效/已过期的旧版 Token");
217
  }
218
  }
219
 
@@ -224,6 +301,7 @@ async function request(endpoint, options = {}) {
224
  // 🔑 P0修复:cacheKey 必须包含完整 endpoint(含查询参数),不能截断 '?' 之后的部分
225
  // 例:'/api/items?type=tool&sort=time' 和 '/api/items?type=tool&sort=views' 必须产生不同的缓存 Key
226
  const cacheKey = `api_${endpoint}`;
 
227
  if (method === "GET" && !options.noCache) {
228
  const cached = getCache(cacheKey);
229
  if (cached) {
@@ -235,19 +313,29 @@ async function request(endpoint, options = {}) {
235
  // 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
236
  // ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
237
  // ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
238
- const fallbackMeta = (method === "GET")
239
  ? getCacheWithMeta(cacheKey, true) // 忽略过期
240
  : { value: null, expired: false, found: false };
241
-
 
 
 
 
 
 
 
 
242
  // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
243
  // ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
244
  // 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
245
  // 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
246
  // 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
247
  // 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
 
 
248
  if (method === "GET" && (!isOnline() || !isCloudReachable())) {
249
  if (fallbackMeta.found) {
250
- console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存 (${endpoint})`);
251
  const proxyFn2 = await getProxyImages();
252
  return proxyFn2(fallbackMeta.value);
253
  }
@@ -258,13 +346,13 @@ async function request(endpoint, options = {}) {
258
  }
259
 
260
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
261
- if (method === "GET") {
262
  if (pendingRequests.has(cacheKey)) {
263
  return pendingRequests.get(cacheKey);
264
  }
265
  }
266
 
267
- const fetchOptions = { method, headers, ...options };
268
 
269
  // 🚀 核心修改:在将 body 转为 JSON 字符串发送前,执行无情剥离!
270
  if (options.body && !(options.body instanceof FormData) && typeof options.body !== "string") {
@@ -296,7 +384,8 @@ async function request(endpoint, options = {}) {
296
  // 不会把搜索类已经收紧过的 10s 预算又放宽回去。
297
  // noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
298
  // 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
299
- if (method === "GET" && !options.noCache && fallbackMeta.found) {
 
300
  defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
301
  defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
302
  }
@@ -305,6 +394,7 @@ async function request(endpoint, options = {}) {
305
  const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
306
 
307
  // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
 
308
  const requestPromise = (async () => {
309
  let lastError = null;
310
  // ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
@@ -312,6 +402,7 @@ async function request(endpoint, options = {}) {
312
  let networkLevelFailure = false;
313
 
314
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
 
315
  // 🚀 P1优化:指数退避延迟
316
  if (attempt > 0) {
317
  // ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
@@ -324,25 +415,75 @@ async function request(endpoint, options = {}) {
324
 
325
  // 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
326
  const controller = requestCancelManager.create(componentId);
 
327
  const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
328
- // 🔧 修复:超时计时器改到真正发出 fetch 时才启动。原实现在入队前就 setTimeout,
329
- // 并发满 6 个时排队等待会白烧超时预算,排队时长超过超时值会导致 fetch 尚未发出就被 abort
330
  let timeoutId = null;
331
- // 🔍 区分「超时中止」与「调用方主动撤销」:两者抛出的都是 AbortError,
332
- // 但前者应该重试、后者绝不能重试(详见下方 catch 的主动取消分支)
333
  let timedOut = false;
 
 
 
 
 
334
 
335
  try {
336
- // 🚀 P3优化:使用请求队列限制并发
337
- const response = await requestQueue.add(() => {
338
- timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, requestTimeout);
339
- return fetch(url, currentFetchOptions);
340
- });
341
- clearTimeout(timeoutId); // 清除超时计时器
342
- requestCancelManager.remove(componentId, controller); // 🚀 P4: 移除已完成的 controller
343
- let responseData = await response.json().catch(() => ({}));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
 
345
- if (!response.ok) {
 
346
  let errorMsg = `请求失败 (${response.status})`;
347
 
348
  // 🚀 核心修改:增加对 FastAPI 422 报错数组的解析
@@ -365,11 +506,25 @@ async function request(endpoint, options = {}) {
365
  // 迟到的旧 401 或匿名请求的 401 不再误触发登录态重置。
366
  // 登录接口的 401 语义是「密码错误」而非凭证失效,不走全局清理,
367
  // 避免与登录表单自身的错误提示重复叠加
368
- if (endpoint !== "/api/users/login" && handleAuthExpired(errorMsg, token)) {
369
  window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
370
  }
371
  }
372
- throw new Error(errorMsg);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  }
374
 
375
  lastError = new Error(errorMsg);
@@ -386,17 +541,19 @@ async function request(endpoint, options = {}) {
386
  }
387
 
388
  // ⚡ P1性能优化:GET 请求结果缓存
389
- if (method === "GET") {
390
  const ttl = _getCacheTTL(endpoint);
391
  if (ttl > 0) {
392
  // 创作者列表不持久化到 localStorage,避免空间不足
393
  const persist = !endpoint.includes('/api/creators');
394
- setCache(cacheKey, responseData, ttl, persist);
 
395
  }
396
  }
397
 
398
  // 入口数据挂载代理
399
  const proxyFn3 = await getProxyImages();
 
400
  responseData = proxyFn3(responseData);
401
  return responseData;
402
  } catch (error) {
@@ -404,7 +561,8 @@ async function request(endpoint, options = {}) {
404
  // 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
405
  // 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
406
  requestCancelManager.remove(componentId, controller);
407
-
 
408
  // 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
409
  // 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
410
  // 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
@@ -415,11 +573,18 @@ async function request(endpoint, options = {}) {
415
  cancelErr.isCancelled = true;
416
  throw cancelErr;
417
  }
 
 
 
 
 
 
 
418
 
419
  // 🔧 P1优化:可重试的错误类型
420
  const isRetryable = (
421
- error.name === 'AbortError' || // 超时
422
- (error instanceof TypeError && error.message.includes('fetch')) // 网络错误
423
  );
424
 
425
  if (isRetryable) {
@@ -430,7 +595,7 @@ async function request(endpoint, options = {}) {
430
  lastError = isTimeout
431
  ? new Error('网络请求超时,请检查网络连接')
432
  : new Error('网络连接失败,请检查网络');
433
- networkLevelFailure = true;
434
  // ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
435
  // 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
436
  // 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫秒级的,
@@ -444,7 +609,7 @@ async function request(endpoint, options = {}) {
444
 
445
  // 🔧 P3优化:错误分类处理,提供更清晰的错误信息(不可重试错误直接抛出)
446
  if (error instanceof SyntaxError) {
447
- throw new Error('服务器响应格式错误');
448
  }
449
  throw error;
450
  }
@@ -453,12 +618,12 @@ async function request(endpoint, options = {}) {
453
  // ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
454
  // 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
455
  if (networkLevelFailure) markCloudDown(endpoint);
456
-
457
  // 所有重试都失败,尝试回退到过期缓存
458
- if (method === "GET") {
459
  const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期
460
  if (fallback.found) {
461
- console.warn(`📴 网络请求失败,回退到本地缓存: ${endpoint}`);
462
  const proxyFn4 = await getProxyImages();
463
  return proxyFn4(fallback.value); // 返回过期缓存数据
464
  }
@@ -467,8 +632,10 @@ async function request(endpoint, options = {}) {
467
  throw lastError || new Error('请求失败');
468
  })();
469
 
 
 
470
  // 记录进行中的请求
471
- if (method === "GET") {
472
  pendingRequests.set(cacheKey, requestPromise);
473
  }
474
 
@@ -476,9 +643,11 @@ async function request(endpoint, options = {}) {
476
  // 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
477
  // 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
478
  // 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
479
- requestPromise.finally(() => {
480
- pendingRequests.delete(cacheKey);
481
- }).catch(() => {});
 
 
482
 
483
  return requestPromise;
484
  }
@@ -499,12 +668,17 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
499
  const timeout = options.timeout || 1200000; // 20分钟全局超时(大型工具克隆需要较长时间)
500
  const controller = new AbortController();
501
  const globalTimer = setTimeout(() => controller.abort(), timeout);
 
 
 
 
502
 
503
  // 📡 空闲超时检测:长时间无数据视为连接已断
504
  const IDLE_TIMEOUT_DEFAULT = 30000; // 30 秒无数据视为空闲
505
  const IDLE_TIMEOUT_GRACE = 5000; // 收到 finalResult 后的宽限期 5 秒
506
  let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
507
  let idleTimer = null;
 
508
 
509
  const clearIdleTimer = () => {
510
  if (idleTimer !== null) {
@@ -522,14 +696,41 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
522
  });
523
 
524
  if (!response.ok) {
525
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 
 
 
 
 
526
  }
527
 
528
  const reader = response.body.getReader();
529
  const decoder = new TextDecoder();
530
  let buffer = "";
531
- let finalResult = null;
532
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  while (true) {
534
  // 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
535
  const idlePromise = new Promise((_, reject) => {
@@ -549,48 +750,43 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
549
  // 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
550
  if (finalResult) return finalResult;
551
  // 否则返回空闲超时错误
552
- return { status: "error", message: "连接超时:30秒未收到数据" };
553
  }
554
  throw e;
555
  }
556
  clearIdleTimer();
557
 
558
  const { done, value } = readResult;
559
- if (done) break;
 
 
 
 
 
560
 
561
  buffer += decoder.decode(value, { stream: true });
562
  const lines = buffer.split("\n");
563
  buffer = lines.pop() || ""; // 保留不完整行
564
-
565
- for (const line of lines) {
566
- if (line.startsWith("data: ")) {
567
- try {
568
- const data = JSON.parse(line.slice(6));
569
- onProgress?.(data);
570
-
571
- if (data.status === "success" || data.status === "error") {
572
- finalResult = data;
573
- // 收到最终结果后,缩短空闲超时为宽限期,避免无谓等待连接关闭
574
- currentIdleTimeout = IDLE_TIMEOUT_GRACE;
575
- }
576
- } catch (e) {
577
- console.warn("[SSE] 解析事件失败:", line);
578
- }
579
- }
580
- }
581
  }
582
 
583
- return finalResult || { status: "error", message: "未收到最终结果" };
584
  } catch (err) {
 
 
585
  if (err.name === "AbortError") {
586
- // 全局超时时,如果已收到 finalResult 仍返回它
587
- if (finalResult) return finalResult;
588
- return { status: "error", message: "安装超时,请检查网络后重试" };
 
 
 
589
  }
590
  throw err;
591
  } finally {
592
  clearTimeout(globalTimer);
593
  clearIdleTimer();
 
594
  }
595
  }
596
 
 
11
 
12
  import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
  import { API, CACHE } from "./全局配置.js";
14
+ import { eventBus, EVENTS, logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
15
+ import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache, isPrivateApiGet } from "./网络请求_缓存管理.js";
16
  import { unproxyImages } from "./网络请求_图片代理.js";
17
  // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
18
  // 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
 
42
  // 本地凭证会导致所有登录态操作反复报「Token 签名无效」。去重窗口:并发请求
43
  // 同时收到 401 时只提示一次。
44
  let _authExpiredAt = 0;
45
+ function _safeStorageGet(storageName, key) {
46
+ try {
47
+ const storage = window[storageName];
48
+ return storage ? storage.getItem(key) : null;
49
+ } catch (_) {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function _safeGetAuthToken() {
55
+ return _safeStorageGet('localStorage', 'ComfyCommunity_Token')
56
+ || _safeStorageGet('sessionStorage', 'ComfyCommunity_Token')
57
+ || '';
58
+ }
59
+
60
+ /** 与实际 Authorization 使用同一存储读取;仅返回比较函数,不暴露凭证。 */
61
+ export function captureRequestSession() {
62
+ const originalToken = _safeGetAuthToken();
63
+ return () => originalToken === _safeGetAuthToken();
64
+ }
65
+
66
+ // 仅跟踪私有 GET;会话撤销不取消或重放已提交的业务写操作。
67
+ const activePrivateReads = new Set();
68
+ function revokePrivateReads(force = false) {
69
+ for (const entry of activePrivateReads) {
70
+ if (force || !entry.matches()) {
71
+ entry.revoked = true;
72
+ entry.controller?.abort();
73
+ }
74
+ }
75
+ }
76
+ for (const event of ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired']) {
77
+ window.addEventListener(event, () => revokePrivateReads(true));
78
+ }
79
+ window.addEventListener('storage', event => {
80
+ if (event.key === null || event.key === CACHE.LEGACY_KEYS.USER) revokePrivateReads(true);
81
+ else if (event.key === CACHE.LEGACY_KEYS.TOKEN) revokePrivateReads();
82
+ });
83
+ eventBus.on(EVENTS.USER_LOGIN, () => revokePrivateReads(true));
84
+ eventBus.on(EVENTS.USER_LOGOUT, () => revokePrivateReads(true));
85
+
86
+ function _safeClearAuthStorage() {
87
+ for (const storageName of ['localStorage', 'sessionStorage']) {
88
+ try {
89
+ const storage = window[storageName];
90
+ storage?.removeItem('ComfyCommunity_Token');
91
+ storage?.removeItem('ComfyCommunity_User');
92
+ } catch (_) {
93
+ // 浏览器禁用 Storage 时仍继续完成内存登出和事件通知。
94
+ }
95
+ }
96
+ }
97
+
98
+ function notifyTokenExpired() {
99
  const now = Date.now();
100
  if (now - _authExpiredAt < 5000) return;
101
  _authExpiredAt = now;
102
+ console.warn("⚠️ 登录凭证已失效,本地凭证已清除,请重新登录");
103
  // 动态 import UI 组件,与 proxyImages 同样避免模块初始化时序问题
104
  import("../components/UI交互提示组件.js").then(async ({ showToast }) => {
105
  const { t } = await import("../components/用户体验_国际化.js");
 
107
  }).catch(() => {});
108
  }
109
 
110
+ function handleAuthExpired(failedToken) {
111
  // 竞态防护:若用户在 401 返回前已重新登录(存储中的 Token 已更换),
112
  // 不清理新凭证;失败请求未携带 Token 时 401 与登录态无关,同样不打扰
113
  if (!failedToken) return false;
114
+ const currentToken = _safeGetAuthToken();
115
  if (currentToken !== failedToken) return false;
116
  // 清除前取出账号,用于清理账号级 Profile 缓存
117
  let account = null;
118
  try {
119
+ const userStr = _safeStorageGet('localStorage', "ComfyCommunity_User")
120
+ || _safeStorageGet('sessionStorage', "ComfyCommunity_User");
121
  if (userStr) account = JSON.parse(userStr)?.user?.account || null;
122
  } catch (e) {}
123
  // 与手动登出同深度的完整清理:Token/User、私有数据缓存、内存缓存与安装版本戳
124
  logoutAndClearUserData(account);
125
+ notifyTokenExpired();
126
  return true;
127
  }
128
 
 
205
  running: 0,
206
  pending: [],
207
 
208
+ async add(fn, signal) {
209
+ signal.throwIfAborted();
210
  // 如果还有槽位,直接执行
211
  if (this.running < this.maxConcurrent) {
212
  this.running++;
 
220
 
221
  // 否则加入队列等待
222
  return new Promise((resolve, reject) => {
223
+ const onAbort = () => {
224
+ const index = this.pending.indexOf(run);
225
+ if (index !== -1) this.pending.splice(index, 1);
226
+ reject(signal.reason);
227
+ };
228
+ const run = async () => {
229
+ signal.removeEventListener('abort', onAbort);
230
  try {
231
+ signal.throwIfAborted();
232
  resolve(await fn());
233
  } catch (e) {
234
  reject(e);
235
  }
236
+ };
237
+ this.pending.push(run);
238
+ signal.addEventListener('abort', onAbort, { once: true });
239
  });
240
  },
241
 
 
256
  };
257
 
258
  async function request(endpoint, options = {}) {
259
+ const privateRead = (options.method || "GET").toUpperCase() === "GET" && isPrivateApiGet(endpoint);
260
+ if (privateRead) {
261
+ removeCache(`api_${endpoint}`);
262
+ options = { ...options, noStore: true };
263
+ }
264
+ // 私有详情显式不留存:同时绕过响应命中/回退/去重、插件写缓存和浏览器缓存。
265
+ if (options.noStore) options = { ...options, noCache: true, cache: "no-store" };
266
  const url = `${BASE_URL}${endpoint}`;
267
  const method = (options.method || "GET").toUpperCase();
268
  const headers = { ...options.headers };
269
  if (!(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; }
270
+ const token = _safeGetAuthToken();
271
+ const privateEntry = privateRead ? { revoked: false, controller: null, matches: () => token === _safeGetAuthToken() } : null;
272
+ const assertReadSession = () => {
273
+ if (privateRead && (privateEntry.revoked || !privateEntry.matches())) {
274
+ const error = new Error(t('auth.session_changed'));
275
+ error.name = 'SessionChangedError';
276
+ error.isCancelled = true;
277
+ throw error;
278
+ }
279
+ };
280
  if (token) {
281
  const tokenParts = token.split(".");
282
  if (tokenParts.length === 3) {
 
288
  // 🔧 修复:凭证既已清除,同步广播登出事件重置导航登录态/停止轮询,
289
  // 并给用户明确提示(原实现仅 console.warn 静默清理)
290
  console.warn("⚠️ 检测到无效/已过期的旧版 Token,已自动清除,请重新登录");
291
+ _safeClearAuthStorage();
 
 
 
292
  window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
293
+ notifyTokenExpired();
294
  }
295
  }
296
 
 
301
  // 🔑 P0修复:cacheKey 必须包含完整 endpoint(含查询参数),不能截断 '?' 之后的部分
302
  // 例:'/api/items?type=tool&sort=time' 和 '/api/items?type=tool&sort=views' 必须产生不同的缓存 Key
303
  const cacheKey = `api_${endpoint}`;
304
+ const trackPendingRequest = method === "GET" && !options.noCache;
305
  if (method === "GET" && !options.noCache) {
306
  const cached = getCache(cacheKey);
307
  if (cached) {
 
313
  // 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
314
  // ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
315
  // ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
316
+ const fallbackMeta = (method === "GET" && !options.noCache)
317
  ? getCacheWithMeta(cacheKey, true) // 忽略过期
318
  : { value: null, expired: false, found: false };
319
+ const conditionalRead = method === "GET" && !options.noStore && options.credentials !== "include"
320
+ && !Object.keys(headers).some(name => name.toLowerCase() === "authorization")
321
+ && API.PUBLIC_CONDITIONAL_GET_PATHS.includes(endpoint.split(/[?#]/)[0]);
322
+ const conditionalMeta = conditionalRead ? getCacheWithMeta(cacheKey, true) : null;
323
+ if (conditionalRead) {
324
+ for (const name of Object.keys(headers)) if (name.toLowerCase() === "if-none-match") delete headers[name];
325
+ if (conditionalMeta?.found && conditionalMeta.etag) headers["If-None-Match"] = conditionalMeta.etag;
326
+ }
327
+
328
  // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
329
  // ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
330
  // 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
331
  // 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
332
  // 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
333
  // 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
334
+ // noCache 只代表绕过该 endpoint 的响应缓存,不得绕过共享网络熔断;榜单强制刷新
335
+ // 仍由列表层先显示自己的缓存,冷却期内直接失败返回,避免切换榜单逐个重试 HF。
336
  if (method === "GET" && (!isOnline() || !isCloudReachable())) {
337
  if (fallbackMeta.found) {
338
+ console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存`);
339
  const proxyFn2 = await getProxyImages();
340
  return proxyFn2(fallbackMeta.value);
341
  }
 
346
  }
347
 
348
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
349
+ if (trackPendingRequest) {
350
  if (pendingRequests.has(cacheKey)) {
351
  return pendingRequests.get(cacheKey);
352
  }
353
  }
354
 
355
+ const fetchOptions = { ...options, method, headers };
356
 
357
  // 🚀 核心修改:在将 body 转为 JSON 字符串发送前,执行无情剥离!
358
  if (options.body && !(options.body instanceof FormData) && typeof options.body !== "string") {
 
384
  // 不会把搜索类已经收紧过的 10s 预算又放宽回去。
385
  // noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
386
  // 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
387
+ const useCacheBudget = method === "GET" && !options.noCache && fallbackMeta.found;
388
+ if (useCacheBudget) {
389
  defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
390
  defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
391
  }
 
394
  const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
395
 
396
  // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
397
+ if (privateEntry) activePrivateReads.add(privateEntry);
398
  const requestPromise = (async () => {
399
  let lastError = null;
400
  // ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
 
402
  let networkLevelFailure = false;
403
 
404
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
405
+ assertReadSession();
406
  // 🚀 P1优化:指数退避延迟
407
  if (attempt > 0) {
408
  // ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
 
415
 
416
  // 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
417
  const controller = requestCancelManager.create(componentId);
418
+ if (privateEntry) privateEntry.controller = controller;
419
  const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
 
 
420
  let timeoutId = null;
 
 
421
  let timedOut = false;
422
+ let fetchStarted = false;
423
+ let transportFailed = false;
424
+ const onTimeout = () => { timedOut = true; controller.abort(); };
425
+ // 已有缓存时排队也计入预算,避免被无缓存的慢请求无限拖住。
426
+ if (useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
427
 
428
  try {
429
+ const result = await requestQueue.add(async () => {
430
+ if (!useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
431
+ // 请求可能在云端故障被另一个并发请求确认前进入队列;出队发出 fetch
432
+ // 前复查共享状态,避免队列继续逐个访问已知不可达的 HF endpoint。
433
+ if (method === "GET" && (!isOnline() || !isCloudReachable())) {
434
+ const unavailableError = new Error(
435
+ isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache')
436
+ );
437
+ unavailableError.name = 'CloudUnavailableError';
438
+ throw unavailableError;
439
+ }
440
+ assertReadSession();
441
+ fetchStarted = true;
442
+ try {
443
+ let response = await fetch(url, currentFetchOptions);
444
+ let validatedData;
445
+ if (response.status === 304 && conditionalRead) {
446
+ const currentMeta = getCacheWithMeta(cacheKey, true);
447
+ if (conditionalMeta?.found && conditionalMeta.etag
448
+ && response.headers?.get?.('ETag') === conditionalMeta.etag
449
+ && currentMeta.found && currentMeta.etag === conditionalMeta.etag
450
+ && currentMeta.value === conditionalMeta.value) {
451
+ validatedData = currentMeta.value;
452
+ } else {
453
+ // 缓存可能在等待期间被清理/写操作失效;同一预算内补取完整响应。
454
+ const fullHeaders = { ...currentFetchOptions.headers };
455
+ for (const name of Object.keys(fullHeaders)) if (name.toLowerCase() === "if-none-match") delete fullHeaders[name];
456
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
457
+ response = await fetch(url, { ...currentFetchOptions, headers: fullHeaders, cache: "no-store" });
458
+ if (response.status === 304) throw new SyntaxError('Unexpected not-modified response');
459
+ }
460
+ }
461
+ let data;
462
+ try {
463
+ data = validatedData !== undefined ? validatedData
464
+ : response.status === 204 ? {} : await response.json();
465
+ } catch (error) {
466
+ // 错误响应的正文不可读时仍按 HTTP 状态处理,不能用缓存掩盖 401/403。
467
+ if (!response.ok && (error instanceof SyntaxError || error instanceof TypeError || (timedOut && error.name === 'AbortError'))) {
468
+ data = {};
469
+ } else {
470
+ throw error;
471
+ }
472
+ }
473
+ return { response, data, validated: validatedData !== undefined };
474
+ } catch (error) {
475
+ transportFailed = error instanceof TypeError;
476
+ throw error;
477
+ }
478
+ }, controller.signal);
479
+ clearTimeout(timeoutId);
480
+ requestCancelManager.remove(componentId, controller);
481
+ const { response } = result;
482
+ let responseData = result.data;
483
+ assertReadSession();
484
 
485
+ if (!response.ok && !result.validated) {
486
+ if (!responseData || typeof responseData !== "object" || Array.isArray(responseData)) responseData = {};
487
  let errorMsg = `请求失败 (${response.status})`;
488
 
489
  // 🚀 核心修改:增加对 FastAPI 422 报错数组的解析
 
506
  // 迟到的旧 401 或匿名请求的 401 不再误触发登录态重置。
507
  // 登录接口的 401 语义是「密码错误」而非凭证失效,不走全局清理,
508
  // 避免与登录表单自身的错误提示重复叠加
509
+ if (endpoint !== "/api/users/login" && handleAuthExpired(token)) {
510
  window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
511
  }
512
  }
513
+ const error = new Error(errorMsg);
514
+ error.status = response.status;
515
+ if (typeof responseData.detail === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(responseData.detail)) error.code = responseData.detail;
516
+ throw error;
517
+ }
518
+
519
+ // Hugging Face Space / 网关不可用通常以 502、503 或 504 响应,
520
+ // 这类响应证明请求到达了代理层,但云端当前无法提供服务。
521
+ // 与超时/连接失败一样共享冷却,避免切换榜单时每个 endpoint
522
+ // 都重复访问一次;普通 500 仍只视为单接口服务端错误。
523
+ if ([502, 503, 504].includes(response.status)) {
524
+ lastError = new Error(errorMsg);
525
+ networkLevelFailure = true;
526
+ markCloudDown(endpoint);
527
+ break;
528
  }
529
 
530
  lastError = new Error(errorMsg);
 
541
  }
542
 
543
  // ⚡ P1性能优化:GET 请求结果缓存
544
+ if (method === "GET" && !options.noStore) {
545
  const ttl = _getCacheTTL(endpoint);
546
  if (ttl > 0) {
547
  // 创作者列表不持久化到 localStorage,避免空间不足
548
  const persist = !endpoint.includes('/api/creators');
549
+ setCache(cacheKey, responseData, ttl, persist,
550
+ conditionalRead ? { etag: response.headers?.get?.('ETag') } : {});
551
  }
552
  }
553
 
554
  // 入口数据挂载代理
555
  const proxyFn3 = await getProxyImages();
556
+ assertReadSession();
557
  responseData = proxyFn3(responseData);
558
  return responseData;
559
  } catch (error) {
 
561
  // 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
562
  // 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
563
  requestCancelManager.remove(componentId, controller);
564
+ assertReadSession();
565
+
566
  // 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
567
  // 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
568
  // 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
 
573
  cancelErr.isCancelled = true;
574
  throw cancelErr;
575
  }
576
+
577
+ // 队列等待期间已有并发请求确证网络不可达:该请求尚未发出 fetch,
578
+ // 直接交给统一缓存兜底,不重试、不延长冷却,也不把队列错误当作网络探测。
579
+ if (error.name === 'CloudUnavailableError') {
580
+ lastError = error;
581
+ break;
582
+ }
583
 
584
  // 🔧 P1优化:可重试的错误类型
585
  const isRetryable = (
586
+ error.name === 'AbortError' ||
587
+ transportFailed
588
  );
589
 
590
  if (isRetryable) {
 
595
  lastError = isTimeout
596
  ? new Error('网络请求超时,请检查网络连接')
597
  : new Error('网络连接失败,请检查网络');
598
+ if (fetchStarted) networkLevelFailure = true;
599
  // ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
600
  // 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
601
  // 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫秒级的,
 
609
 
610
  // 🔧 P3优化:错误分类处理,提供更清晰的错误信息(不可重试错误直接抛出)
611
  if (error instanceof SyntaxError) {
612
+ throw new Error(t('feedback.response_format_error'));
613
  }
614
  throw error;
615
  }
 
618
  // ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
619
  // 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
620
  if (networkLevelFailure) markCloudDown(endpoint);
621
+
622
  // 所有重试都失败,尝试回退到过期缓存
623
+ if (method === "GET" && !options.noCache) {
624
  const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期
625
  if (fallback.found) {
626
+ console.warn('📴 网络请求失败,回退到本地缓存');
627
  const proxyFn4 = await getProxyImages();
628
  return proxyFn4(fallback.value); // 返回过期缓存数据
629
  }
 
632
  throw lastError || new Error('请求失败');
633
  })();
634
 
635
+ if (privateEntry) requestPromise.finally(() => activePrivateReads.delete(privateEntry)).catch(() => {});
636
+
637
  // 记录进行中的请求
638
+ if (trackPendingRequest) {
639
  pendingRequests.set(cacheKey, requestPromise);
640
  }
641
 
 
643
  // 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
644
  // 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
645
  // 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
646
+ if (trackPendingRequest) {
647
+ requestPromise.finally(() => {
648
+ pendingRequests.delete(cacheKey);
649
+ }).catch(() => {});
650
+ }
651
 
652
  return requestPromise;
653
  }
 
668
  const timeout = options.timeout || 1200000; // 20分钟全局超时(大型工具克隆需要较长时间)
669
  const controller = new AbortController();
670
  const globalTimer = setTimeout(() => controller.abort(), timeout);
671
+ const externalSignal = options.signal;
672
+ const abortFromCaller = () => controller.abort();
673
+ if (externalSignal?.aborted) controller.abort();
674
+ else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
675
 
676
  // 📡 空闲超时检测:长时间无数据视为连接已断
677
  const IDLE_TIMEOUT_DEFAULT = 30000; // 30 秒无数据视为空闲
678
  const IDLE_TIMEOUT_GRACE = 5000; // 收到 finalResult 后的宽限期 5 秒
679
  let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
680
  let idleTimer = null;
681
+ let finalResult = null;
682
 
683
  const clearIdleTimer = () => {
684
  if (idleTimer !== null) {
 
696
  });
697
 
698
  if (!response.ok) {
699
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
700
+ error.status = response.status;
701
+ // 仅当流式路由明确不存在/不接受该方法时,调用方可安全回退到旧端点。
702
+ // 网络错误、超时和服务端错误都可能发生在操作已开始后,不可自动重发。
703
+ error.fallbackSafe = response.status === 404 || response.status === 405;
704
+ throw error;
705
  }
706
 
707
  const reader = response.body.getReader();
708
  const decoder = new TextDecoder();
709
  let buffer = "";
710
+ const processSSELine = (line) => {
711
+ if (!line.startsWith("data: ")) return;
712
+ let data;
713
+ try {
714
+ data = JSON.parse(line.slice(6));
715
+ } catch (parseError) {
716
+ console.warn("[SSE] 解析事件失败:", parseError?.name || 'SyntaxError');
717
+ return;
718
+ }
719
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
720
+ console.warn("[SSE] 忽略非对象事件");
721
+ return;
722
+ }
723
+ if (data.status === "success" || data.status === "error") {
724
+ // 先保存最终结果,UI 更新回调即使异常也不能丢失服务端结果。
725
+ finalResult = data;
726
+ currentIdleTimeout = IDLE_TIMEOUT_GRACE;
727
+ }
728
+ try {
729
+ onProgress?.(data);
730
+ } catch (callbackError) {
731
+ console.warn("[SSE] 进度回调失败");
732
+ }
733
+ };
734
  while (true) {
735
  // 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
736
  const idlePromise = new Promise((_, reject) => {
 
750
  // 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
751
  if (finalResult) return finalResult;
752
  // 否则返回空闲超时错误
753
+ return { status: "error", outcomeUnknown: true, message: "连接超时:30秒未收到数据" };
754
  }
755
  throw e;
756
  }
757
  clearIdleTimer();
758
 
759
  const { done, value } = readResult;
760
+ if (done) {
761
+ buffer += decoder.decode();
762
+ for (const line of buffer.split("\n")) processSSELine(line);
763
+ buffer = "";
764
+ break;
765
+ }
766
 
767
  buffer += decoder.decode(value, { stream: true });
768
  const lines = buffer.split("\n");
769
  buffer = lines.pop() || ""; // 保留不完整行
770
+ for (const line of lines) processSSELine(line);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  }
772
 
773
+ return finalResult || { status: "error", outcomeUnknown: true, message: "未收到最终结果" };
774
  } catch (err) {
775
+ // 已收到最终事件时,后续连接关闭错误不应覆盖操作结果或触发重复提交。
776
+ if (finalResult) return finalResult;
777
  if (err.name === "AbortError") {
778
+ return {
779
+ status: "error",
780
+ outcomeUnknown: true,
781
+ cancelRequested: !!externalSignal?.aborted,
782
+ message: externalSignal?.aborted ? t('feedback.request_cancelled') : "连接超时,操作结果暂时无法确认"
783
+ };
784
  }
785
  throw err;
786
  } finally {
787
  clearTimeout(globalTimer);
788
  clearIdleTimer();
789
+ externalSignal?.removeEventListener("abort", abortFromCaller);
790
  }
791
  }
792
 
前端页面/core/网络请求_缓存管理.js CHANGED
@@ -9,7 +9,7 @@
9
  // - 性能优化工具.js (重导出缓存函数,保持向后兼容)
10
  // ==========================================
11
 
12
- import { CACHE } from "./全局配置.js";
13
 
14
  // ==========================================
15
  // 📦 缓存底层实现
@@ -23,6 +23,10 @@ import { CACHE } from "./全局配置.js";
23
  const CACHE_PREFIX = CACHE.PREFIX;
24
  const DEFAULT_TTL = CACHE.DEFAULT_TTL;
25
  const MAX_MEMORY_ITEMS = CACHE.MAX_MEMORY_ITEMS;
 
 
 
 
26
 
27
  // 内存缓存(最快)
28
  const memoryCache = new Map();
@@ -46,6 +50,21 @@ function _isExpired(cached) {
46
  return Date.now() >= cached.expireAt;
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  /**
50
  * 安全写入 localStorage,配额不足时先清理过期项再重试,仍失败则 LRU 淘汰后重试
51
  * @param {string} fullKey - 完整缓存键
@@ -54,12 +73,15 @@ function _isExpired(cached) {
54
  function _safeStorageWrite(fullKey, jsonStr) {
55
  try {
56
  localStorage.setItem(fullKey, jsonStr);
 
57
  } catch (e) {
58
  if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
 
59
  console.warn('⚠️ localStorage 已满,尝试清理过期缓存...');
60
  _cleanExpiredStorage();
61
  try {
62
  localStorage.setItem(fullKey, jsonStr);
 
63
  } catch {
64
  let stored = false;
65
  for (let attempt = 0; attempt < 5 && !stored; attempt++) {
@@ -73,11 +95,13 @@ function _safeStorageWrite(fullKey, jsonStr) {
73
  }
74
  }
75
  if (!stored) {
76
- console.warn(`⚠️ localStorage 持久化失败,降级到仅内存缓存: ${fullKey}`);
77
  }
 
78
  }
79
  } else {
80
- console.warn('⚠️ localStorage 存储失败:', fullKey, e.message);
 
81
  }
82
  }
83
  }
@@ -88,11 +112,13 @@ function _safeStorageWrite(fullKey, jsonStr) {
88
  * @param {any} value - 缓存值
89
  * @param {number} ttl - 过期时间(毫秒),默认2小时
90
  * @param {boolean} persist - 是否持久化到 localStorage
 
91
  */
92
- export function setCache(key, value, ttl = DEFAULT_TTL, persist = true) {
93
  const fk = _fullKey(key);
94
  const expireAt = Date.now() + ttl;
95
- const cacheData = { value, expireAt };
 
96
 
97
  // 内存缓存
98
  memoryCache.set(fk, cacheData);
@@ -101,7 +127,22 @@ export function setCache(key, value, ttl = DEFAULT_TTL, persist = true) {
101
 
102
  // 持久化缓存
103
  if (persist) {
104
- _safeStorageWrite(fk, JSON.stringify(cacheData));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
  }
107
 
@@ -116,12 +157,15 @@ export function getCache(key) {
116
  // 优先从内存获取
117
  if (memoryCache.has(fk)) {
118
  const cached = memoryCache.get(fk);
119
- if (!_isExpired(cached)) {
120
- _updateLRU(fk);
121
- return cached.value;
 
 
 
 
 
122
  }
123
- // 过期,清除(走 _deleteMemoryEntry 同步摘除 LRU 顺序数组,避免幽灵键挤占名额)
124
- _deleteMemoryEntry(fk);
125
  }
126
 
127
  // 降级到 localStorage
@@ -129,10 +173,15 @@ export function getCache(key) {
129
  const stored = localStorage.getItem(fk);
130
  if (stored) {
131
  const cached = JSON.parse(stored);
 
 
 
 
132
  if (!_isExpired(cached)) {
133
  // 回填内存缓存
134
  memoryCache.set(fk, cached);
135
  _updateLRU(fk);
 
136
  return cached.value;
137
  }
138
  // 🔧 修复:过期条目不再物理删除,保留给断网时的过期缓存兜底使用;
@@ -140,7 +189,7 @@ export function getCache(key) {
140
  // (原实现此处删除会导致 request() 新鲜检查先删、离线兜底后读而落空)
141
  }
142
  } catch (e) {
143
- console.warn("⚠️ 缓存读取失败:", key);
144
  }
145
 
146
  return null;
@@ -158,14 +207,17 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
158
  // 优先从内存获取
159
  if (memoryCache.has(fk)) {
160
  const cached = memoryCache.get(fk);
161
- const expired = _isExpired(cached);
162
-
163
- if (!expired || ignoreExpiry) {
164
- _updateLRU(fk);
165
- return { value: cached.value, expired, found: true };
 
 
 
 
 
166
  }
167
- // 过期且不忽略,清除(同上:必须同步维护 memoryCacheOrder)
168
- _deleteMemoryEntry(fk);
169
  }
170
 
171
  // 降级到 localStorage
@@ -173,19 +225,23 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
173
  const stored = localStorage.getItem(fk);
174
  if (stored) {
175
  const cached = JSON.parse(stored);
 
 
 
 
176
  const expired = _isExpired(cached);
177
 
178
  if (!expired || ignoreExpiry) {
179
  // 回填内存缓存
180
  memoryCache.set(fk, cached);
181
  _updateLRU(fk);
182
- return { value: cached.value, expired, found: true };
 
183
  }
184
- // 🔧 修复:与 getCache 一致,过期条目保留不删(供离线兜底),仅返回未命中;
185
- // 内存中的过期副本已在上方删除,下次可重新从 storage 回填
186
  }
187
  } catch (e) {
188
- console.warn("⚠️ 缓存读取失败:", key);
189
  }
190
 
191
  return { value: null, expired: false, found: false };
@@ -203,6 +259,99 @@ function _deleteMemoryEntry(fk) {
203
  if (idx > -1) memoryCacheOrder.splice(idx, 1);
204
  }
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  /**
207
  * 删除缓存(精确键,内存 + localStorage 两级)
208
  * @param {string} key - 缓存键
@@ -213,6 +362,7 @@ export function removeCache(key) {
213
  try {
214
  localStorage.removeItem(fk);
215
  } catch {}
 
216
  }
217
 
218
  /**
@@ -231,7 +381,7 @@ export function removeCacheByPrefix(prefix) {
231
  const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
232
  const isMatch = (key) => candidates.some(p => key.startsWith(p));
233
  const removed = new Set();
234
-
235
  // 🔧 内存层必须先清:getCache / getCacheWithMeta 都优先读 memoryCache,
236
  // 只清 localStorage 的话旧数据仍会命中内存并被直接返回(请求根本不会发出)
237
  for (const fk of Array.from(memoryCache.keys())) {
@@ -240,7 +390,7 @@ export function removeCacheByPrefix(prefix) {
240
  removed.add(fk);
241
  }
242
  }
243
-
244
  try {
245
  const keysToRemove = [];
246
  for (let i = 0; i < localStorage.length; i++) {
@@ -252,7 +402,8 @@ export function removeCacheByPrefix(prefix) {
252
  removed.add(k);
253
  });
254
  } catch {}
255
-
 
256
  return removed.size;
257
  }
258
 
@@ -262,6 +413,7 @@ export function removeCacheByPrefix(prefix) {
262
  export function clearAllCache() {
263
  memoryCache.clear();
264
  memoryCacheOrder.length = 0;
 
265
 
266
  try {
267
  const keysToRemove = [];
@@ -298,7 +450,7 @@ function _cleanExpiredStorage(aggressive = false) {
298
  const keysToRemove = [];
299
  for (let i = 0; i < localStorage.length; i++) {
300
  const key = localStorage.key(i);
301
- if (key && key.startsWith(CACHE_PREFIX)) {
302
  if (aggressive) {
303
  // 激进模式:清除所有缓存项
304
  keysToRemove.push(key);
@@ -327,10 +479,15 @@ function _evictOldestStorage() {
327
  const entries = [];
328
  for (let i = 0; i < localStorage.length; i++) {
329
  const key = localStorage.key(i);
330
- if (key && key.startsWith(CACHE_PREFIX)) {
331
  try {
332
  const cached = JSON.parse(localStorage.getItem(key));
333
- entries.push({ key, expireAt: cached.expireAt || 0 });
 
 
 
 
 
334
  } catch {
335
  localStorage.removeItem(key);
336
  }
@@ -339,7 +496,7 @@ function _evictOldestStorage() {
339
 
340
  if (entries.length === 0) return false;
341
 
342
- entries.sort((a, b) => a.expireAt - b.expireAt);
343
 
344
  // 🧹 批量淘汰策略:每次淘汰最旧的 30%(温和策略,减少频繁全量扫描)
345
  const evictCount = Math.max(1, Math.ceil(entries.length * 0.3));
@@ -436,10 +593,10 @@ const CACHE_INVALIDATION_MAP = {
436
  },
437
  // 帖子相关
438
  "/api/posts": {
439
- "POST": ["api_/api/posts", "ComfyRanking_ListCache_posts"], // 创建帖子,清除列表缓存
440
- "PUT": ["api_/api/posts"], // 更新帖子,清除列表缓存
441
- "DELETE": ["api_/api/posts", "ComfyRanking_ListCache_posts"], // 删除帖子,清除列表缓存
442
- "INTERACTION": ["api_/api/posts", "ComfyRanking_ListCache_posts"] // 👍 互动操作(view/like/favorite/tip)
443
  },
444
  // 评论相关
445
  "/api/comments": {
@@ -448,11 +605,18 @@ const CACHE_INVALIDATION_MAP = {
448
  },
449
  // 商品相关
450
  "/api/items": {
451
- "POST": ["api_/api/items", "api_/api/creators"], // 创建商品,清除商品列表和创作者列表
452
- "PUT": ["api_/api/items"], // 更新商品,仅清除商品列表(不碰创作者)
453
- "DELETE": ["api_/api/items", "api_/api/creators"], // 删除商品,清除商品列表和创作者列表
454
- "INTERACTION": ["api_/api/items", "api_/api/creators"] // 👍 互动操作(view/like)
455
  },
 
 
 
 
 
 
 
456
  // 用户相关
457
  "/api/users": {
458
  "PUT": [
@@ -526,7 +690,7 @@ export function invalidateRelatedCache(endpoint, method = null) {
526
  }
527
 
528
  if (cleared > 0) {
529
- console.log(`🗑️ 精确清除缓存: ${cleared} 个项 (${endpoint}${method ? ` ${method}` : ''})`);
530
  }
531
  }
532
 
@@ -544,3 +708,24 @@ export function _getCacheTTL(endpoint) {
544
 
545
  // 导出配置和映射
546
  export { CACHE_CONFIG, CACHE_INVALIDATION_MAP };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  // - 性能优化工具.js (重导出缓存函数,保持向后兼容)
10
  // ==========================================
11
 
12
+ import { API, CACHE } from "./全局配置.js";
13
 
14
  // ==========================================
15
  // 📦 缓存底层实现
 
23
  const CACHE_PREFIX = CACHE.PREFIX;
24
  const DEFAULT_TTL = CACHE.DEFAULT_TTL;
25
  const MAX_MEMORY_ITEMS = CACHE.MAX_MEMORY_ITEMS;
26
+ const CACHE_SCHEMA_VERSION = 2;
27
+ const MAX_STORAGE_BYTES = CACHE.MAX_STORAGE_BYTES;
28
+ let storageUsageBytes = null;
29
+ let lastStorageBudgetSweepAt = 0;
30
 
31
  // 内存缓存(最快)
32
  const memoryCache = new Map();
 
50
  return Date.now() >= cached.expireAt;
51
  }
52
 
53
+ function _isValidCacheEntry(cached) {
54
+ return !!cached && typeof cached === "object"
55
+ && Object.prototype.hasOwnProperty.call(cached, "value")
56
+ && Number.isFinite(cached.expireAt)
57
+ && (cached.schemaVersion == null || (Number.isInteger(cached.schemaVersion)
58
+ && cached.schemaVersion >= 1 && cached.schemaVersion <= CACHE_SCHEMA_VERSION));
59
+ }
60
+
61
+ // 同前缀的本地记录/配置使用独立格式;自动缓存清理不能处理它们。
62
+ function _isManagedStorageKey(key) {
63
+ if (!key || !key.startsWith(CACHE_PREFIX)) return false;
64
+ return !Object.values(CACHE.LOCAL_KEYS).some(localKey => key === localKey
65
+ || key.startsWith(localKey.endsWith('_') ? localKey : localKey + '_'));
66
+ }
67
+
68
  /**
69
  * 安全写入 localStorage,配额不足时先清理过期项再重试,仍失败则 LRU 淘汰后重试
70
  * @param {string} fullKey - 完整缓存键
 
73
  function _safeStorageWrite(fullKey, jsonStr) {
74
  try {
75
  localStorage.setItem(fullKey, jsonStr);
76
+ return true;
77
  } catch (e) {
78
  if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
79
+ storageUsageBytes = null;
80
  console.warn('⚠️ localStorage 已满,尝试清理过期缓存...');
81
  _cleanExpiredStorage();
82
  try {
83
  localStorage.setItem(fullKey, jsonStr);
84
+ return true;
85
  } catch {
86
  let stored = false;
87
  for (let attempt = 0; attempt < 5 && !stored; attempt++) {
 
95
  }
96
  }
97
  if (!stored) {
98
+ console.warn('localStorage 持久化失败,保留内存缓存');
99
  }
100
+ return stored;
101
  }
102
  } else {
103
+ console.warn('localStorage 存储不可用,保留内存缓存');
104
+ return false;
105
  }
106
  }
107
  }
 
112
  * @param {any} value - 缓存值
113
  * @param {number} ttl - 过期时间(毫秒),默认2小时
114
  * @param {boolean} persist - 是否持久化到 localStorage
115
+ * @param {{etag?: string}} metadata - 可选的正文验证器,与value一同失效
116
  */
117
+ export function setCache(key, value, ttl = DEFAULT_TTL, persist = true, metadata = {}) {
118
  const fk = _fullKey(key);
119
  const expireAt = Date.now() + ttl;
120
+ const cacheData = { schemaVersion: CACHE_SCHEMA_VERSION, value, expireAt, lastAccessAt: Date.now() };
121
+ if (typeof metadata?.etag === "string" && API.CONDITIONAL_ETAG_PATTERN.test(metadata.etag)) cacheData.etag = metadata.etag;
122
 
123
  // 内存缓存
124
  memoryCache.set(fk, cacheData);
 
127
 
128
  // 持久化缓存
129
  if (persist) {
130
+ const jsonStr = JSON.stringify(cacheData);
131
+ let previousBytes = 0;
132
+ let previousLengthKnown = false;
133
+ try {
134
+ const previous = localStorage.getItem(fk);
135
+ previousBytes = previous === null ? 0 : (fk.length + previous.length) * 2;
136
+ previousLengthKnown = true;
137
+ } catch {}
138
+ if (_safeStorageWrite(fk, jsonStr)) {
139
+ if (storageUsageBytes !== null && previousLengthKnown) {
140
+ storageUsageBytes += (fk.length + jsonStr.length) * 2 - previousBytes;
141
+ } else {
142
+ storageUsageBytes = null;
143
+ }
144
+ _enforceStorageBudget(fk);
145
+ }
146
  }
147
  }
148
 
 
157
  // 优先从内存获取
158
  if (memoryCache.has(fk)) {
159
  const cached = memoryCache.get(fk);
160
+ if (!_isValidCacheEntry(cached)) _deleteMemoryEntry(fk);
161
+ else {
162
+ if (!_isExpired(cached)) {
163
+ _updateLRU(fk);
164
+ _touchStorageEntry(fk, cached);
165
+ return cached.value;
166
+ }
167
+ // 仅内存缓存也需要保留过期副本,供弱网回退使用。
168
  }
 
 
169
  }
170
 
171
  // 降级到 localStorage
 
173
  const stored = localStorage.getItem(fk);
174
  if (stored) {
175
  const cached = JSON.parse(stored);
176
+ if (!_isValidCacheEntry(cached)) {
177
+ localStorage.removeItem(fk);
178
+ return null;
179
+ }
180
  if (!_isExpired(cached)) {
181
  // 回填内存缓存
182
  memoryCache.set(fk, cached);
183
  _updateLRU(fk);
184
+ _touchStorageEntry(fk, cached);
185
  return cached.value;
186
  }
187
  // 🔧 修复:过期条目不再物理删除,保留给断网时的过期缓存兜底使用;
 
189
  // (原实现此处删除会导致 request() 新鲜检查先删、离线兜底后读而落空)
190
  }
191
  } catch (e) {
192
+ console.warn("⚠️ 缓存读取失败。");
193
  }
194
 
195
  return null;
 
207
  // 优先从内存获取
208
  if (memoryCache.has(fk)) {
209
  const cached = memoryCache.get(fk);
210
+ if (!_isValidCacheEntry(cached)) _deleteMemoryEntry(fk);
211
+ else {
212
+ const expired = _isExpired(cached);
213
+
214
+ if (!expired || ignoreExpiry) {
215
+ _updateLRU(fk);
216
+ _touchStorageEntry(fk, cached);
217
+ return { value: cached.value, expired, found: true, ...(typeof cached.etag === "string" && API.CONDITIONAL_ETAG_PATTERN.test(cached.etag) ? { etag: cached.etag } : {}) };
218
+ }
219
+ // 新鲜度检查不删除数据;淘汰仍由 LRU 和显式失效负责。
220
  }
 
 
221
  }
222
 
223
  // 降级到 localStorage
 
225
  const stored = localStorage.getItem(fk);
226
  if (stored) {
227
  const cached = JSON.parse(stored);
228
+ if (!_isValidCacheEntry(cached)) {
229
+ localStorage.removeItem(fk);
230
+ return { value: null, expired: false, found: false };
231
+ }
232
  const expired = _isExpired(cached);
233
 
234
  if (!expired || ignoreExpiry) {
235
  // 回填内存缓存
236
  memoryCache.set(fk, cached);
237
  _updateLRU(fk);
238
+ _touchStorageEntry(fk, cached);
239
+ return { value: cached.value, expired, found: true, ...(typeof cached.etag === "string" && API.CONDITIONAL_ETAG_PATTERN.test(cached.etag) ? { etag: cached.etag } : {}) };
240
  }
241
+ // 与内存层一致,保留过期条目供离线兜底。
 
242
  }
243
  } catch (e) {
244
+ console.warn("⚠️ 缓存读取失败。");
245
  }
246
 
247
  return { value: null, expired: false, found: false };
 
259
  if (idx > -1) memoryCacheOrder.splice(idx, 1);
260
  }
261
 
262
+ /**
263
+ * 按业务前缀取得两级缓存键快照,返回不含 CACHE.PREFIX 的键。
264
+ * 内存键排在前面并去重;存储访问失败不影响仅内存缓存的离线查找。
265
+ * 此处只枚举键,条目结构与 TTL 仍由 getCacheWithMeta 校验。
266
+ */
267
+ export function getCacheKeysByPrefix(prefix) {
268
+ if (typeof prefix !== "string" || !prefix) return [];
269
+ const fullPrefix = _fullKey(prefix);
270
+ const keys = new Set();
271
+ for (const key of memoryCache.keys()) {
272
+ if (key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
273
+ }
274
+ try {
275
+ for (let index = 0; index < localStorage.length; index++) {
276
+ const key = localStorage.key(index);
277
+ if (key && key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
278
+ }
279
+ } catch {
280
+ // 禁用存储或浏览器隐私策略下,继续使用上面已取得的内存键。
281
+ }
282
+ return [...keys];
283
+ }
284
+
285
+ function _touchStorageEntry(key, cached) {
286
+ try {
287
+ if (localStorage.getItem(key) === null) return;
288
+ const now = Date.now();
289
+ if (Number.isFinite(cached.lastAccessAt) && now - cached.lastAccessAt < 60_000) return;
290
+ cached.lastAccessAt = now;
291
+ localStorage.setItem(key, JSON.stringify(cached));
292
+ } catch {}
293
+ }
294
+
295
+ /** Keep this plugin's persistent cache within its estimated UTF-16 storage budget. */
296
+ function _enforceStorageBudget(protectedKey = null) {
297
+ const now = Date.now();
298
+ if (storageUsageBytes !== null && storageUsageBytes <= MAX_STORAGE_BYTES
299
+ && now - lastStorageBudgetSweepAt < 60_000) return;
300
+
301
+ try {
302
+ const entries = [];
303
+ const invalidKeys = [];
304
+ let totalBytes = 0;
305
+ for (let i = 0; i < localStorage.length; i++) {
306
+ const key = localStorage.key(i);
307
+ if (!_isManagedStorageKey(key)) continue;
308
+ const raw = localStorage.getItem(key);
309
+ if (raw === null) continue;
310
+ let cached;
311
+ try {
312
+ cached = JSON.parse(raw);
313
+ } catch {
314
+ invalidKeys.push(key);
315
+ continue;
316
+ }
317
+ if (!_isValidCacheEntry(cached)) {
318
+ invalidKeys.push(key);
319
+ continue;
320
+ }
321
+ const sizeBytes = (key.length + raw.length) * 2;
322
+ const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
323
+ entries.push({ key, sizeBytes, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
324
+ totalBytes += sizeBytes;
325
+ }
326
+ invalidKeys.forEach(key => localStorage.removeItem(key));
327
+
328
+ let evicted = 0;
329
+ for (const entry of [...entries]) {
330
+ if (entry.key === protectedKey && entry.sizeBytes > MAX_STORAGE_BYTES) {
331
+ localStorage.removeItem(entry.key);
332
+ totalBytes -= entry.sizeBytes;
333
+ entries.splice(entries.indexOf(entry), 1);
334
+ evicted++;
335
+ }
336
+ }
337
+ entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
338
+ while (totalBytes > MAX_STORAGE_BYTES && entries.length > 0) {
339
+ const oldest = entries.shift();
340
+ localStorage.removeItem(oldest.key);
341
+ totalBytes -= oldest.sizeBytes;
342
+ evicted++;
343
+ }
344
+ storageUsageBytes = totalBytes;
345
+ lastStorageBudgetSweepAt = now;
346
+ if (evicted > 0) {
347
+ console.warn(`⚠️ 本地缓存超过约 ${Math.round(MAX_STORAGE_BYTES / 1024)} KiB 预算,按最近访问时间淘汰 ${evicted} 项`);
348
+ }
349
+ } catch {
350
+ // 存储不可用时保留内存缓存;下一次持久化再重试预算扫描。
351
+ storageUsageBytes = null;
352
+ }
353
+ }
354
+
355
  /**
356
  * 删除缓存(精确键,内存 + localStorage 两级)
357
  * @param {string} key - 缓存键
 
362
  try {
363
  localStorage.removeItem(fk);
364
  } catch {}
365
+ storageUsageBytes = null;
366
  }
367
 
368
  /**
 
381
  const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
382
  const isMatch = (key) => candidates.some(p => key.startsWith(p));
383
  const removed = new Set();
384
+
385
  // 🔧 内存层必须先清:getCache / getCacheWithMeta 都优先读 memoryCache,
386
  // 只清 localStorage 的话旧数据仍会命中内存并被直接返回(请求根本不会发出)
387
  for (const fk of Array.from(memoryCache.keys())) {
 
390
  removed.add(fk);
391
  }
392
  }
393
+
394
  try {
395
  const keysToRemove = [];
396
  for (let i = 0; i < localStorage.length; i++) {
 
402
  removed.add(k);
403
  });
404
  } catch {}
405
+
406
+ if (removed.size > 0) storageUsageBytes = null;
407
  return removed.size;
408
  }
409
 
 
413
  export function clearAllCache() {
414
  memoryCache.clear();
415
  memoryCacheOrder.length = 0;
416
+ storageUsageBytes = null;
417
 
418
  try {
419
  const keysToRemove = [];
 
450
  const keysToRemove = [];
451
  for (let i = 0; i < localStorage.length; i++) {
452
  const key = localStorage.key(i);
453
+ if (_isManagedStorageKey(key)) {
454
  if (aggressive) {
455
  // 激进模式:清除所有缓存项
456
  keysToRemove.push(key);
 
479
  const entries = [];
480
  for (let i = 0; i < localStorage.length; i++) {
481
  const key = localStorage.key(i);
482
+ if (_isManagedStorageKey(key)) {
483
  try {
484
  const cached = JSON.parse(localStorage.getItem(key));
485
+ if (!_isValidCacheEntry(cached)) {
486
+ localStorage.removeItem(key);
487
+ continue;
488
+ }
489
+ const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
490
+ entries.push({ key, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
491
  } catch {
492
  localStorage.removeItem(key);
493
  }
 
496
 
497
  if (entries.length === 0) return false;
498
 
499
+ entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
500
 
501
  // 🧹 批量淘汰策略:每次淘汰最旧的 30%(温和策略,减少频繁全量扫描)
502
  const evictCount = Math.max(1, Math.ceil(entries.length * 0.3));
 
593
  },
594
  // 帖子相关
595
  "/api/posts": {
596
+ "POST": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 创建帖子,清除列表缓存
597
+ "PUT": ["api_/api/posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 更新帖子,清除列表缓存
598
+ "DELETE": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 删除帖子,清除列表缓存
599
+ "INTERACTION": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like/favorite/tip)
600
  },
601
  // 评论相关
602
  "/api/comments": {
 
605
  },
606
  // 商品相关
607
  "/api/items": {
608
+ "POST": ["api_/api/items", "api_/api/creators", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 创建商品,清除商品列表和创作者列表
609
+ "PUT": ["api_/api/items", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 更新商品,仅清除商品列表(不碰创作者)
610
+ "DELETE": ["api_/api/items", "api_/api/creators", ...CACHE.RESOURCE_LIST_PREFIXES, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 删除商品也清除四类排序缓存
611
+ "INTERACTION": ["api_/api/items", "api_/api/creators", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like)
612
  },
613
+ "/api/prompts": {
614
+ "POST": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
615
+ "PUT": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
616
+ "DELETE": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
617
+ "INTERACTION": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX]
618
+ },
619
+ "/api/interactions": { "POST": ["api_/api/items", "api_/api/creators", ...CACHE.RESOURCE_LIST_PREFIXES, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] },
620
  // 用户相关
621
  "/api/users": {
622
  "PUT": [
 
690
  }
691
 
692
  if (cleared > 0) {
693
+ console.log(`🗑️ 精确清除缓存: ${cleared} 个项`);
694
  }
695
  }
696
 
 
708
 
709
  // 导出配置和映射
710
  export { CACHE_CONFIG, CACHE_INVALIDATION_MAP };
711
+
712
+ /** 私有 GET 策略只匹配路径,不受分页/筛选查询串影响。 */
713
+ export function isPrivateApiGet(endpoint) {
714
+ const path = endpoint.split(/[?#]/)[0];
715
+ return API.PRIVATE_GET_PATTERNS.some(pattern => pattern.test(path));
716
+ }
717
+
718
+ /** 一次性移除旧通用私有响应;不处理独立本地记录或公开榜单缓存。 */
719
+ export function purgePrivateApiCache() {
720
+ const keys = new Set(memoryCache.keys());
721
+ try {
722
+ for (let index = 0; index < localStorage.length; index++) keys.add(localStorage.key(index));
723
+ } catch (_) {}
724
+ const prefix = CACHE_PREFIX + "api_";
725
+ for (const fullKey of keys) {
726
+ if (fullKey?.startsWith(prefix) && isPrivateApiGet(fullKey.slice(prefix.length))) {
727
+ removeCache(fullKey.slice(CACHE_PREFIX.length));
728
+ }
729
+ }
730
+ }
731
+ purgePrivateApiCache();
前端页面/market/列表卡片组件.js CHANGED
@@ -1,23 +1,29 @@
1
  // 前端页面/market/列表卡片组件.js
2
- import { createCommentSection, setupToggleButton, createRatingStars } from "../social/评论与互动组件.js";
3
- import { api } from "../core/网络请求API.js";
4
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
5
  import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
6
  import { renderItemTrendChart } from "../components/图表渲染组件.js";
7
  import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
8
  import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
9
- import { showToast, showConfirm } from "../components/UI交互提示组件.js";
10
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
11
  import { renderTipBoardHTML, isMaxTipLevel } from "../components/打赏等级工具.js";
12
  import { lazyLoadImages } from "../components/性能优化工具.js";
13
  import { t } from "../components/用户体验增强.js";
14
- import { getCachedProfile, getProfileWithSWR, CACHE, IS_WEB_MODE } from "../core/全局配置.js";
 
 
 
 
 
 
15
 
16
  /**
17
  * 生成价格显示HTML(含待生效价格提示)
18
  */
19
  function _generatePriceHTML(itemData, t) {
20
- const formatPrice = (price) => price > 0 ? `${price} ${t('market.points')}` : t('market.free');
21
 
22
  if (itemData.pending_price !== null && itemData.pending_price !== undefined && itemData.pending_price_effective_at) {
23
  const effectiveTime = new Date(itemData.pending_price_effective_at);
@@ -25,18 +31,18 @@ function _generatePriceHTML(itemData, t) {
25
  if (effectiveTime > now) {
26
  const hoursLeft = Math.ceil((effectiveTime - now) / (1000 * 60 * 60));
27
  return `
28
- <span style="float: right;">
29
- <span style="color: #FF9800;">💰 ${formatPrice(itemData.price)}</span>
30
- <span style="margin-left: 5px; font-size: 10px; color: #F44336; background: rgba(244,67,54,0.15); padding: 1px 4px; border-radius: 3px;" title="${hoursLeft}${t('time.hours_later')}">
31
- → ${itemData.pending_price}${t('market.points')}
32
  </span>
33
  </span>
34
  `;
35
  } else {
36
- return `<span style="float: right; color: #FF9800;">💰 ${formatPrice(itemData.pending_price)}</span>`;
37
  }
38
  } else {
39
- return `<span style="float: right; color: #FF9800;">💰 ${formatPrice(itemData.price)}</span>`;
40
  }
41
  }
42
 
@@ -46,6 +52,10 @@ function _generatePriceHTML(itemData, t) {
46
  * @param {Object} callbacks - 回调集合 { onName, onAvatar, fallbackName }
47
  */
48
  function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
 
 
 
 
49
  const cached = getCachedProfile(account);
50
  if (cached) {
51
  if (cached.name && onName) onName(cached.name);
@@ -55,6 +65,7 @@ function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
55
  }
56
 
57
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
 
58
  if (profile.name && onName) onName(profile.name);
59
  if (profile.avatar && onAvatar) onAvatar(profile.avatar);
60
  });
@@ -62,18 +73,22 @@ function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
62
 
63
  export function createItemCard(itemData, currentUser = null, contextType = null) {
64
  const card = document.createElement("div");
 
 
 
 
 
65
  card.setAttribute("data-item-id", itemData.id);
66
- Object.assign(card.style, {
67
- backgroundColor: "var(--comfy-input-bg, #2b2b2b)", borderRadius: "8px", padding: "10px",
68
- marginBottom: "12px", border: "1px solid #444", color: "#fff", fontFamily: "sans-serif"
69
- });
70
 
71
  // 🚀 核心新增:初始渲染时,动态计算真实的有效评论数(过滤软删除的废弃数据)
72
  const calcActiveComments = (data) => {
73
  if (!data || !Array.isArray(data)) return 0;
74
  return data.reduce((acc, c) => {
 
75
  const parentCnt = c.isDeleted ? 0 : 1;
76
- const repliesCnt = (c.replies || []).filter(r => !r.isDeleted).length;
 
 
77
  return acc + parentCnt + repliesCnt;
78
  }, 0);
79
  };
@@ -84,34 +99,33 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
84
  // 避免标题容器的 overflow: hidden + ellipsis 在长标题时把徽章一并裁掉
85
  const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
86
  const updateBadgeHtml = installStatus.hasUpdate
87
- ? `<span style="flex: none; white-space: nowrap; background: linear-gradient(135deg, #FF9800, #F44336); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; animation: pulse 2s infinite;">🔄 ${t('market.update_available')}</span>`
88
  : '';
89
 
90
  const summaryView = document.createElement("div");
91
  summaryView.className = "item-summary";
92
- summaryView.style.cursor = "pointer";
93
 
94
  // 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
95
  // 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
96
  summaryView.innerHTML = `
97
  <!-- 第1行: 标题 + 更新徽章 + 使用次数 -->
98
- <div style="display: flex; align-items: center; margin-bottom: 4px; gap: 6px;">
99
- <div style="font-weight: bold; font-size: 14px; color: #4CAF50; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(itemData.title)}</div>
100
  ${updateBadgeHtml}
101
- <span style="margin-left: auto; flex: none; font-size: 11px; color: #888; display: flex; align-items: center; gap: 6px;">
102
- <span data-stat="uses" style="display: flex; align-items: center; gap: 2px;">📥 ${itemData.uses || 0}</span>
103
- <span data-stat="views" style="display: flex; align-items: center; gap: 2px;">🔥 ${itemData.views || 0}</span>
104
  </span>
105
  </div>
106
  <!-- 第2行: 描述 -->
107
- <div style="font-size: 12px; color: #aaa; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px;">${escapeHtml(itemData.shortDesc)}</div>
108
  <!-- 第3行: 互动数据 + 发布者信息 -->
109
- <div style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: #888;">
110
- <span data-stat="likes">👍 ${itemData.likes || 0}</span> <span data-stat="favorites">🔖 ${itemData.favorites || 0}</span> <span class="card-comment-count">💬 ${initialCommentCount}</span>
111
- <span style="margin-left: auto; display: flex; align-items: center; gap: 4px; cursor: pointer;" class="card-author-info">
112
- <img class="card-author-avatar" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect fill='%23333' width='40' height='40' rx='20'/%3E%3Ctext x='20' y='25' text-anchor='middle' fill='%23666' font-size='16'%3E%3F%3C/text%3E%3C/svg%3E" style="width: 18px; height: 18px; border-radius: 50%; object-fit: cover;">
113
- <span class="card-author-name" style="font-size: 12px; color: #aaa; transition: color 0.2s;">${escapeHtml(itemData.author || '未知')}</span>
114
- </span>
115
  </div>
116
  `;
117
 
@@ -125,7 +139,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
125
  },
126
  compact: true
127
  });
128
- ratingCompact.className = "card-rating-compact";
129
  const authorInfoInSummary = summaryView.querySelector(".card-author-info");
130
  if (authorInfoInSummary && authorInfoInSummary.parentNode) {
131
  authorInfoInSummary.parentNode.insertBefore(ratingCompact, authorInfoInSummary);
@@ -143,14 +157,6 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
143
  openOtherUserProfileModal(itemData.author, currentUser);
144
  };
145
 
146
- // hover效果:发布者名称颜色变亮
147
- authorInfoEl.onmouseenter = () => {
148
- if (authorNameEl) authorNameEl.style.color = '#4CAF50';
149
- };
150
- authorInfoEl.onmouseleave = () => {
151
- if (authorNameEl) authorNameEl.style.color = '#aaa';
152
- };
153
-
154
  // 使用统一辅助函数加载作者资料
155
  _loadAuthorProfile(itemData.author, {
156
  onName: (name) => { authorNameEl.textContent = name; },
@@ -160,15 +166,20 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
160
  }
161
 
162
  const detailView = document.createElement("div");
163
- Object.assign(detailView.style, { display: "none", marginTop: "12px", paddingTop: "12px", borderTop: "1px dashed #555" });
 
 
 
 
 
164
 
165
  const authorInfo = document.createElement("div");
166
- Object.assign(authorInfo.style, { fontSize: "12px", color: "#ccc", marginBottom: "10px" });
167
 
168
  // 🔄 P7后悔模式:检查是否有待生效价格
169
  const priceHtml = _generatePriceHTML(itemData, t);
170
 
171
- authorInfo.innerHTML = `${t('market.author')}: <span class="author-name-link" style="color: #2196F3; cursor: pointer; text-decoration: underline;">${t('common.loading')}...</span> ${priceHtml}`;
172
  detailView.appendChild(authorInfo);
173
 
174
  const nameDOM = authorInfo.querySelector('.author-name-link');
@@ -183,12 +194,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
183
  // 原创内容标识
184
  if (itemData.is_original) {
185
  const originalBadge = document.createElement("div");
186
- Object.assign(originalBadge.style, {
187
- display: "inline-flex", alignItems: "center", gap: "6px",
188
- padding: "4px 12px", background: "linear-gradient(135deg, #FF6B35, #FF8F00)",
189
- borderRadius: "12px", fontSize: "11px", color: "#fff", fontWeight: "500",
190
- marginBottom: "10px"
191
- });
192
  originalBadge.textContent = "🎨 " + t('publish.mark_original');
193
  detailView.appendChild(originalBadge);
194
  }
@@ -196,38 +202,35 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
196
  // 支持退款标识(仅工具、Skill和应用类型显示,且允许退款时)
197
  if (itemData.allow_refund !== false && (itemData.type === 'tool' || itemData.type === 'skill' || itemData.type === 'app')) {
198
  const refundBadge = document.createElement("div");
199
- Object.assign(refundBadge.style, {
200
- display: "inline-flex", alignItems: "center", gap: "6px",
201
- padding: "4px 12px", background: "linear-gradient(135deg, #4CAF50, #2E7D32)",
202
- borderRadius: "12px", fontSize: "11px", color: "#fff", fontWeight: "500",
203
- marginBottom: "10px", marginLeft: "8px"
204
- });
205
  refundBadge.textContent = `🛡️ ${t('item.refundable')}`;
206
  detailView.appendChild(refundBadge);
207
  }
208
 
209
  const actionArea = document.createElement("div");
210
- Object.assign(actionArea.style, { display: "flex", gap: "8px", marginBottom: "12px" });
211
  const btnLike = document.createElement("button");
212
- Object.assign(btnLike.style, { flex: "1", padding: "6px", background: "transparent", border: "1px solid #555", borderRadius: "4px", cursor: "pointer", color: "#aaa" });
213
  const btnFav = document.createElement("button");
214
- Object.assign(btnFav.style, { flex: "1", padding: "6px", background: "transparent", border: "1px solid #555", borderRadius: "4px", cursor: "pointer", color: "#aaa" });
215
 
216
  // 🚀 根据安装状态显示不同的按钮样式(installStatus 已在上方声明)
217
  const btnUse = document.createElement("button");
 
 
218
 
219
  if (installStatus.hasUpdate) {
220
  // 有更新可用
221
- Object.assign(btnUse.style, { flex: "1", padding: "6px", background: "#FF9800", border: "none", borderRadius: "4px", cursor: "pointer", color: "white", fontWeight: "bold" });
222
  btnUse.innerHTML = `♻️ ${t('market.update_available')}`;
223
  } else if (installStatus.installed) {
224
  // 已安装
225
- Object.assign(btnUse.style, { flex: "1", padding: "6px", background: "#4CAF50", border: "none", borderRadius: "4px", cursor: "pointer", color: "white", fontWeight: "bold" });
226
  btnUse.innerHTML = `✅ ${t('market.installed')}`;
227
  } else {
228
  // 未安装
229
- Object.assign(btnUse.style, { flex: "1", padding: "6px", background: "#2196F3", border: "none", borderRadius: "4px", cursor: "pointer", color: "white", fontWeight: "bold" });
230
- btnUse.innerHTML = `🚀 ${t('market.get_now')} ${itemData.uses ? '(' + itemData.uses + ')' : ''}`;
231
  }
232
 
233
  actionArea.appendChild(btnLike); actionArea.appendChild(btnFav); actionArea.appendChild(btnUse);
@@ -243,9 +246,13 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
243
  },
244
  currentUser: currentUser,
245
  authorAccount: itemData.author,
246
- onRate: async (score) => {
 
 
 
247
  const res = await api.rateItem(itemData.id, score);
248
- if (res && res.status === "success") {
 
249
  itemData.rating_avg = res.rating_avg;
250
  itemData.rating_count = res.rating_count;
251
  itemData.rating_dist = res.rating_dist;
@@ -264,7 +271,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
264
  },
265
  compact: true
266
  });
267
- newCompact.className = "card-rating-compact";
268
  oldCompact.parentNode.replaceChild(newCompact, oldCompact);
269
  }
270
  }
@@ -278,49 +285,67 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
278
  const isFav = currentUser && Array.isArray(itemData.favorited_by) && itemData.favorited_by.includes(currentUser.account);
279
 
280
  const handleInteraction = async (type, isActive) => {
281
- if (!currentUser) { showToast(`⚠️ ${t('feedback.login_required')}`, "warning"); throw new Error("User not logged in"); }
282
- await api.toggleInteraction(itemData.id, currentUser.account, type, isActive);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  };
284
 
285
  setupToggleButton(btnLike, isLiked, itemData.likes || 0, `👍 ${t('market.liked')}`, `👍 ${t('market.like')}`, "#FF5722", (isActive) => handleInteraction("like", isActive));
286
  setupToggleButton(btnFav, isFav, itemData.favorites || 0, `🔖 ${t('market.favorited')}`, `🔖 ${t('market.favorite')}`, "#FFC107", (isActive) => handleInteraction("favorite", isActive));
287
 
288
  const inlineStatusBox = document.createElement("div");
289
- Object.assign(inlineStatusBox.style, { display: "none", margin: "0 0 15px 0", padding: "12px", background: "#222", border: "1px solid #444", borderRadius: "6px", fontSize: "12px", lineHeight: "1.5" });
 
290
  detailView.appendChild(inlineStatusBox);
291
  setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox);
292
 
293
  const mediaArea = document.createElement("div");
294
  const chartContainerId = `chart-${Math.random().toString(36).substr(2, 9)}`;
295
- const chartHtml = `<div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; color: #aaa;">📈 ${t('market.usage_trend')}</div><div id="${chartContainerId}" style="width: 100%; height: 160px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 4px; margin-bottom: 12px;"></div>`;
296
 
297
  mediaArea.innerHTML = `
298
  ${chartHtml}
299
  ${getCoverSandboxHTML(itemData.imageUrls?.length > 0 ? itemData.imageUrls : itemData.coverUrl)}
300
- <div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; color: #aaa;">📝 ${t('market.full_description')}</div>
301
- <div style="background: var(--comfy-menu-bg); padding: 10px; border-radius: 4px; font-size: 12px; color: #bbb; margin-bottom: 12px; max-height: 200px; overflow-y: auto; line-height: 1.6; border: 1px solid var(--border-color, #333); word-wrap: break-word; white-space: pre-wrap;">${escapeHtml(itemData.fullDesc)}</div>
302
  `;
303
  detailView.appendChild(mediaArea);
304
 
305
  // 🚀 新增:赞赏贡献榜单与打赏按钮(带等级显示)
306
  const tipArea = document.createElement("div");
307
- Object.assign(tipArea.style, { marginBottom: "12px", padding: "12px", background: "var(--comfy-menu-bg)", border: "1px solid var(--border-color, #333)", borderRadius: "6px" });
308
 
309
  // 使用统一的打赏榜单渲染工具(带星星/月亮/太阳等级)
310
- const boardData = itemData.tip_board || [];
 
 
311
  const boardHtml = renderTipBoardHTML(boardData, 5, t('market.no_tips_yet'), "small");
312
 
313
  tipArea.innerHTML = `
314
- <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
315
- <div style="font-size:12px; font-weight:bold; color:#E91E63;">💖 ${t('market.tip_board')} (TOP 5)</div>
316
- <button id="btn-tip-item-${itemData.id}" style="background:#E91E63; color:white; border:none; padding:4px 10px; border-radius:4px; cursor:pointer; font-size:11px; font-weight:bold;">💰 ${t('market.tip')}</button>
317
  </div>
318
  ${boardHtml}
319
  `;
320
  detailView.appendChild(tipArea);
321
 
322
  // 🚀 绑定打赏按钮事件
323
- tipArea.querySelector(`#btn-tip-item-${itemData.id}`).onclick = (e) => {
324
  e.stopPropagation();
325
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
326
  // 🐛 Bug修复:防御性校验,防止 itemId 为空
@@ -328,32 +353,31 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
328
  console.warn('⚠️ 列表卡片: itemData.id 为空,已拦截无效打赏请求');
329
  return showToast(t('item.invalid_id_tip'), "error");
330
  }
331
- openTipModal(currentUser, { account: itemData.author }, async (newBalance) => {
332
- // 🐛 Bug修复:确保打赏后余额即时同步
333
- if (newBalance !== undefined && newBalance !== null) {
334
- currentUser.balance = newBalance;
335
- } else {
336
- // 后备:如果回调未传余额,主动调用getWallet刷新
337
- try {
338
- const walletRes = await api.getWallet(currentUser.account, { noCache: true });
339
- if (walletRes && walletRes.status === "success") {
340
- currentUser.balance = walletRes.balance;
341
- }
342
- } catch (e) { /* 静默失败 */ }
343
- }
344
- }, itemData.id);
345
  };
346
 
347
  // 🔄 P7后悔模式:退款区域(仅在"我购买的"列表中显示)
348
  const refundArea = document.createElement("div");
349
  refundArea.id = `refund-area-${itemData.id}`;
350
- Object.assign(refundArea.style, { display: "none", marginBottom: "12px" });
 
351
  detailView.appendChild(refundArea);
352
 
353
  let isRendered = false;
354
- summaryView.onclick = async () => {
355
- const isHidden = detailView.style.display === "none";
356
- detailView.style.display = isHidden ? "block" : "none";
 
 
 
 
 
 
 
 
 
 
357
 
358
  if (isHidden && !isRendered) {
359
  renderItemTrendChart(detailView.querySelector(`#${chartContainerId}`), itemData);
@@ -367,7 +391,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
367
  recordView(api.recordItemView, itemData.id, 'item', (result) => {
368
  if (result?.views !== undefined) {
369
  const viewsEl = summaryView.querySelector('[data-stat="views"]');
370
- if (viewsEl) viewsEl.innerHTML = `🔥 ${result.views}`;
371
  }
372
  });
373
 
@@ -377,9 +401,16 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
377
  }
378
  }
379
  };
 
 
 
 
 
 
 
380
 
381
  const commentsContainer = document.createElement("div");
382
- commentsContainer.style.height = "250px";
383
 
384
  // 🚀 核心修改:传入一个回调函数,当组件内部发送/删除留言时,外部的数字能实时响应刷新!
385
  // 🚀 P0安全修复:传递 itemData.author 作为 contentAuthor,使内容作者可以删除其内容下的评论
@@ -393,12 +424,12 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
393
 
394
  if (currentUser && currentUser.account === itemData.author) {
395
  const authorActionArea = document.createElement("div");
396
- Object.assign(authorActionArea.style, { display: "flex", gap: "10px", marginTop: "15px", paddingTop: "15px", borderTop: "1px dashed #555" });
397
 
398
  authorActionArea.innerHTML = `
399
- <div style="flex: 1; font-size: 12px; color: #FF9800; display: flex; align-items: center; font-weight: bold;">👑 ${t('market.creator_manage')}</div>
400
- ${IS_WEB_MODE ? '' : `<button id="btn-edit-item" style="padding: 6px 12px; background: #2196F3; border: none; border-radius: 4px; color: white; cursor: pointer; font-weight: bold; font-size: 12px; transition: 0.2s;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">✏️ ${t('market.edit_content')}</button>`}
401
- <button id="btn-del-item" style="padding: 6px 12px; background: #F44336; border: none; border-radius: 4px; color: white; cursor: pointer; font-weight: bold; font-size: 12px; transition: 0.2s;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">🗑️ ${t('market.delete_permanently')}</button>
402
  `;
403
 
404
  // 📱 Web 模式不渲染编辑按钮,此处需空值保护
@@ -410,15 +441,8 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
410
 
411
  authorActionArea.querySelector("#btn-del-item").onclick = async (e) => {
412
  e.stopPropagation();
413
- if (await showConfirm(t('market.delete_confirm'))) {
414
- try {
415
- await api.deleteItem(itemData.id, currentUser.account);
416
- showToast(t('feedback.deleted'), "success");
417
- window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload"));
418
- } catch (err) {
419
- showToast(`${t('feedback.delete_failed')}: ${err.message}`, "error");
420
- }
421
- }
422
  };
423
  detailView.appendChild(authorActionArea);
424
  }
@@ -440,12 +464,16 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
440
  */
441
  async function loadRefundStatus(refundArea, itemData, currentUser, card) {
442
  if (!refundArea || !currentUser) return;
 
 
443
 
444
  refundArea.style.display = "block";
445
- refundArea.innerHTML = `<div style="padding: 10px; color: #888; font-size: 12px;">⏳ 加载购买状态...</div>`;
446
 
447
  try {
448
- const statusRes = await api.getPurchaseStatus(currentUser.account, itemData.id);
 
 
449
 
450
  if (!statusRes.owned) {
451
  refundArea.innerHTML = '';
@@ -453,40 +481,42 @@ async function loadRefundStatus(refundArea, itemData, currentUser, card) {
453
  return;
454
  }
455
 
456
- if (statusRes.can_refund) {
457
- const hoursLeft = statusRes.refund_hours_left;
 
458
  refundArea.innerHTML = `
459
- <div style="padding: 12px; background: rgba(255,152,0,0.1); border: 1px dashed #FF9800; border-radius: 6px;">
460
- <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
461
- <div style="font-size: 12px; color: #FF9800;">
462
- 🔄 后悔模式:还剩 <strong>${hoursLeft.toFixed(1)}</strong> 小时可申请退款
463
  </div>
464
- <button id="btn-refund-${itemData.id}" style="background: #FF5722; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">
465
- 💸 申请退款
466
  </button>
467
  </div>
468
- <div style="font-size: 11px; color: #888; margin-top: 8px;">
469
- ⚠️ 退款后权限将被收回,已下载的文件将被删���,且 <strong>30天内禁止再次购买</strong> 此商品
470
  </div>
471
  </div>
472
  `;
473
 
474
- const refundBtn = refundArea.querySelector(`#btn-refund-${itemData.id}`);
475
  if (refundBtn) {
476
  refundBtn.onclick = (e) => {
477
  e.stopPropagation();
 
478
  showRefundConfirm(itemData, currentUser, statusRes.price_paid, card);
479
  };
480
  }
481
  } else {
482
  refundArea.innerHTML = `
483
- <div style="padding: 10px; background: rgba(76,175,80,0.1); border: 1px solid rgba(76,175,80,0.3); border-radius: 6px; font-size: 12px; color: #4CAF50;">
484
- ✅ 已购买此资源,退款窗口已过期
485
  </div>
486
  `;
487
  }
488
  } catch (e) {
489
- console.warn("获取购买状态失败:", e);
490
  refundArea.innerHTML = '';
491
  refundArea.style.display = "none";
492
  }
@@ -496,93 +526,16 @@ async function loadRefundStatus(refundArea, itemData, currentUser, card) {
496
  * 显示退款确认弹窗
497
  */
498
  function showRefundConfirm(itemData, currentUser, pricePaid, card) {
499
- const overlay = document.createElement("div");
500
- overlay.id = "refund-confirm-overlay";
501
- Object.assign(overlay.style, {
502
- position: "fixed", top: "0", left: "0", right: "0", bottom: "0",
503
- background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center",
504
- zIndex: "10000"
505
- });
506
-
507
- overlay.innerHTML = `
508
- <div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
509
- <div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: #FF5722; display: flex; align-items: center; gap: 10px;">
510
- ⚠️ 确认退款
511
- </div>
512
-
513
- <div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
514
- <div style="font-size: 14px; margin-bottom: 10px;">商品:<strong>${escapeHtml(itemData.title || '未命名资源')}</strong></div>
515
- <div style="font-size: 14px; color: #4CAF50;">退款金额:<strong>${pricePaid}</strong> 积分</div>
516
- </div>
517
-
518
- <div style="background: rgba(255,87,34,0.1); border: 1px solid rgba(255,87,34,0.3); border-radius: 8px; padding: 15px; margin-bottom: 20px;">
519
- <div style="font-size: 13px; color: #FF9800; line-height: 1.8;">
520
- <div>🚨 <strong>退款后果:</strong></div>
521
- <div style="margin-left: 20px; margin-top: 5px;">
522
- • 您将失去此资源的访问权限<br>
523
- • 已下载的文件将被自动删除<br>
524
- • <strong style="color: #F44336;">30天内禁止再次购买此商品</strong>
525
- </div>
526
- </div>
527
- </div>
528
-
529
- <div style="display: flex; gap: 12px; justify-content: flex-end;">
530
- <button id="btn-cancel-refund" style="background: #555; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: 0.2s;" onmouseover="this.style.background='#666'" onmouseout="this.style.background='#555'">
531
- 取消
532
- </button>
533
- <button id="btn-confirm-refund" style="background: #FF5722; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.background='#E64A19'" onmouseout="this.style.background='#FF5722'">
534
- 确认退款
535
- </button>
536
- </div>
537
- </div>
538
- `;
539
-
540
- document.body.appendChild(overlay);
541
-
542
- overlay.querySelector("#btn-cancel-refund").onclick = () => overlay.remove();
543
-
544
- overlay.querySelector("#btn-confirm-refund").onclick = async () => {
545
- const confirmBtn = overlay.querySelector("#btn-confirm-refund");
546
- confirmBtn.disabled = true;
547
- confirmBtn.innerText = "处理中...";
548
-
549
- try {
550
- const res = await api.requestRefund(currentUser.account, itemData.id);
551
- if (res.status === "success") {
552
- // 清除本地购买记录
553
- const key = CACHE.LOCAL_KEYS.ACQUIRED_ITEMS;
554
- const acquiredItems = JSON.parse(localStorage.getItem(key) || '[]');
555
- const updated = acquiredItems.filter(i => i.id !== itemData.id);
556
- localStorage.setItem(key, JSON.stringify(updated));
557
-
558
- // 从DOM中移除卡片
559
- if (card && card.parentNode) {
560
- card.remove();
561
- }
562
-
563
- overlay.innerHTML = `
564
- <div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 380px; width: 90%; color: #fff; text-align: center;">
565
- <div style="font-size: 48px; margin-bottom: 15px;">✅</div>
566
- <div style="font-size: 18px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">退款成功</div>
567
- <div style="font-size: 14px; color: #aaa; margin-bottom: 20px;">
568
- ${res.refund_amount} 积分已退还到您的账户<br>
569
- <span style="color: #FF9800;">${res.ban_days}天内禁止再次购买此商品</span>
570
- </div>
571
- <button id="btn-close-refund" style="background: #4CAF50; color: #fff; border: none; padding: 10px 30px; border-radius: 6px; cursor: pointer; font-size: 14px;">
572
- 知道了
573
- </button>
574
- </div>
575
- `;
576
- overlay.querySelector("#btn-close-refund").onclick = () => overlay.remove();
577
-
578
- showToast("退款成功!积分已退还", "success");
579
- } else {
580
- showToast("退款失败:" + (res.detail || "未知错误"), "error");
581
- overlay.remove();
582
- }
583
- } catch (e) {
584
- showToast("退款请求失败:" + (e.message || "网络错误"), "error");
585
- overlay.remove();
586
  }
587
- };
588
- }
 
1
  // 前端页面/market/列表卡片组件.js
2
+ import { createCommentSection, setupToggleButton, createRatingStars, isValidRatingResponse } from "../social/评论与互动组件.js";
3
+ import { api, captureRequestSession } from "../core/网络请求API.js";
4
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
5
  import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
6
  import { renderItemTrendChart } from "../components/图表渲染组件.js";
7
  import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
8
  import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
9
+ import { showToast } from "../components/UI交互提示组件.js";
10
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
11
  import { renderTipBoardHTML, isMaxTipLevel } from "../components/打赏等级工具.js";
12
  import { lazyLoadImages } from "../components/性能优化工具.js";
13
  import { t } from "../components/用户体验增强.js";
14
+ import { getCachedProfile, getProfileWithSWR, IS_WEB_MODE } from "../core/全局配置.js";
15
+ import { getUser } from "../core/状态管理.js";
16
+ import { createPurchaseStatusScope, isValidPurchaseStatus } from "./购买状态生命周期.js";
17
+ import { showResourceDeleteDialog } from "./资源删除组件.js";
18
+ import { showResourceRefundDialog } from "./资源退款组件.js";
19
+
20
+ let nextCardId = 0;
21
 
22
  /**
23
  * 生成价格显示HTML(含待生效价格提示)
24
  */
25
  function _generatePriceHTML(itemData, t) {
26
+ const formatPrice = (price) => price > 0 ? `${escapeHtml(price)} ${t('market.points')}` : t('market.free');
27
 
28
  if (itemData.pending_price !== null && itemData.pending_price !== undefined && itemData.pending_price_effective_at) {
29
  const effectiveTime = new Date(itemData.pending_price_effective_at);
 
31
  if (effectiveTime > now) {
32
  const hoursLeft = Math.ceil((effectiveTime - now) / (1000 * 60 * 60));
33
  return `
34
+ <span class="resource-detail__price">
35
+ <span>💰 ${formatPrice(itemData.price)}</span>
36
+ <span class="resource-detail__pending-price" title="${hoursLeft}${t('time.hours_later')}">
37
+ → ${escapeHtml(itemData.pending_price)}${t('market.points')}
38
  </span>
39
  </span>
40
  `;
41
  } else {
42
+ return `<span class="resource-detail__price">💰 ${formatPrice(itemData.pending_price)}</span>`;
43
  }
44
  } else {
45
+ return `<span class="resource-detail__price">💰 ${formatPrice(itemData.price)}</span>`;
46
  }
47
  }
48
 
 
52
  * @param {Object} callbacks - 回调集合 { onName, onAvatar, fallbackName }
53
  */
54
  function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
55
+ if (!account) {
56
+ onName?.(t('common.unknown_user'));
57
+ return;
58
+ }
59
  const cached = getCachedProfile(account);
60
  if (cached) {
61
  if (cached.name && onName) onName(cached.name);
 
65
  }
66
 
67
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
68
+ if (!profile || typeof profile !== "object") return;
69
  if (profile.name && onName) onName(profile.name);
70
  if (profile.avatar && onAvatar) onAvatar(profile.avatar);
71
  });
 
73
 
74
  export function createItemCard(itemData, currentUser = null, contextType = null) {
75
  const card = document.createElement("div");
76
+ const ratingMatches = captureRequestSession();
77
+ const ratingAccount = currentUser?.account || null;
78
+ const ratingCurrent = () => card.isConnected && ratingMatches() && (getUser()?.account || null) === ratingAccount;
79
+ const cardId = `ranking-resource-${++nextCardId}`;
80
+ card.className = "ranking-item-card";
81
  card.setAttribute("data-item-id", itemData.id);
 
 
 
 
82
 
83
  // 🚀 核心新增:初始渲染时,动态计算真实的有效评论数(过滤软删除的废弃数据)
84
  const calcActiveComments = (data) => {
85
  if (!data || !Array.isArray(data)) return 0;
86
  return data.reduce((acc, c) => {
87
+ if (!c || typeof c !== "object" || Array.isArray(c)) return acc;
88
  const parentCnt = c.isDeleted ? 0 : 1;
89
+ const repliesCnt = Array.isArray(c.replies)
90
+ ? c.replies.filter(r => r && typeof r === "object" && !Array.isArray(r) && !r.isDeleted).length
91
+ : 0;
92
  return acc + parentCnt + repliesCnt;
93
  }, 0);
94
  };
 
99
  // 避免标题容器的 overflow: hidden + ellipsis 在长标题时把徽章一并裁掉
100
  const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
101
  const updateBadgeHtml = installStatus.hasUpdate
102
+ ? `<span class="card-update-badge">🔄 ${t('market.update_available')}</span>`
103
  : '';
104
 
105
  const summaryView = document.createElement("div");
106
  summaryView.className = "item-summary";
 
107
 
108
  // 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
109
  // 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
110
  summaryView.innerHTML = `
111
  <!-- 第1行: 标题 + 更新徽章 + 使用次数 -->
112
+ <div class="card-summary__title-row">
113
+ <button type="button" id="${cardId}-toggle" class="card-summary__title card-summary__toggle" aria-expanded="false" aria-controls="${cardId}-details">${escapeHtml(itemData.title || t('item.untitled'))}</button>
114
  ${updateBadgeHtml}
115
+ <span class="card-summary__top-stats">
116
+ <span data-stat="uses" class="card-summary__stat">📥 ${escapeHtml(itemData.uses || 0)}</span>
117
+ <span data-stat="views" class="card-summary__stat">🔥 ${escapeHtml(itemData.views || 0)}</span>
118
  </span>
119
  </div>
120
  <!-- 第2行: 描述 -->
121
+ <div class="card-summary__description">${escapeHtml(itemData.shortDesc)}</div>
122
  <!-- 第3行: 互动数据 + 发布者信息 -->
123
+ <div class="card-summary__footer">
124
+ <span data-stat="likes">👍 ${escapeHtml(itemData.likes || 0)}</span> <span data-stat="favorites">🔖 ${escapeHtml(itemData.favorites || 0)}</span> <span class="card-comment-count">💬 ${escapeHtml(initialCommentCount)}</span>
125
+ <button type="button" class="card-author-info card-summary__author" ${itemData.author ? '' : 'disabled'}>
126
+ <img class="card-author-avatar card-summary__avatar" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect fill='%23333' width='40' height='40' rx='20'/%3E%3Ctext x='20' y='25' text-anchor='middle' fill='%23666' font-size='16'%3E%3F%3C/text%3E%3C/svg%3E">
127
+ <span class="card-author-name card-summary__author-name">${escapeHtml(itemData.author || t('common.unknown_user'))}</span>
128
+ </button>
129
  </div>
130
  `;
131
 
 
139
  },
140
  compact: true
141
  });
142
+ ratingCompact.classList.add("card-rating-compact");
143
  const authorInfoInSummary = summaryView.querySelector(".card-author-info");
144
  if (authorInfoInSummary && authorInfoInSummary.parentNode) {
145
  authorInfoInSummary.parentNode.insertBefore(ratingCompact, authorInfoInSummary);
 
157
  openOtherUserProfileModal(itemData.author, currentUser);
158
  };
159
 
 
 
 
 
 
 
 
 
160
  // 使用统一辅助函数加载作者资料
161
  _loadAuthorProfile(itemData.author, {
162
  onName: (name) => { authorNameEl.textContent = name; },
 
166
  }
167
 
168
  const detailView = document.createElement("div");
169
+ detailView.id = `${cardId}-details`;
170
+ detailView.hidden = true;
171
+ detailView.setAttribute("role", "region");
172
+ detailView.setAttribute("aria-labelledby", `${cardId}-toggle`);
173
+ detailView.className = "resource-detail";
174
+ detailView.style.display = "none";
175
 
176
  const authorInfo = document.createElement("div");
177
+ authorInfo.className = "resource-detail__author";
178
 
179
  // 🔄 P7后悔模式:检查是否有待生效价格
180
  const priceHtml = _generatePriceHTML(itemData, t);
181
 
182
+ authorInfo.innerHTML = `${t('market.author')}: <button type="button" class="author-name-link card-author-link" ${itemData.author ? '' : 'disabled'}>${t('common.loading')}...</button> ${priceHtml}`;
183
  detailView.appendChild(authorInfo);
184
 
185
  const nameDOM = authorInfo.querySelector('.author-name-link');
 
194
  // 原创内容标识
195
  if (itemData.is_original) {
196
  const originalBadge = document.createElement("div");
197
+ originalBadge.className = "resource-detail__badge resource-detail__badge--original";
 
 
 
 
 
198
  originalBadge.textContent = "🎨 " + t('publish.mark_original');
199
  detailView.appendChild(originalBadge);
200
  }
 
202
  // 支持退款标识(仅工具、Skill和应用类型显示,且允许退款时)
203
  if (itemData.allow_refund !== false && (itemData.type === 'tool' || itemData.type === 'skill' || itemData.type === 'app')) {
204
  const refundBadge = document.createElement("div");
205
+ refundBadge.className = "resource-detail__badge resource-detail__badge--refundable";
 
 
 
 
 
206
  refundBadge.textContent = `🛡️ ${t('item.refundable')}`;
207
  detailView.appendChild(refundBadge);
208
  }
209
 
210
  const actionArea = document.createElement("div");
211
+ actionArea.className = "resource-detail__actions";
212
  const btnLike = document.createElement("button");
213
+ btnLike.type = "button";
214
  const btnFav = document.createElement("button");
215
+ btnFav.type = "button";
216
 
217
  // 🚀 根据安装状态显示不同的按钮样式(installStatus 已在上方声明)
218
  const btnUse = document.createElement("button");
219
+ btnUse.type = "button";
220
+ btnUse.className = "resource-detail__button resource-detail__use";
221
 
222
  if (installStatus.hasUpdate) {
223
  // 有更新可用
224
+ btnUse.classList.add("resource-detail__button--warning");
225
  btnUse.innerHTML = `♻️ ${t('market.update_available')}`;
226
  } else if (installStatus.installed) {
227
  // 已安装
228
+ btnUse.classList.add("resource-detail__button--success");
229
  btnUse.innerHTML = `✅ ${t('market.installed')}`;
230
  } else {
231
  // 未安装
232
+ btnUse.classList.add("resource-detail__button--primary");
233
+ btnUse.textContent = `🚀 ${t('market.get_now')} ${itemData.uses ? '(' + itemData.uses + ')' : ''}`;
234
  }
235
 
236
  actionArea.appendChild(btnLike); actionArea.appendChild(btnFav); actionArea.appendChild(btnUse);
 
246
  },
247
  currentUser: currentUser,
248
  authorAccount: itemData.author,
249
+ isCurrent: ratingCurrent,
250
+ onRate: async (score, requestContext) => {
251
+ const current = () => ratingCurrent() && (!requestContext || requestContext.isCurrent());
252
+ if (!current()) return null;
253
  const res = await api.rateItem(itemData.id, score);
254
+ if (!current()) return null;
255
+ if (isValidRatingResponse(res, score)) {
256
  itemData.rating_avg = res.rating_avg;
257
  itemData.rating_count = res.rating_count;
258
  itemData.rating_dist = res.rating_dist;
 
271
  },
272
  compact: true
273
  });
274
+ newCompact.classList.add("card-rating-compact");
275
  oldCompact.parentNode.replaceChild(newCompact, oldCompact);
276
  }
277
  }
 
285
  const isFav = currentUser && Array.isArray(itemData.favorited_by) && itemData.favorited_by.includes(currentUser.account);
286
 
287
  const handleInteraction = async (type, isActive) => {
288
+ if (!currentUser?.account) {
289
+ showToast(t('feedback.login_required'), "warning");
290
+ return { status: "cancelled" };
291
+ }
292
+ const response = await api.toggleInteraction(itemData.id, currentUser.account, type, isActive);
293
+ if (response?.status === "success" && Number.isSafeInteger(response.new_count) && response.new_count >= 0) {
294
+ const membershipKey = type === "like" ? "liked_by" : "favorited_by";
295
+ const countKey = type === "like" ? "likes" : "favorites";
296
+ const previousMembers = Array.isArray(itemData[membershipKey]) ? itemData[membershipKey] : [];
297
+ const members = previousMembers.filter(account => account !== currentUser.account);
298
+ if (isActive) members.push(currentUser.account);
299
+ itemData[membershipKey] = members;
300
+ itemData[countKey] = response.new_count;
301
+ const summaryCount = summaryView.querySelector(type === "like" ? '[data-stat="likes"]' : '[data-stat="favorites"]');
302
+ if (summaryCount) summaryCount.textContent = `${type === "like" ? "👍" : "🔖"} ${response.new_count}`;
303
+ }
304
+ return response ?? { status: "error" };
305
  };
306
 
307
  setupToggleButton(btnLike, isLiked, itemData.likes || 0, `👍 ${t('market.liked')}`, `👍 ${t('market.like')}`, "#FF5722", (isActive) => handleInteraction("like", isActive));
308
  setupToggleButton(btnFav, isFav, itemData.favorites || 0, `🔖 ${t('market.favorited')}`, `🔖 ${t('market.favorite')}`, "#FFC107", (isActive) => handleInteraction("favorite", isActive));
309
 
310
  const inlineStatusBox = document.createElement("div");
311
+ inlineStatusBox.className = "resource-detail__status";
312
+ inlineStatusBox.style.display = "none";
313
  detailView.appendChild(inlineStatusBox);
314
  setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox);
315
 
316
  const mediaArea = document.createElement("div");
317
  const chartContainerId = `chart-${Math.random().toString(36).substr(2, 9)}`;
318
+ const chartHtml = `<div class="resource-detail__heading">📈 ${t('market.usage_trend')}</div><div id="${chartContainerId}" class="resource-detail__chart"></div>`;
319
 
320
  mediaArea.innerHTML = `
321
  ${chartHtml}
322
  ${getCoverSandboxHTML(itemData.imageUrls?.length > 0 ? itemData.imageUrls : itemData.coverUrl)}
323
+ <div class="resource-detail__heading">📝 ${t('market.full_description')}</div>
324
+ <div class="resource-detail__description">${escapeHtml(itemData.fullDesc)}</div>
325
  `;
326
  detailView.appendChild(mediaArea);
327
 
328
  // 🚀 新增:赞赏贡献榜单与打赏按钮(带等级显示)
329
  const tipArea = document.createElement("div");
330
+ tipArea.className = "resource-detail__tips";
331
 
332
  // 使用统一的打赏榜单渲染工具(带星星/月亮/太阳等级)
333
+ const boardData = Array.isArray(itemData.tip_board)
334
+ ? itemData.tip_board.filter(entry => entry && typeof entry === "object" && !Array.isArray(entry))
335
+ : [];
336
  const boardHtml = renderTipBoardHTML(boardData, 5, t('market.no_tips_yet'), "small");
337
 
338
  tipArea.innerHTML = `
339
+ <div class="resource-detail__tip-header">
340
+ <div class="resource-detail__heading resource-detail__tip-heading">💖 ${t('market.tip_board')} (TOP 5)</div>
341
+ <button type="button" class="btn-tip-item resource-detail__button resource-detail__button--tip">💰 ${t('market.tip')}</button>
342
  </div>
343
  ${boardHtml}
344
  `;
345
  detailView.appendChild(tipArea);
346
 
347
  // 🚀 绑定打赏按钮事件
348
+ tipArea.querySelector('.btn-tip-item').onclick = (e) => {
349
  e.stopPropagation();
350
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
351
  // 🐛 Bug修复:防御性校验,防止 itemId 为空
 
353
  console.warn('⚠️ 列表卡片: itemData.id 为空,已拦截无效打赏请求');
354
  return showToast(t('item.invalid_id_tip'), "error");
355
  }
356
+ openTipModal(currentUser, { account: itemData.author }, undefined, itemData.id,
357
+ { isCurrent: () => card.isConnected });
 
 
 
 
 
 
 
 
 
 
 
 
358
  };
359
 
360
  // 🔄 P7后悔模式:退款区域(仅在"我购买的"列表中显示)
361
  const refundArea = document.createElement("div");
362
  refundArea.id = `refund-area-${itemData.id}`;
363
+ refundArea.className = "resource-detail__refund";
364
+ refundArea.style.display = "none";
365
  detailView.appendChild(refundArea);
366
 
367
  let isRendered = false;
368
+ const toggleButton = summaryView.querySelector('.card-summary__toggle');
369
+ const setExpanded = (expanded) => {
370
+ if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
371
+ detailView.hidden = !expanded;
372
+ detailView.style.display = expanded ? "block" : "none";
373
+ toggleButton.setAttribute("aria-expanded", String(expanded));
374
+ };
375
+ summaryView.onclick = (event) => {
376
+ // 摘要中的作者、评分等独立控件不触发折叠;标题按钮使用浏览器原生键盘行为。
377
+ const control = event.target.closest('button, a, input, select, textarea, [role="button"]');
378
+ if (control && control !== toggleButton) return;
379
+ const isHidden = detailView.hidden;
380
+ setExpanded(isHidden);
381
 
382
  if (isHidden && !isRendered) {
383
  renderItemTrendChart(detailView.querySelector(`#${chartContainerId}`), itemData);
 
391
  recordView(api.recordItemView, itemData.id, 'item', (result) => {
392
  if (result?.views !== undefined) {
393
  const viewsEl = summaryView.querySelector('[data-stat="views"]');
394
+ if (viewsEl) viewsEl.textContent = `🔥 ${result.views}`;
395
  }
396
  });
397
 
 
401
  }
402
  }
403
  };
404
+ detailView.addEventListener("keydown", (event) => {
405
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
406
+ event.preventDefault();
407
+ event.stopPropagation();
408
+ setExpanded(false);
409
+ toggleButton.focus();
410
+ });
411
 
412
  const commentsContainer = document.createElement("div");
413
+ commentsContainer.className = "resource-detail__comments";
414
 
415
  // 🚀 核心修改:传入一个回调函数,当组件内部发送/删除留言时,外部的数字能实时响应刷新!
416
  // 🚀 P0安全修复:传递 itemData.author 作为 contentAuthor,使内容作者可以删除其内容下的评论
 
424
 
425
  if (currentUser && currentUser.account === itemData.author) {
426
  const authorActionArea = document.createElement("div");
427
+ authorActionArea.className = "resource-detail__manage";
428
 
429
  authorActionArea.innerHTML = `
430
+ <div class="resource-detail__manage-title">👑 ${t('market.creator_manage')}</div>
431
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-item" class="resource-detail__button resource-detail__button--primary">✏️ ${t('market.edit_content')}</button>`}
432
+ <button type="button" id="btn-del-item" class="resource-detail__button resource-detail__button--danger">🗑️ ${t('market.delete_permanently')}</button>
433
  `;
434
 
435
  // 📱 Web 模式不渲染编辑按钮,此处需空值保护
 
441
 
442
  authorActionArea.querySelector("#btn-del-item").onclick = async (e) => {
443
  e.stopPropagation();
444
+ await showResourceDeleteDialog({ itemData, currentUser, isCurrent: () => card.isConnected,
445
+ onDeleted: () => window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload")) });
 
 
 
 
 
 
 
446
  };
447
  detailView.appendChild(authorActionArea);
448
  }
 
464
  */
465
  async function loadRefundStatus(refundArea, itemData, currentUser, card) {
466
  if (!refundArea || !currentUser) return;
467
+ const scope = createPurchaseStatusScope(refundArea, currentUser.account);
468
+ if (!scope.isCurrent()) return;
469
 
470
  refundArea.style.display = "block";
471
+ refundArea.innerHTML = `<div class="resource-detail__refund-loading">⏳ ${t('item.purchase_status_loading')}</div>`;
472
 
473
  try {
474
+ const statusRes = await api.getPurchaseStatus(currentUser.account, itemData.id, scope.readOptions);
475
+ if (!scope.isCurrent()) return;
476
+ if (!isValidPurchaseStatus(statusRes)) throw new Error();
477
 
478
  if (!statusRes.owned) {
479
  refundArea.innerHTML = '';
 
481
  return;
482
  }
483
 
484
+ if (statusRes.can_refund && statusRes.allow_refund) {
485
+ const hoursLeft = Number(statusRes.refund_hours_left);
486
+ if (!Number.isFinite(hoursLeft) || hoursLeft < 0) throw new Error("Invalid refund hours");
487
  refundArea.innerHTML = `
488
+ <div class="resource-detail__refund-panel">
489
+ <div class="resource-detail__refund-header">
490
+ <div class="resource-detail__refund-copy">
491
+ 🔄 ${t('item.regret_mode')}${t('item.hours_left', { hours: hoursLeft.toFixed(1) })}
492
  </div>
493
+ <button type="button" class="btn-refund-item resource-detail__button resource-detail__button--warning">
494
+ 💸 ${t('item.apply_refund')}
495
  </button>
496
  </div>
497
+ <div class="resource-detail__refund-warning">
498
+ ⚠️ ${t('item.refund_warning')}
499
  </div>
500
  </div>
501
  `;
502
 
503
+ const refundBtn = refundArea.querySelector('.btn-refund-item');
504
  if (refundBtn) {
505
  refundBtn.onclick = (e) => {
506
  e.stopPropagation();
507
+ if (!scope.isCurrent()) return;
508
  showRefundConfirm(itemData, currentUser, statusRes.price_paid, card);
509
  };
510
  }
511
  } else {
512
  refundArea.innerHTML = `
513
+ <div class="resource-detail__refund-expired">
514
+ ✅ ${t('item.refund_expired')}
515
  </div>
516
  `;
517
  }
518
  } catch (e) {
519
+ if (!scope.isCurrent()) return;
520
  refundArea.innerHTML = '';
521
  refundArea.style.display = "none";
522
  }
 
526
  * 显示退款确认弹窗
527
  */
528
  function showRefundConfirm(itemData, currentUser, pricePaid, card) {
529
+ let nextFocus;
530
+ return showResourceRefundDialog({
531
+ itemData, currentUser, pricePaid, isCurrent: () => !!card?.isConnected,
532
+ onRefunded: () => {
533
+ nextFocus = (card?.nextElementSibling || card?.previousElementSibling)?.querySelector('.card-summary__toggle');
534
+ card?.remove();
535
+ },
536
+ onClosed: () => {
537
+ const target = nextFocus?.isConnected ? nextFocus : document.querySelector('.comfy-ranking-root .card-summary__toggle');
538
+ target?.focus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  }
540
+ });
541
+ }
前端页面/market/创作者卡片组件.js CHANGED
@@ -48,7 +48,7 @@ let currentExpandedCard = null;
48
  * @param {object} currentUser - 当前登录用户
49
  * @param {Function} onTipSuccess - 打赏成功回调
50
  */
51
- async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
52
  // 检查用户是否登录
53
  if (!currentUser) {
54
  showToast(t('creator.tip_login_required'), "warning");
@@ -66,15 +66,9 @@ async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
66
  account: creatorData.account,
67
  name: creatorData.name
68
  }, (newBalance) => {
69
- // 🐛 Bug修复:打赏成功后同步余额并通知其他组件
70
- if (newBalance !== undefined && newBalance !== null) {
71
- currentUser.balance = newBalance;
72
- }
73
- // 派发全局余额更新事件
74
- window.dispatchEvent(new CustomEvent('comfy-wallet-update', { detail: { balance: currentUser.balance } }));
75
  // 执行原有成功回调
76
  if (onTipSuccess) onTipSuccess();
77
- });
78
  }
79
 
80
  function loadECharts() {
@@ -138,7 +132,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
138
  if (creatorData) {
139
  const tipBtn = container.querySelector('#btn-tip-creator-empty');
140
  if (tipBtn) {
141
- tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess);
142
  }
143
  }
144
 
@@ -210,7 +204,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
210
  if (creatorData) {
211
  const tipBtn = container.querySelector('#btn-tip-creator');
212
  if (tipBtn) {
213
- tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess);
214
  }
215
  }
216
 
 
48
  * @param {object} currentUser - 当前登录用户
49
  * @param {Function} onTipSuccess - 打赏成功回调
50
  */
51
+ async function handleTipCreator(creatorData, currentUser, onTipSuccess, parentCurrent) {
52
  // 检查用户是否登录
53
  if (!currentUser) {
54
  showToast(t('creator.tip_login_required'), "warning");
 
66
  account: creatorData.account,
67
  name: creatorData.name
68
  }, (newBalance) => {
 
 
 
 
 
 
69
  // 执行原有成功回调
70
  if (onTipSuccess) onTipSuccess();
71
+ }, null, { isCurrent: parentCurrent });
72
  }
73
 
74
  function loadECharts() {
 
132
  if (creatorData) {
133
  const tipBtn = container.querySelector('#btn-tip-creator-empty');
134
  if (tipBtn) {
135
+ tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, () => container.isConnected);
136
  }
137
  }
138
 
 
204
  if (creatorData) {
205
  const tipBtn = container.querySelector('#btn-tip-creator');
206
  if (tipBtn) {
207
+ tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, () => container.isConnected);
208
  }
209
  }
210
 
前端页面/market/发布内容_UI模板.js CHANGED
@@ -3,141 +3,127 @@ import { escapeHtml } from "../core/全局配置.js";
3
 
4
  export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
5
  const safeData = editItemData || {};
6
-
7
  return `
8
- <style>.form-input{width:100%;padding:8px;background:var(--comfy-input-bg);border:1px solid #555;color:#fff;border-radius:4px;box-sizing:border-box;}</style>
9
- <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 5px; border-bottom: 1px solid #444; padding-bottom: 15px;">
10
- <!-- 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移 -->
11
- <button id="btn-back" style="margin-left: 15px; margin-top: 15px; background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
12
- ⬅ ${t('common.back')}
13
- </button>
14
- <span style="font-size: 16px; font-weight: bold; color: #fff;">${escapeHtml(viewTitle)}</span>
15
- </div>
16
 
17
- <div style="display: flex; gap: 10px; margin-bottom: 5px;">
18
- <div style="flex: 1;">
19
- <label style="display: block; margin-bottom: 5px;">${t('publish.main_category')} <span style="color: #F44336;">*</span></label>
20
- <select id="pub-type" class="form-input">
21
- <option value="tool">🧰 ${t('publish.type_tool')}</option>
22
- <option value="skill">⚡ ${t('publish.type_skill')}</option>
23
- <option value="app">📦 ${t('publish.type_app')}</option>
24
- <option value="recommend">🌟 ${t('publish.type_recommend')}</option>
25
  </select>
26
  </div>
27
- <div id="box-recommend-type" style="display: none; flex: 1;">
28
- <label style="display: block; margin-bottom: 5px;">${t('publish.recommend_form')} <span style="color: #F44336;">*</span></label>
29
- <select id="pub-recommend-type" class="form-input">
30
- <option value="recommend_tool">🧰 ${t('publish.recommend_as_tool')}</option>
31
- <option value="recommend_skill">⚡ ${t('publish.recommend_as_skill')}</option>
32
- <option value="recommend_app">📦 ${t('publish.recommend_as_app')}</option>
33
- <option value="recommend_link">🔗 ${t('publish.recommend_as_link')}</option>
34
  </select>
35
  </div>
36
- <div style="flex: 2;">
37
- <label style="display: block; margin-bottom: 5px;">${t('publish.name')} <span style="color: #F44336;">*</span></label>
38
- <input type="text" id="pub-title" placeholder="${t('publish.name_placeholder')}" required class="form-input">
39
  </div>
40
  </div>
41
 
42
- <div style="margin-bottom: 5px;">
43
- <label style="display: block; margin-bottom: 5px;">${t('publish.short_desc')} <span style="color: #F44336;">*</span></label>
44
- <input type="text" id="pub-short" placeholder="${t('publish.short_desc_placeholder')}" maxlength="50" required class="form-input">
45
  </div>
46
 
47
- <div style="display: flex; gap: 10px; margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 4px; border: 1px dashed #555;">
48
- <div style="flex: 1;" id="box-resource-select">
49
- <label style="display: block; margin-bottom: 5px;">${t('publish.resource_type')} <span style="color: #F44336;">*</span></label>
50
- <select id="pub-resource-type" class="form-input">
51
- <option value="link">🔗 ${t('publish.resource_link')}</option>
52
- <option value="json">📄 ${t('publish.resource_json')}</option>
53
- <option value="netdisk">☁️ ${t('publish.resource_netdisk')}</option>
54
  </select>
55
  </div>
56
- <div style="flex: 2;" id="box-link">
57
- <label style="display: block; margin-bottom: 5px;">${t('publish.source_url')} <span style="color: #F44336;">*</span></label>
58
- <input type="text" id="pub-link" placeholder="${t('publish.source_url_placeholder')}" class="form-input">
59
  </div>
60
- <div style="flex: 2; display: none;" id="box-json">
61
- <label style="display: block; margin-bottom: 5px;">${t('publish.select_json')} <span style="color: #F44336;">*</span> <span id="json-file-hint" style="color:#4CAF50; font-size:12px; font-weight:normal; display:none;">(${t('publish.has_cloud_file')})</span></label>
62
- <input type="file" id="pub-json" accept=".json" style="width: 100%; padding: 6px; color: #aaa; font-size: 12px;">
63
  </div>
64
- <div style="flex: 2; display: none;" id="box-netdisk">
65
- <label style="display: block; margin-bottom: 5px;">${t('publish.netdisk_link')} <span style="color: #F44336;">*</span></label>
66
- <input type="text" id="pub-netdisk-link" placeholder="${t('publish.netdisk_placeholder')}" class="form-input">
67
  </div>
68
  </div>
69
 
70
- <div id="netdisk-password-settings" style="margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 4px; border: 1px dashed #555; display: none;">
71
- <div style="display: flex; gap: 10px; align-items: flex-start;">
72
- <div style="flex: 1;">
73
- <label style="display: block; margin-bottom: 6px; color: #FF9800; font-weight: bold;">🔐 ${t('publish.netdisk_password')}</label>
74
- <input type="text" id="pub-netdisk-password" placeholder="${t('publish.netdisk_password_placeholder')}" maxlength="20" class="form-input" style="background: #222;">
75
- </div>
76
- <div style="flex: 2; padding-top: 28px;">
77
- <div style="font-size: 11px; color: #888; line-height: 1.4;">🔒 <span style="color: #4CAF50; font-weight: bold;">${t('publish.security_guarantee')}</span>:${t('publish.password_encrypted_hint')}</div>
78
  </div>
 
79
  </div>
80
  </div>
81
 
82
- <div id="private-repo-settings" style="margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 4px; border: 1px dashed #555; display: none;">
83
- <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #aaa; font-size: 13px;">
84
- <input type="checkbox" id="pub-is-private" ${hasExistingToken ? 'checked' : ''} style="cursor: pointer;">
85
- ${t('publish.private_repo_hint')}
86
  </label>
87
- <div id="github-token-container" style="display: ${hasExistingToken ? 'block' : 'none'}; margin-top: 10px; padding: 12px; background: rgba(33, 150, 243, 0.1); border: 1px dashed #2196F3; border-radius: 6px;">
88
- <label style="display: block; margin-bottom: 6px; color: #2196F3; font-weight: bold;">🔒 ${t('publish.pat_label')} <span style="color: #F44336;">*</span></label>
89
- <div style="font-size: 12px; color: #aaa; margin-bottom: 8px; line-height: 1.4;">${t('publish.pat_hint')}</div>
90
- <input type="password" id="pub-github-token" value="${isEditMode ? (safeData.github_token || '') : ''}" placeholder="${hasExistingToken && !safeData.github_token ? t('publish.token_keep_hint') : t('publish.token_placeholder')}" class="form-input" style="background: #222;">
91
  </div>
92
  </div>
93
 
94
- <div id="box-cover" style="margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 4px; border: 1px dashed #555;">
95
- <label style="display: block; margin-bottom: 5px;">🖼️ ${t('publish.effect_images')} <span id="cover-file-hint" style="color:#4CAF50; font-size:12px; font-weight:normal; display:none;">(${t('publish.reselect_override')})</span></label>
96
- <input type="file" id="pub-images" accept="image/*" multiple style="width: 100%; padding: 4px; color: #aaa; font-size: 12px;">
97
- <div style="font-size: 11px; color: #888; margin-top: 4px;">${t('publish.drag_select_hint')}</div>
98
- <div id="pub-images-preview" style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px;"></div>
99
  </div>
100
 
101
- <div id="box-price" style="display: flex; gap: 10px; margin-bottom: 5px;">
102
- <div style="flex: 1; display: flex; flex-direction: column; justify-content: center;">
103
- <label style="display: block; margin-bottom: 5px;">${t('publish.price')} <span style="color: #F44336;">*</span></label>
104
- <input type="number" id="pub-price" value="0" min="0" class="form-input" style="color: #FF9800; font-weight: bold;">
105
- <!-- 🔄 P7后悔模式:价格延迟生效提示 -->
106
- <div id="price-pending-hint" style="display: none; margin-top: 6px; padding: 6px 8px; background: rgba(255,152,0,0.15); border: 1px dashed #FF9800; border-radius: 4px; font-size: 11px; color: #FF9800;">
107
- </div>
108
- </div>
109
- <div style="flex: 2;">
110
- <div style="font-size: 11px; color: #888; margin-top: 25px; line-height: 1.4;">${t('publish.price_hint')}<br>⚠️ <strong>${t('publish.price_delay_hint')}</strong></div>
111
  </div>
 
112
  </div>
113
 
114
- <div style="margin-bottom: 10px;">
115
- <label style="display: block; margin-bottom: 5px;">${t('publish.full_desc')}</label>
116
- <textarea id="pub-full" rows="36" placeholder="${t('publish.full_desc_placeholder')}" class="form-input" style="resize: vertical;"></textarea>
117
  </div>
118
 
119
- <!-- 原创作品勾选框 -->
120
- <div style="margin-bottom: 15px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px;">
121
- <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
122
- <input type="checkbox" id="is-original-checkbox" ${safeData.is_original ? 'checked' : ''} style="width: 16px; height: 16px; cursor: pointer; accent-color: #4CAF50;" />
123
- <span style="font-size: 13px; color: #ccc;">🎨 ${t('publish.mark_as_original')}</span>
124
  </label>
125
- <div id="original-hint-text" style="font-size: 11px; color: #888; margin-top: 6px; padding-left: 24px;">
126
- ${t('publish.original_default_hint')}
127
- </div>
128
  </div>
129
 
130
- <!-- 💸 是否支持退款勾选框 (仅 tool/app 类型显示) -->
131
- <div id="box-allow-refund" style="margin-bottom: 15px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px;">
132
- <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
133
- <input type="checkbox" id="allow-refund-checkbox" ${safeData.allow_refund !== false ? 'checked' : ''} style="width: 16px; height: 16px; cursor: pointer; accent-color: #2196F3;" />
134
- <span style="font-size: 13px; color: #ccc;">💸 ${t('publish.allow_refund')}</span>
135
  </label>
136
- <div style="font-size: 11px; color: #888; margin-top: 6px; padding-left: 24px;">
137
- ${t('publish.refund_hint')}
138
- </div>
139
  </div>
140
 
141
- <button id="btn-submit-publish" style="width: 100%; padding: 12px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 15px; transition: 0.3s; margin-bottom: 20px;">${escapeHtml(submitBtnText)}</button>
 
142
  `;
143
- }
 
3
 
4
  export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
5
  const safeData = editItemData || {};
6
+
7
  return `
8
+ <section class="ranking-publish" aria-labelledby="publish-view-title">
9
+ <header class="ranking-publish__header">
10
+ <button type="button" id="btn-back" class="ranking-publish__back">⬅ ${escapeHtml(t('common.back'))}</button>
11
+ <h2 id="publish-view-title" class="ranking-publish__title">${escapeHtml(viewTitle)}</h2>
12
+ </header>
 
 
 
13
 
14
+ <div class="ranking-publish__row">
15
+ <div class="ranking-publish__field">
16
+ <label for="pub-type">${escapeHtml(t('publish.main_category'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
17
+ <select id="pub-type" class="form-input" aria-required="true">
18
+ <option value="tool">🧰 ${escapeHtml(t('publish.type_tool'))}</option>
19
+ <option value="skill">⚡ ${escapeHtml(t('publish.type_skill'))}</option>
20
+ <option value="app">📦 ${escapeHtml(t('publish.type_app'))}</option>
21
+ <option value="recommend">🌟 ${escapeHtml(t('publish.type_recommend'))}</option>
22
  </select>
23
  </div>
24
+ <div id="box-recommend-type" class="ranking-publish__field" style="display: none;">
25
+ <label for="pub-recommend-type">${escapeHtml(t('publish.recommend_form'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
26
+ <select id="pub-recommend-type" class="form-input" aria-required="true">
27
+ <option value="recommend_tool">🧰 ${escapeHtml(t('publish.recommend_as_tool'))}</option>
28
+ <option value="recommend_skill">⚡ ${escapeHtml(t('publish.recommend_as_skill'))}</option>
29
+ <option value="recommend_app">📦 ${escapeHtml(t('publish.recommend_as_app'))}</option>
30
+ <option value="recommend_link">🔗 ${escapeHtml(t('publish.recommend_as_link'))}</option>
31
  </select>
32
  </div>
33
+ <div class="ranking-publish__field ranking-publish__field--wide">
34
+ <label for="pub-title">${escapeHtml(t('publish.name'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
35
+ <input type="text" id="pub-title" placeholder="${escapeHtml(t('publish.name_placeholder'))}" required class="form-input">
36
  </div>
37
  </div>
38
 
39
+ <div class="ranking-publish__field">
40
+ <label for="pub-short">${escapeHtml(t('publish.short_desc'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
41
+ <input type="text" id="pub-short" placeholder="${escapeHtml(t('publish.short_desc_placeholder'))}" maxlength="50" required class="form-input">
42
  </div>
43
 
44
+ <div class="ranking-publish__row ranking-publish__panel">
45
+ <div class="ranking-publish__field" id="box-resource-select">
46
+ <label for="pub-resource-type">${escapeHtml(t('publish.resource_type'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
47
+ <select id="pub-resource-type" class="form-input" aria-required="true">
48
+ <option value="link">🔗 ${escapeHtml(t('publish.resource_link'))}</option>
49
+ <option value="json">📄 ${escapeHtml(t('publish.resource_json'))}</option>
50
+ <option value="netdisk">☁️ ${escapeHtml(t('publish.resource_netdisk'))}</option>
51
  </select>
52
  </div>
53
+ <div class="ranking-publish__field ranking-publish__field--wide" id="box-link">
54
+ <label for="pub-link">${escapeHtml(t('publish.source_url'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
55
+ <input type="text" id="pub-link" placeholder="${escapeHtml(t('publish.source_url_placeholder'))}" class="form-input" aria-required="true">
56
  </div>
57
+ <div class="ranking-publish__field ranking-publish__field--wide" id="box-json" style="display: none;">
58
+ <label for="pub-json">${escapeHtml(t('publish.select_json'))} <span class="ranking-publish__required" aria-hidden="true">*</span> <span id="json-file-hint" class="ranking-publish__file-hint" style="display: none;">(${escapeHtml(t('publish.has_cloud_file'))})</span></label>
59
+ <input type="file" id="pub-json" accept=".json" aria-describedby="json-file-hint" class="ranking-publish__file">
60
  </div>
61
+ <div class="ranking-publish__field ranking-publish__field--wide" id="box-netdisk" style="display: none;">
62
+ <label for="pub-netdisk-link">${escapeHtml(t('publish.netdisk_link'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
63
+ <input type="text" id="pub-netdisk-link" placeholder="${escapeHtml(t('publish.netdisk_placeholder'))}" class="form-input" aria-required="true">
64
  </div>
65
  </div>
66
 
67
+ <div id="netdisk-password-settings" class="ranking-publish__panel" style="display: none;">
68
+ <div class="ranking-publish__row">
69
+ <div class="ranking-publish__field">
70
+ <label for="pub-netdisk-password" class="ranking-publish__security-label">🔐 ${escapeHtml(t('publish.netdisk_password'))}</label>
71
+ <input type="text" id="pub-netdisk-password" placeholder="${escapeHtml(t('publish.netdisk_password_placeholder'))}" maxlength="20" class="form-input" aria-describedby="publish-password-hint">
 
 
 
72
  </div>
73
+ <div id="publish-password-hint" class="ranking-publish__hint ranking-publish__field--wide ranking-publish__aligned-hint">🔒 <strong>${escapeHtml(t('publish.security_guarantee'))}</strong>:${escapeHtml(t('publish.password_encrypted_hint'))}</div>
74
  </div>
75
  </div>
76
 
77
+ <div id="private-repo-settings" class="ranking-publish__panel" style="display: none;">
78
+ <label class="ranking-publish__checkbox">
79
+ <input type="checkbox" id="pub-is-private" ${hasExistingToken ? 'checked' : ''}>
80
+ <span>${escapeHtml(t('publish.private_repo_hint'))}</span>
81
  </label>
82
+ <div id="github-token-container" class="ranking-publish__token" style="display: ${hasExistingToken ? 'block' : 'none'};">
83
+ <label for="pub-github-token" class="ranking-publish__security-label">🔒 ${escapeHtml(t('publish.pat_label'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
84
+ <div id="publish-pat-hint" class="ranking-publish__hint">${escapeHtml(t('publish.pat_hint'))}</div>
85
+ <input type="password" id="pub-github-token" value="${escapeHtml(isEditMode ? (safeData.github_token || '') : '')}" placeholder="${hasExistingToken && !safeData.github_token ? escapeHtml(t('publish.token_keep_hint')) : escapeHtml(t('publish.token_placeholder'))}" class="form-input" aria-describedby="publish-pat-hint">
86
  </div>
87
  </div>
88
 
89
+ <div id="box-cover" class="ranking-publish__panel ranking-publish__field">
90
+ <label for="pub-images">🖼️ ${escapeHtml(t('publish.effect_images'))} <span id="cover-file-hint" class="ranking-publish__file-hint" style="display: none;">(${escapeHtml(t('publish.reselect_override'))})</span></label>
91
+ <input type="file" id="pub-images" accept="image/*" multiple class="ranking-publish__file" aria-describedby="publish-images-hint cover-file-hint">
92
+ <div id="publish-images-hint" class="ranking-publish__hint">${escapeHtml(t('publish.drag_select_hint'))}</div>
93
+ <div id="pub-images-preview" class="ranking-publish__preview"></div>
94
  </div>
95
 
96
+ <div id="box-price" class="ranking-publish__row">
97
+ <div class="ranking-publish__field">
98
+ <label for="pub-price">${escapeHtml(t('publish.price'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
99
+ <input type="number" id="pub-price" value="0" min="0" class="form-input ranking-publish__price" aria-required="true" aria-describedby="publish-price-hint price-pending-hint">
100
+ <div id="price-pending-hint" class="ranking-publish__pending" role="status" style="display: none;"></div>
 
 
 
 
 
101
  </div>
102
+ <div id="publish-price-hint" class="ranking-publish__hint ranking-publish__field--wide ranking-publish__aligned-hint">${escapeHtml(t('publish.price_hint'))}<br>⚠️ <strong>${escapeHtml(t('publish.price_delay_hint'))}</strong></div>
103
  </div>
104
 
105
+ <div class="ranking-publish__field">
106
+ <label for="pub-full">${escapeHtml(t('publish.full_desc'))}</label>
107
+ <textarea id="pub-full" rows="36" placeholder="${escapeHtml(t('publish.full_desc_placeholder'))}" class="form-input ranking-publish__description"></textarea>
108
  </div>
109
 
110
+ <div class="ranking-publish__panel">
111
+ <label class="ranking-publish__checkbox">
112
+ <input type="checkbox" id="is-original-checkbox" ${safeData.is_original ? 'checked' : ''} aria-describedby="original-hint-text" />
113
+ <span>🎨 ${escapeHtml(t('publish.mark_as_original'))}</span>
 
114
  </label>
115
+ <div id="original-hint-text" class="ranking-publish__hint ranking-publish__checkbox-hint">${escapeHtml(t('publish.original_default_hint'))}</div>
 
 
116
  </div>
117
 
118
+ <div id="box-allow-refund" class="ranking-publish__panel">
119
+ <label class="ranking-publish__checkbox">
120
+ <input type="checkbox" id="allow-refund-checkbox" ${safeData.allow_refund !== false ? 'checked' : ''} aria-describedby="publish-refund-hint" />
121
+ <span>💸 ${escapeHtml(t('publish.allow_refund'))}</span>
 
122
  </label>
123
+ <div id="publish-refund-hint" class="ranking-publish__hint ranking-publish__checkbox-hint">${escapeHtml(t('publish.refund_hint'))}</div>
 
 
124
  </div>
125
 
126
+ <button type="button" id="btn-submit-publish" class="ranking-publish__submit">${escapeHtml(submitBtnText)}</button>
127
+ </section>
128
  `;
129
+ }
前端页面/market/发布内容_提交引擎.js CHANGED
@@ -1,4 +1,8 @@
 
 
 
1
  // 前端页面/market/发布内容_提交引擎.js
 
2
  import { api } from "../core/网络请求API.js";
3
  import { showToast } from "../components/UI交互提示组件.js";
4
  import { t } from "../components/用户体验增强.js";
@@ -46,112 +50,6 @@ function clearItemCacheByType(type) {
46
  console.log(`🗑️ 已清除 [${tab}] 类型的列表缓存`);
47
  }
48
 
49
- /**
50
- * 通用图片处理核心函数:加载图片 → Canvas绘制 → 转换为JPG文件
51
- * @param {File} file - 原始图片文件
52
- * @param {Object} options - 配置选项
53
- * @param {boolean} options.cropToSquare - 是否中心裁剪为正方形
54
- * @param {number|null} options.targetSize - 目标尺寸(正方形边长),null则保持原始尺寸
55
- * @param {number} options.quality - 初始JPG质量 (0-1)
56
- * @param {string|null} options.fillBackground - 背景填充颜色,null则不填充
57
- * @param {number|null} options.maxFileSize - 最大文件大小(字节),超限则迭代降低质量
58
- * @param {boolean} options.skipIfSmallJpeg - 是否跳过已是小体积JPEG的文件
59
- * @param {string} options.suffix - 输出文件名后缀
60
- * @returns {Promise<File>} 处理后的文件
61
- */
62
- function _processImageCore(file, options = {}) {
63
- const {
64
- cropToSquare = false,
65
- targetSize = null,
66
- quality = 0.85,
67
- fillBackground = null,
68
- maxFileSize = null,
69
- skipIfSmallJpeg = false,
70
- suffix = '.jpg'
71
- } = options;
72
-
73
- return new Promise((resolve, reject) => {
74
- const img = new Image();
75
- const url = URL.createObjectURL(file);
76
-
77
- img.onload = () => {
78
- URL.revokeObjectURL(url);
79
-
80
- const canvas = document.createElement('canvas');
81
- const ctx = canvas.getContext('2d');
82
-
83
- let sourceX = 0, sourceY = 0;
84
- let sourceW = img.naturalWidth, sourceH = img.naturalHeight;
85
- let destW = img.naturalWidth, destH = img.naturalHeight;
86
-
87
- // 中心裁剪为正方形
88
- if (cropToSquare) {
89
- const sourceMin = Math.min(sourceW, sourceH);
90
- sourceX = (sourceW - sourceMin) / 2;
91
- sourceY = (sourceH - sourceMin) / 2;
92
- sourceW = sourceMin;
93
- sourceH = sourceMin;
94
- destW = sourceMin;
95
- destH = sourceMin;
96
- }
97
-
98
- // 指定目标尺寸时缩放
99
- if (targetSize) {
100
- destW = targetSize;
101
- destH = targetSize;
102
- }
103
-
104
- canvas.width = destW;
105
- canvas.height = destH;
106
-
107
- // 填充背景色
108
- if (fillBackground) {
109
- ctx.fillStyle = fillBackground;
110
- ctx.fillRect(0, 0, canvas.width, canvas.height);
111
- }
112
-
113
- ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, destW, destH);
114
-
115
- // 跳过已是小体积JPEG的文件
116
- if (skipIfSmallJpeg && maxFileSize &&
117
- (file.type === 'image/jpeg' || file.type === 'image/jpg') &&
118
- file.size <= maxFileSize) {
119
- resolve(file);
120
- return;
121
- }
122
-
123
- // 转换为JPG Blob,超限时迭代降低质量
124
- let currentQuality = quality;
125
- const tryCompress = () => {
126
- canvas.toBlob((blob) => {
127
- if (!blob) {
128
- reject(new Error('Canvas 转换 Blob 失败'));
129
- return;
130
- }
131
-
132
- if (maxFileSize && blob.size > maxFileSize && currentQuality > 0.3) {
133
- currentQuality -= 0.1;
134
- tryCompress();
135
- return;
136
- }
137
-
138
- const newFileName = file.name.replace(/\.[^/.]+$/, '') + suffix;
139
- resolve(new File([blob], newFileName, { type: 'image/jpeg' }));
140
- }, 'image/jpeg', currentQuality);
141
- };
142
-
143
- tryCompress();
144
- };
145
-
146
- img.onerror = () => {
147
- URL.revokeObjectURL(url);
148
- reject(new Error('图片加载失败'));
149
- };
150
-
151
- img.src = url;
152
- });
153
- }
154
-
155
  /**
156
  * 🟢 利用 HTML5 Canvas 纯前端裁剪并压缩头像
157
  * 功能:强制 1:1 比例中心裁剪,缩放到 512x512,转换为 JPG 格式。
@@ -159,7 +57,8 @@ function _processImageCore(file, options = {}) {
159
  function processAvatar(file) {
160
  return _processImageCore(file, {
161
  cropToSquare: true,
162
- targetSize: 512,
 
163
  quality: 0.85,
164
  suffix: '_cropped.jpg'
165
  }).then(processedFile => {
@@ -171,7 +70,7 @@ function processAvatar(file) {
171
  /**
172
  * 上传前图片压缩处理
173
  * - 转为 JPG 格式
174
- * - 超过 5MB 时进行质量压缩,不改变宽高比例
175
  * @param {File} file - 原始图片文件
176
  * @returns {Promise<File>} - 处理后的文件
177
  */
@@ -181,65 +80,54 @@ export async function compressImageForUpload(file) {
181
  return file;
182
  }
183
 
184
- // GIF 动图保留原始格式,不转换为 JPG
185
- if (file.type === 'image/gif') {
186
- const GIF_MAX_SIZE = 50 * 1024 * 1024; // 50MB
187
- if (file.size > GIF_MAX_SIZE) {
188
- throw new Error(`GIF文件过大(${(file.size/1024/1024).toFixed(1)}MB),最大允许50MB`);
189
- }
190
- console.log(`🎞️ GIF动图保留原始格式: ${(file.size/1024/1024).toFixed(2)}MB`);
191
- return file;
192
- }
193
-
194
- try {
195
- const processedFile = await _processImageCore(file, {
196
- quality: 0.92,
197
- fillBackground: '#FFFFFF',
198
- maxFileSize: 5 * 1024 * 1024,
199
- skipIfSmallJpeg: true,
200
- suffix: '.jpg'
201
- });
202
- console.log(`📦 图片压缩: ${(file.size/1024/1024).toFixed(2)}MB → ${(processedFile.size/1024/1024).toFixed(2)}MB`);
203
- return processedFile;
204
- } catch (e) {
205
- // 加载失败,返回原文件
206
- return file;
207
  }
 
 
 
 
 
 
208
  }
209
 
210
  /**
211
  * 🟢 新增:全局统一的文件上传包装器(供个人设置等其它组件调用)
212
  * 会在上传前自动拦截并处理头像图片
213
  */
214
- export async function uploadFile(file, file_type) {
 
 
 
215
  try {
216
  let fileToUpload = file;
217
 
218
  // 拦截点:如果是头像,就在纯内存中先进行裁剪、缩放、压缩
219
  if (file_type === "avatar" && file.type && file.type.startsWith("image/")) {
220
- showToast(`🖼️ ${t('publish.processing_avatar')}`);
221
  fileToUpload = await processAvatar(file);
222
  }
223
 
224
- showToast(`🌐 ${t('publish.uploading_file', { size: (fileToUpload.size/1024).toFixed(1) })}`);
 
225
  const res = await api.uploadFile(fileToUpload, file_type);
226
 
 
227
  if (res.status === "success") {
228
- showToast(`✅ ${t('publish.upload_success')}`);
229
  return res.url;
230
  } else {
231
- showToast(`❌ ${t('publish.upload_failed')}: ${res.error || res.message}`, "error");
232
  return null;
233
  }
234
  } catch (error) {
235
- console.error("Upload Error:", error);
236
- showToast(`❌ ${t('publish.upload_error')}: ${error.message}`, "error");
237
  return null;
238
  }
239
  }
240
 
241
  // ==========================================
242
- // 原有的发布表单提交逻辑 (完美保留,无任何删减)
243
  // ==========================================
244
  export async function handlePublishSubmit(params) {
245
  const {
@@ -247,6 +135,13 @@ export async function handlePublishSubmit(params) {
247
  imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
248
  } = params;
249
 
 
 
 
 
 
 
 
250
  const typeSelect = container.querySelector("#pub-type");
251
  const recommendTypeSelect = container.querySelector("#pub-recommend-type");
252
  const resTypeSelect = container.querySelector("#pub-resource-type");
@@ -264,7 +159,8 @@ export async function handlePublishSubmit(params) {
264
  const title = container.querySelector("#pub-title").value.trim();
265
  const shortDesc = container.querySelector("#pub-short").value.trim();
266
  const fullDesc = container.querySelector("#pub-full").value.trim();
267
- const price = rules.price ? (parseFloat(container.querySelector("#pub-price").value) || 0) : 0;
 
268
 
269
  let finalLink = inputLink.value.trim();
270
  let isJsonUpload = (resTypeSelect.value === "json") || (type === "recommend_app");
@@ -315,15 +211,18 @@ export async function handlePublishSubmit(params) {
315
  const allowRefundCheckbox = container.querySelector("#allow-refund-checkbox");
316
  const allowRefund = rules.refund ? (allowRefundCheckbox?.checked ?? true) : null;
317
 
 
 
318
  submitBtn.innerHTML = `⏳ ${t('publish.connecting')}`;
319
  submitBtn.disabled = true;
320
- submitBtn.style.background = "#555";
321
 
322
  try {
323
  if (isJsonUpload && jsonFile) {
324
  submitBtn.innerHTML = `⏳ ${t('publish.uploading_secure')}`;
325
  const uploadType = type.includes("app") ? "app" : (type.includes("tool") || type === "skill" ? "tool" : "recommend");
326
  const jsonUploadRes = await api.uploadFile(jsonFile, uploadType);
 
 
327
  finalLink = jsonUploadRes.url;
328
  }
329
 
@@ -331,19 +230,21 @@ export async function handlePublishSubmit(params) {
331
  let coverUrl = isEditMode ? editItemData.coverUrl : null;
332
  let imageUrls = isEditMode ? (editItemData.imageUrls || []) : [];
333
 
334
- if (imageFiles && imageFiles.length > 0) {
335
- submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: 0, total: imageFiles.length })}`;
336
  const uploadedUrls = [];
337
 
338
- for (let i = 0; i < imageFiles.length; i++) {
339
- submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: i + 1, total: imageFiles.length })}`;
340
  // 上传前压缩:转JPG,超5MB压缩
341
- const processedFile = await compressImageForUpload(imageFiles[i]);
 
342
  const uploadRes = await api.uploadFile(processedFile, "cover");
343
- if (uploadRes && uploadRes.url) {
 
344
  uploadedUrls.push(uploadRes.url);
345
  } else {
346
- throw new Error(`图片上传失败: ${uploadRes?.error || '未知错误'}`);
347
  }
348
  }
349
 
@@ -351,12 +252,15 @@ export async function handlePublishSubmit(params) {
351
  imageUrls = uploadedUrls; // 全部图片URL
352
  }
353
 
 
354
  submitBtn.innerHTML = `⏳ ${t('publish.syncing')}`;
355
  // 🖼️ 统一封面逻辑:第一张图片始终作为封面(兑容拖拽排序后的顺序变化)
356
  if (imageUrls && imageUrls.length > 0) {
357
  coverUrl = imageUrls[0];
358
  }
359
  const submitData = {
 
 
360
  type, title, shortDesc, fullDesc, price, link: finalLink, coverUrl, imageUrls, // 🖼️ 添加 imageUrls
361
  author: currentUser.account, github_token,
362
  netdisk_password, // ☁️ 网盘密码(后端加密存储)
@@ -369,22 +273,40 @@ export async function handlePublishSubmit(params) {
369
  submitData.allow_refund = allowRefund;
370
  }
371
 
372
- if (isEditMode) {
373
- await api.updateItem(editItemData.id, currentUser.account, submitData);
374
- showToast(`✅ ${t('publish.save_success')}`, "success");
375
- } else {
376
- await api.publishItem(submitData);
377
- showToast(`🎉 ${t('publish.publish_success')}`, "success");
 
 
 
 
 
 
378
  }
379
-
380
- // 🚀 精确清除:只清除当前发布类型的缓存
381
- clearItemCacheByType(type);
382
-
383
- if (onSuccessCallback) onSuccessCallback();
384
  } catch (err) {
385
- showToast(`${t('common.operation_failed')}: ` + err.message, "error");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  submitBtn.disabled = false;
387
  submitBtn.innerHTML = submitBtnText;
388
- submitBtn.style.background = "#2196F3";
 
389
  }
390
- }
 
1
+ import { processImageCore as _processImageCore } from "./有界图片处理.js";
2
+ import { validateImageFile } from "./图片文件检查.js";
3
+ import { IMAGE_PROCESS } from "../core/全局配置.js";
4
  // 前端页面/market/发布内容_提交引擎.js
5
+ import { createPublishSubmissionScope } from "./发布提交生命周期.js";
6
  import { api } from "../core/网络请求API.js";
7
  import { showToast } from "../components/UI交互提示组件.js";
8
  import { t } from "../components/用户体验增强.js";
 
50
  console.log(`🗑️ 已清除 [${tab}] 类型的列表缓存`);
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  /**
54
  * 🟢 利用 HTML5 Canvas 纯前端裁剪并压缩头像
55
  * 功能:强制 1:1 比例中心裁剪,缩放到 512x512,转换为 JPG 格式。
 
57
  function processAvatar(file) {
58
  return _processImageCore(file, {
59
  cropToSquare: true,
60
+ maxFileSize: IMAGE_PROCESS.AVATAR_MAX_BYTES,
61
+ targetSize: IMAGE_PROCESS.AVATAR_SIZE,
62
  quality: 0.85,
63
  suffix: '_cropped.jpg'
64
  }).then(processedFile => {
 
70
  /**
71
  * 上传前图片压缩处理
72
  * - 转为 JPG 格式
73
+ * - 最长边至多4096并保持比例;质量压缩后仍超过5MB则拒绝
74
  * @param {File} file - 原始图片文件
75
  * @returns {Promise<File>} - 处理后的文件
76
  */
 
80
  return file;
81
  }
82
 
83
+ if (!Number.isFinite(file.size) || file.size < 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES) {
84
+ throw new Error('Image exceeds upload budget');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  }
86
+ // 动图保持原格式;不由Canvas变为静态图片。
87
+ if (file.type === 'image/gif') { await validateImageFile(file); return file; }
88
+ return _processImageCore(file, {
89
+ quality: 0.92, fillBackground: '#FFFFFF', maxFileSize: IMAGE_PROCESS.MAX_OUTPUT_BYTES,
90
+ skipIfSmallJpeg: true, suffix: '.jpg'
91
+ });
92
  }
93
 
94
  /**
95
  * 🟢 新增:全局统一的文件上传包装器(供个人设置等其它组件调用)
96
  * 会在上传前自动拦截并处理头像图片
97
  */
98
+ export async function uploadFile(file, file_type, options = {}) {
99
+ const current = options.isCurrent || (() => true);
100
+ const feedback = (message, level) => { if (!options.silent && current()) showToast(message, level); };
101
+ if (!current()) return null;
102
  try {
103
  let fileToUpload = file;
104
 
105
  // 拦截点:如果是头像,就在纯内存中先进行裁剪、缩放、压缩
106
  if (file_type === "avatar" && file.type && file.type.startsWith("image/")) {
107
+ feedback(`🖼️ ${t('publish.processing_avatar')}`);
108
  fileToUpload = await processAvatar(file);
109
  }
110
 
111
+ feedback(`🌐 ${t('publish.uploading_file', { size: (fileToUpload.size/1024).toFixed(1) })}`);
112
+ if (!current()) return null;
113
  const res = await api.uploadFile(fileToUpload, file_type);
114
 
115
+ if (!current()) return null;
116
  if (res.status === "success") {
117
+ feedback(`✅ ${t('publish.upload_success')}`);
118
  return res.url;
119
  } else {
120
+ feedback(t('publish.upload_failed'), 'error');
121
  return null;
122
  }
123
  } catch (error) {
124
+ feedback(t('publish.upload_error'), 'error');
 
125
  return null;
126
  }
127
  }
128
 
129
  // ==========================================
130
+ // 发布表单提交:原会话范围与保存结果核实
131
  // ==========================================
132
  export async function handlePublishSubmit(params) {
133
  const {
 
135
  imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
136
  } = params;
137
 
138
+ const scope = params.submissionScope || createPublishSubmissionScope(container, submitBtn, currentUser?.account);
139
+ if (!scope.isCurrent()) return;
140
+ const expectedRevision = isEditMode ? (editItemData?.edit_revision === undefined ? 0 : editItemData.edit_revision) : null;
141
+ if (isEditMode && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) {
142
+ showToast(t('common.operation_failed'), 'error'); return;
143
+ }
144
+ const selectedImages = Array.from(imageFiles || []);
145
  const typeSelect = container.querySelector("#pub-type");
146
  const recommendTypeSelect = container.querySelector("#pub-recommend-type");
147
  const resTypeSelect = container.querySelector("#pub-resource-type");
 
159
  const title = container.querySelector("#pub-title").value.trim();
160
  const shortDesc = container.querySelector("#pub-short").value.trim();
161
  const fullDesc = container.querySelector("#pub-full").value.trim();
162
+ const price = rules.price ? Number(container.querySelector("#pub-price").value) : 0;
163
+ if (!Number.isSafeInteger(price) || price < 0) return showToast(t('common.operation_failed'), "warning");
164
 
165
  let finalLink = inputLink.value.trim();
166
  let isJsonUpload = (resTypeSelect.value === "json") || (type === "recommend_app");
 
211
  const allowRefundCheckbox = container.querySelector("#allow-refund-checkbox");
212
  const allowRefund = rules.refund ? (allowRefundCheckbox?.checked ?? true) : null;
213
 
214
+ if (!scope.begin()) return;
215
+ let committed = false, saveAttempted = false, outcomeUnknown = false;
216
  submitBtn.innerHTML = `⏳ ${t('publish.connecting')}`;
217
  submitBtn.disabled = true;
 
218
 
219
  try {
220
  if (isJsonUpload && jsonFile) {
221
  submitBtn.innerHTML = `⏳ ${t('publish.uploading_secure')}`;
222
  const uploadType = type.includes("app") ? "app" : (type.includes("tool") || type === "skill" ? "tool" : "recommend");
223
  const jsonUploadRes = await api.uploadFile(jsonFile, uploadType);
224
+ if (!scope.isCurrent()) return;
225
+ if (jsonUploadRes?.status !== "success" || typeof jsonUploadRes.url !== "string" || !jsonUploadRes.url.trim()) throw new Error("Invalid upload result");
226
  finalLink = jsonUploadRes.url;
227
  }
228
 
 
230
  let coverUrl = isEditMode ? editItemData.coverUrl : null;
231
  let imageUrls = isEditMode ? (editItemData.imageUrls || []) : [];
232
 
233
+ if (selectedImages.length > 0) {
234
+ submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: 0, total: selectedImages.length })}`;
235
  const uploadedUrls = [];
236
 
237
+ for (let i = 0; i < selectedImages.length; i++) {
238
+ submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: i + 1, total: selectedImages.length })}`;
239
  // 上传前压缩:转JPG,超5MB压缩
240
+ const processedFile = await compressImageForUpload(selectedImages[i]);
241
+ if (!scope.isCurrent()) return;
242
  const uploadRes = await api.uploadFile(processedFile, "cover");
243
+ if (!scope.isCurrent()) return;
244
+ if (uploadRes?.status === "success" && typeof uploadRes.url === "string" && uploadRes.url.trim()) {
245
  uploadedUrls.push(uploadRes.url);
246
  } else {
247
+ throw new Error("Invalid image upload result");
248
  }
249
  }
250
 
 
252
  imageUrls = uploadedUrls; // 全部图片URL
253
  }
254
 
255
+ if (!scope.isCurrent()) return;
256
  submitBtn.innerHTML = `⏳ ${t('publish.syncing')}`;
257
  // 🖼️ 统一封面逻辑:第一张图片始终作为封面(兑容拖拽排序后的顺序变化)
258
  if (imageUrls && imageUrls.length > 0) {
259
  coverUrl = imageUrls[0];
260
  }
261
  const submitData = {
262
+ ...(!isEditMode && scope.publicationId ? { request_id: scope.publicationId } : {}),
263
+ ...(isEditMode ? { expected_revision: expectedRevision } : {}),
264
  type, title, shortDesc, fullDesc, price, link: finalLink, coverUrl, imageUrls, // 🖼️ 添加 imageUrls
265
  author: currentUser.account, github_token,
266
  netdisk_password, // ☁️ 网盘密码(后端加密存储)
 
273
  submitData.allow_refund = allowRefund;
274
  }
275
 
276
+ saveAttempted = true;
277
+ const result = isEditMode
278
+ ? await api.updateItem(editItemData.id, currentUser.account, submitData)
279
+ : await api.publishItem(submitData);
280
+ if (!scope.isCurrent()) return;
281
+ if (result?.status !== "success") throw new Error("Invalid save result");
282
+ committed = true;
283
+ showToast(isEditMode ? `✅ ${t('publish.save_success')}` : `🎉 ${t('publish.publish_success')}`, "success");
284
+ // 主保存已确认;派生缓存或界面回调异常不重新开放提交。
285
+ try { clearItemCacheByType(type); } catch (_) {}
286
+ if (scope.isCurrent() && onSuccessCallback) {
287
+ try { await onSuccessCallback(); } catch (_) {}
288
  }
 
 
 
 
 
289
  } catch (err) {
290
+ if (!scope.isCurrent() || committed) return;
291
+ if (isEditMode && err?.status === 409 && err?.code === 'resource_revision_conflict') {
292
+ outcomeUnknown = true; // 保持入口锁,要求重开取得新版本;不自动覆盖。
293
+ showToast(t('publish.edit_conflict'), 'warning');
294
+ submitBtn.disabled = true; submitBtn.innerHTML = t('publish.edit_reopen');
295
+ return;
296
+ }
297
+ // 已发出的保存可能在服务端提交;仅已知前置拒绝允许原表单重试。
298
+ const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
299
+ outcomeUnknown = saveAttempted && !rejected;
300
+ if (outcomeUnknown) {
301
+ showToast(t('publish.save_unconfirmed'), "warning");
302
+ submitBtn.disabled = true;
303
+ submitBtn.innerHTML = t('publish.result_pending');
304
+ return;
305
+ }
306
+ showToast(t('common.operation_failed'), "error");
307
  submitBtn.disabled = false;
308
  submitBtn.innerHTML = submitBtnText;
309
+ } finally {
310
+ scope.finish(committed, outcomeUnknown);
311
  }
312
+ }
前端页面/market/发布内容组件.js CHANGED
@@ -1,5 +1,7 @@
 
1
  // 前端页面/market/发布内容组件.js
2
  import { generatePublishHTML } from "./发布内容_UI模板.js";
 
3
  import { handlePublishSubmit } from "./发布内容_提交引擎.js";
4
  import { t } from "../components/用户体验增强.js";
5
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
@@ -206,7 +208,9 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
206
  // 1. 渲染分离出去的视图模板
207
  container.innerHTML = generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t);
208
 
 
209
  container.querySelector("#btn-back").onclick = async () => {
 
210
  // 检查是否有已编辑内容
211
  const hasChanges = !!(
212
  container.querySelector("#pub-title")?.value?.trim() ||
@@ -222,12 +226,13 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
222
  title: isEditMode ? t('publish.unsaved_changes_title') : t('publish.leave_confirm_title'),
223
  confirmText: t('common.leave'),
224
  cancelText: t('common.stay'),
225
- type: 'warning'
226
  }
227
  );
228
- if (!confirmed) return;
229
  }
230
 
 
231
  if (onBackCallback) onBackCallback();
232
  };
233
 
@@ -449,36 +454,49 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
449
 
450
  updateFormView();
451
 
452
- // 🖼️ 多图上传处理
453
- imagesInput.onchange = (e) => {
454
- const rawFiles = Array.from(e.target.files).slice(0, 6); // 最多6张
455
- if (rawFiles.length === 0) return;
456
-
457
- // GIF 文件大小校验:超过 50MB 的 GIF 立即排除
458
- const GIF_MAX_SIZE = 50 * 1024 * 1024;
459
- const files = rawFiles.filter(file => {
460
- if (file.type === 'image/gif' && file.size > GIF_MAX_SIZE) {
461
- const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
462
- const msg = `"${file.name}" 文件过大(${sizeMB}MB),GIF最大允许50MB,已自动跳过`;
463
- console.warn('[发布内容组件]', msg);
464
- showToast(msg, "warning");
465
- return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  }
467
- return true;
468
- });
469
- if (files.length === 0) return;
470
-
471
- imageFiles = files;
472
- imagesPreview.innerHTML = '';
473
-
474
- files.forEach((file, idx) => {
475
- const objectUrl = URL.createObjectURL(file);
476
- imagesPreview.appendChild(_createImagePreviewWrapper(objectUrl, idx));
477
- });
478
- updateCoverMark(imagesPreview);
479
- setupImageDragSort(imagesPreview, imageFiles);
480
  };
481
-
482
  inputJson.onchange = (e) => { jsonFile = e.target.files[0]; };
483
 
484
  // 🎨 原创勾选框逻辑:工具/应用类型强制确认弹窗
@@ -490,15 +508,18 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
490
  const rules = TYPE_RULES[typeSelect.value];
491
  if (originalHintText) {
492
  if (rules?.originalRequired) {
493
- originalHintText.innerHTML = `<span style="color: #FF9800;">⚠️ ${t('publish.original_required_hint')}</span>`;
 
494
  } else {
495
  originalHintText.textContent = t('publish.original_default_hint');
 
496
  }
497
  }
498
  };
499
 
500
  // 显示原创确认弹窗
501
  const showOriginalConfirmModal = (onConfirm, onCancel) => {
 
502
  const contentDiv = document.createElement("div");
503
  contentDiv.innerHTML = `
504
  <div style="line-height: 1.6; color: #ccc;">
@@ -519,20 +540,25 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
519
  </div>
520
  `;
521
 
522
- globalModal.openModal(t('publish.original_confirm_title'), contentDiv, { width: "450px" });
 
 
523
 
524
  contentDiv.querySelector("#btn-original-confirm").onclick = () => {
525
- globalModal.closeTopModal();
 
526
  onConfirm();
527
  };
528
  contentDiv.querySelector("#btn-original-cancel").onclick = () => {
529
- globalModal.closeTopModal();
 
530
  onCancel();
531
  };
532
  };
533
 
534
  // 监听原创勾选框变化
535
  isOriginalCheckbox.onchange = (e) => {
 
536
  const rules = TYPE_RULES[typeSelect.value];
537
  // 只有规则要求原创强制的类型才需要确认弹窗
538
  if (rules?.originalRequired && e.target.checked) {
@@ -552,7 +578,10 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
552
 
553
  // 4. 将提取的参数交接给分离出去的提交引擎
554
  const submitBtn = container.querySelector("#btn-submit-publish");
555
- submitBtn.onclick = () => handlePublishSubmit({
 
 
 
556
  container, currentUser, isEditMode, editItemData,
557
  imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
558
  });
 
1
+ import { validateImageFile } from "./图片文件检查.js";
2
  // 前端页面/market/发布内容组件.js
3
  import { generatePublishHTML } from "./发布内容_UI模板.js";
4
+ import { createPublishSubmissionScope } from "./发布提交生命周期.js";
5
  import { handlePublishSubmit } from "./发布内容_提交引擎.js";
6
  import { t } from "../components/用户体验增强.js";
7
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
 
208
  // 1. 渲染分离出去的视图模板
209
  container.innerHTML = generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t);
210
 
211
+ let submissionScope;
212
  container.querySelector("#btn-back").onclick = async () => {
213
+ if (!submissionScope?.isCurrent()) return;
214
  // 检查是否有已编辑内容
215
  const hasChanges = !!(
216
  container.querySelector("#pub-title")?.value?.trim() ||
 
226
  title: isEditMode ? t('publish.unsaved_changes_title') : t('publish.leave_confirm_title'),
227
  confirmText: t('common.leave'),
228
  cancelText: t('common.stay'),
229
+ type: 'warning', signal: submissionScope.confirmationSignal
230
  }
231
  );
232
+ if (!confirmed || !submissionScope.isCurrent()) return;
233
  }
234
 
235
+ submissionScope.dispose();
236
  if (onBackCallback) onBackCallback();
237
  };
238
 
 
454
 
455
  updateFormView();
456
 
457
+ // 文件头确认后才创建预览,迟到选择不覆盖后一次选择。
458
+ let previewGeneration = 0, previewPending = false, resumeDisabled = false;
459
+ const previewUrls = new Set();
460
+ const releasePreviews = () => {
461
+ for (const url of previewUrls) URL.revokeObjectURL(url);
462
+ previewUrls.clear();
463
+ };
464
+ imagesInput.onchange = async (e) => {
465
+ if (!submissionScope?.isCurrent()) return;
466
+ const rawFiles = Array.from(e.target.files).slice(0, 6);
467
+ if (!rawFiles.length) return;
468
+ const generation = ++previewGeneration;
469
+ if (!previewPending) resumeDisabled = submitBtn.disabled;
470
+ previewPending = true; submitBtn.disabled = true;
471
+ try {
472
+ const files = [];
473
+ for (const file of rawFiles) {
474
+ try { await validateImageFile(file); files.push(file); }
475
+ catch (_) {
476
+ if (generation === previewGeneration && submissionScope.isCurrent()) showToast(t('common.operation_failed'), 'warning');
477
+ }
478
+ if (generation !== previewGeneration || !submissionScope.isCurrent()) return;
479
+ }
480
+ if (!files.length) return;
481
+ releasePreviews(); imageFiles = files; imagesPreview.replaceChildren();
482
+ for (const [index, file] of files.entries()) {
483
+ const url = URL.createObjectURL(file); previewUrls.add(url);
484
+ imagesPreview.appendChild(_createImagePreviewWrapper(url, index));
485
+ }
486
+ updateCoverMark(imagesPreview); setupImageDragSort(imagesPreview, imageFiles);
487
+ } catch (_) {
488
+ if (generation === previewGeneration && submissionScope.isCurrent()) {
489
+ releasePreviews(); imageFiles = []; imagesPreview.replaceChildren();
490
+ showToast(t('common.operation_failed'), 'warning');
491
  }
492
+ } finally {
493
+ if (generation === previewGeneration) {
494
+ previewPending = false;
495
+ if (submissionScope.isCurrent()) submitBtn.disabled = resumeDisabled;
496
+ }
497
+ }
 
 
 
 
 
 
 
498
  };
499
+
500
  inputJson.onchange = (e) => { jsonFile = e.target.files[0]; };
501
 
502
  // 🎨 原创勾选框逻辑:工具/应用类型强制确认弹窗
 
508
  const rules = TYPE_RULES[typeSelect.value];
509
  if (originalHintText) {
510
  if (rules?.originalRequired) {
511
+ originalHintText.textContent = `⚠️ ${t('publish.original_required_hint')}`;
512
+ originalHintText.classList.add('ranking-publish__original-required');
513
  } else {
514
  originalHintText.textContent = t('publish.original_default_hint');
515
+ originalHintText.classList.remove('ranking-publish__original-required');
516
  }
517
  }
518
  };
519
 
520
  // 显示原创确认弹窗
521
  const showOriginalConfirmModal = (onConfirm, onCancel) => {
522
+ if (!submissionScope?.isCurrent()) return;
523
  const contentDiv = document.createElement("div");
524
  contentDiv.innerHTML = `
525
  <div style="line-height: 1.6; color: #ccc;">
 
540
  </div>
541
  `;
542
 
543
+ const modal = globalModal.openModal(t('publish.original_confirm_title'), contentDiv, { width: "450px" });
544
+ const release = submissionScope.addCleanup(() => globalModal.closeModal(modal));
545
+ contentDiv._cleanup = release;
546
 
547
  contentDiv.querySelector("#btn-original-confirm").onclick = () => {
548
+ if (!submissionScope.isCurrent()) return;
549
+ globalModal.closeModal(modal);
550
  onConfirm();
551
  };
552
  contentDiv.querySelector("#btn-original-cancel").onclick = () => {
553
+ if (!submissionScope.isCurrent()) return;
554
+ globalModal.closeModal(modal);
555
  onCancel();
556
  };
557
  };
558
 
559
  // 监听原创勾选框变化
560
  isOriginalCheckbox.onchange = (e) => {
561
+ if (!submissionScope?.isCurrent()) return;
562
  const rules = TYPE_RULES[typeSelect.value];
563
  // 只有规则要求原创强制的类型才需要确认弹窗
564
  if (rules?.originalRequired && e.target.checked) {
 
578
 
579
  // 4. 将提取的参数交接给分离出去的提交引擎
580
  const submitBtn = container.querySelector("#btn-submit-publish");
581
+ submissionScope = createPublishSubmissionScope(container, submitBtn, currentUser?.account);
582
+ submissionScope.addCleanup(() => { ++previewGeneration; releasePreviews(); imageFiles = []; jsonFile = null; imagesPreview.replaceChildren(); });
583
+ submitBtn.onclick = () => previewPending ? undefined : handlePublishSubmit({
584
+ submissionScope,
585
  container, currentUser, isEditMode, editItemData,
586
  imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
587
  });
前端页面/market/发布字段错误.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 发布表单本地校验提示;不保存草稿,也不展示远端异常正文。 */
2
+ export function createPublishFieldErrors(inputs, signal) {
3
+ const entries = new Map(); let disposed = false;
4
+ const clear = input => {
5
+ const entry = entries.get(input); if (!entry) return;
6
+ entry.error.hidden = true; entry.error.textContent = '';
7
+ const tokens = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(token => token && token !== entry.error.id);
8
+ if (tokens.length) input.setAttribute('aria-describedby', tokens.join(' ')); else input.removeAttribute('aria-describedby');
9
+ if (entry.active && input.getAttribute('aria-invalid') === 'true') {
10
+ if (entry.previousInvalid === null) input.removeAttribute('aria-invalid');
11
+ else input.setAttribute('aria-invalid', entry.previousInvalid);
12
+ }
13
+ entry.active = false;
14
+ };
15
+ const clearAll = () => { for (const input of entries.keys()) clear(input); };
16
+ const dispose = () => {
17
+ if (disposed) return; disposed = true;
18
+ clearAll();
19
+ for (const [input, entry] of entries) {
20
+ input.removeEventListener('input', entry.onChange); input.removeEventListener('change', entry.onChange); entry.error.remove();
21
+ }
22
+ entries.clear(); signal?.removeEventListener('abort', dispose);
23
+ };
24
+ const show = (input, message) => {
25
+ if (disposed || signal?.aborted) return;
26
+ const entry = entries.get(input); if (!entry) return;
27
+ if (!entry.active) entry.previousInvalid = input.getAttribute('aria-invalid');
28
+ entry.active = true; entry.error.textContent = message; entry.error.hidden = false;
29
+ const tokens = new Set((input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)); tokens.add(entry.error.id);
30
+ input.setAttribute('aria-describedby', [...tokens].join(' ')); input.setAttribute('aria-invalid', 'true'); input.focus();
31
+ };
32
+ if (signal?.aborted) { disposed = true; return { show, clearAll, dispose }; }
33
+ for (const input of inputs) {
34
+ const error = document.createElement('div'); error.id = `${input.id}-error`; error.className = 'ranking-publish-field-error';
35
+ error.hidden = true; error.setAttribute('role', 'alert'); input.insertAdjacentElement('afterend', error);
36
+ const entry = { error, active: false, previousInvalid: null, onChange: () => clear(input) };
37
+ entries.set(input, entry); input.addEventListener('input', entry.onChange); input.addEventListener('change', entry.onChange);
38
+ }
39
+ signal?.addEventListener('abort', dispose, { once: true });
40
+ return { show, clearAll, dispose };
41
+ }
前端页面/market/发布提交生命周期.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 发布表单绑定原账号与凭证,已经发送的写操作不取消或重放。 */
2
+ import { captureRequestSession } from "../core/网络请求API.js";
3
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
4
+ import { CACHE } from "../core/全局配置.js";
5
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
6
+
7
+ const bindings = new WeakMap();
8
+
9
+ export function createPublishSubmissionScope(container, button, account) {
10
+ const previous = bindings.get(container);
11
+ if (previous?.button === button && previous.account === account) return previous.binding;
12
+ previous?.binding.dispose();
13
+ const matches = captureRequestSession(), confirmation = new AbortController();
14
+ let phase = 'idle', observer;
15
+ const scope = createTaskViewScope({
16
+ sessionMatches: () => !!account && matches() && getUser()?.account === account,
17
+ connected: () => container.isConnected && button.isConnected,
18
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
19
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
20
+ cancelReads: () => {},
21
+ invalidate: () => {}
22
+ });
23
+ scope.addCleanup(() => {
24
+ phase = 'disposed'; observer?.disconnect(); confirmation.abort();
25
+ button.disabled = true; button.removeAttribute('aria-busy');
26
+ for (const selector of ['#pub-github-token', '#pub-netdisk-password']) {
27
+ const input = container.querySelector(selector); if (input) input.value = '';
28
+ }
29
+ });
30
+ let wasConnected = container.isConnected;
31
+ observer = new MutationObserver(() => {
32
+ if (container.isConnected) wasConnected = true;
33
+ if (wasConnected) scope.isCurrent();
34
+ });
35
+ observer.observe(document.body, { childList: true, subtree: true });
36
+ container._cleanup = () => scope.dispose();
37
+ const publicationId = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
38
+ const binding = {
39
+ publicationId,
40
+ confirmationSignal: confirmation.signal,
41
+ addCleanup: cleanup => scope.addCleanup(cleanup),
42
+ isCurrent: () => scope.isCurrent(),
43
+ dispose: () => scope.dispose(),
44
+ begin() {
45
+ if (phase !== 'idle' || !scope.isCurrent()) return false;
46
+ phase = 'submitting'; button.setAttribute('aria-busy', 'true'); return true;
47
+ },
48
+ finish(committed, outcomeUnknown = false) {
49
+ if (!scope.isCurrent()) return;
50
+ phase = committed ? 'committed' : (outcomeUnknown ? 'unconfirmed' : 'idle'); button.removeAttribute('aria-busy');
51
+ }
52
+ };
53
+ bindings.set(container, { button, account, binding });
54
+ return binding;
55
+ }
前端页面/market/发布文件选择可访问性.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const bindings = new WeakMap();
2
+
3
+ /** onclick属性变化不触发MutationObserver,渲染时显式同步空态语义。 */
4
+ export function setPublishFilePickerAction(area, action) {
5
+ area.onclick = action;
6
+ bindings.get(area)?.();
7
+ }
8
+
9
+ /** 上传区域只在空态作为一个按钮;已有媒体中的原生控件保留独立语义。 */
10
+ export function bindPublishFilePicker(area, input, label, signal) {
11
+ let disposed = false;
12
+ input.setAttribute('aria-label', label);
13
+ const sync = () => {
14
+ if (disposed) return;
15
+ const active = typeof area.onclick === 'function';
16
+ if (active) {
17
+ area.setAttribute('role', 'button'); area.tabIndex = 0;
18
+ area.setAttribute('aria-label', label);
19
+ area.setAttribute('aria-disabled', String(input.disabled));
20
+ if (input.getAttribute('aria-busy') === 'true') area.setAttribute('aria-busy', 'true');
21
+ else area.removeAttribute('aria-busy');
22
+ } else {
23
+ for (const name of ['role', 'tabindex', 'aria-label', 'aria-disabled', 'aria-busy']) area.removeAttribute(name);
24
+ }
25
+ for (const button of area.querySelectorAll('[data-file-picker-button]')) button.disabled = input.disabled;
26
+ };
27
+ const onKey = event => {
28
+ if (disposed || signal?.aborted || input.disabled || event.target !== area || typeof area.onclick !== 'function'
29
+ || event.isComposing || event.repeat || !['Enter', ' '].includes(event.key)) return;
30
+ event.preventDefault(); event.stopPropagation(); area.click();
31
+ };
32
+ const observer = new MutationObserver(sync);
33
+ const dispose = () => {
34
+ if (disposed) return;
35
+ disposed = true; observer.disconnect();
36
+ if (bindings.get(area) === sync) bindings.delete(area); area.removeEventListener('keydown', onKey);
37
+ signal?.removeEventListener('abort', dispose);
38
+ };
39
+ if (signal?.aborted) { dispose(); return dispose; }
40
+ observer.observe(area, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
41
+ observer.observe(input, { attributes: true, attributeFilter: ['disabled', 'aria-busy'] });
42
+ area.addEventListener('keydown', onKey); signal?.addEventListener('abort', dispose, { once: true });
43
+ bindings.set(area, sync); sync(); return dispose;
44
+ }
前端页面/market/发布编辑锁.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 锁住本次发布的编辑入口;返回、提交按钮由原生命周期管理。 */
2
+ export function createPublishEditingLock(container, excluded = []) {
3
+ let locked = false, disposed = false;
4
+ const controls = new Map();
5
+ const except = target => excluded.some(node => node === target || node?.contains(target));
6
+ const captureControls = () => {
7
+ if (!locked || disposed) return;
8
+ for (const control of container.querySelectorAll('input, textarea, select, button')) {
9
+ if (except(control)) continue;
10
+ if (!controls.has(control)) controls.set(control, control.disabled);
11
+ control.disabled = true;
12
+ }
13
+ };
14
+ const preventEditing = event => {
15
+ if (!locked || disposed || except(event.target)) return;
16
+ // 媒体播放不改变待发布内容,仍可预览;其它点击/键盘/拖拽入口锁定。
17
+ if (event.target?.closest?.('video, audio')) return;
18
+ event.preventDefault(); event.stopImmediatePropagation();
19
+ };
20
+ const events = ['click', 'keydown', 'dragstart', 'drop', 'input', 'change'];
21
+ for (const name of events) container.addEventListener(name, preventEditing, true);
22
+ const observer = new MutationObserver(captureControls);
23
+ return {
24
+ lock() {
25
+ if (disposed || locked) return;
26
+ locked = true; captureControls();
27
+ observer.observe(container, { childList: true, subtree: true });
28
+ },
29
+ unlock() {
30
+ if (disposed || !locked) return;
31
+ locked = false; observer.disconnect();
32
+ for (const [control, disabled] of controls) {
33
+ if (container.contains(control)) control.disabled = disabled;
34
+ }
35
+ controls.clear();
36
+ },
37
+ dispose() {
38
+ if (disposed) return;
39
+ disposed = true; observer.disconnect(); controls.clear();
40
+ for (const name of events) container.removeEventListener(name, preventEditing, true);
41
+ }
42
+ };
43
+ }
前端页面/market/图片文件检查.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 有界文件头检查;仅确认格式/尺寸,不代替浏览器解码和服务端审核。 */
2
+ import { IMAGE_PROCESS } from "../core/全局配置.js";
3
+
4
+ export function readImageDimensions(buffer) {
5
+ const b = new Uint8Array(buffer), v = new DataView(buffer);
6
+ const text = (offset, count) => String.fromCharCode(...b.subarray(offset, offset + count));
7
+ const requireBytes = end => { if (end > b.length) throw new Error('Truncated image header'); };
8
+ let width, height, type;
9
+ if (b.length >= 8 && b[0] === 137 && text(1, 3) === 'PNG' && text(4, 4) === '\r\n\x1a\n') {
10
+ requireBytes(33);
11
+ if (v.getUint32(8) !== 13 || text(12, 4) !== 'IHDR') throw new Error('Invalid PNG header');
12
+ width = v.getUint32(16); height = v.getUint32(20); type = 'image/png';
13
+ } else if (text(0, 6) === 'GIF87a' || text(0, 6) === 'GIF89a') {
14
+ requireBytes(13); width = v.getUint16(6, true); height = v.getUint16(8, true); type = 'image/gif';
15
+ } else if (text(0, 4) === 'RIFF' && text(8, 4) === 'WEBP') {
16
+ requireBytes(20); const chunk = text(12, 4), size = v.getUint32(16, true);
17
+ if (v.getUint32(4, true) < size + 12) throw new Error('Invalid WebP container');
18
+ if (chunk === 'VP8X' && size === 10) {
19
+ requireBytes(30); width = 1 + b[24] + (b[25] << 8) + (b[26] << 16);
20
+ height = 1 + b[27] + (b[28] << 8) + (b[29] << 16);
21
+ } else if (chunk === 'VP8L' && size >= 5) {
22
+ requireBytes(25); if (b[20] !== 47 || (b[24] >> 5) !== 0) throw new Error('Invalid lossless header');
23
+ const bits = v.getUint32(21, true); width = 1 + (bits & 16383); height = 1 + ((bits >>> 14) & 16383);
24
+ } else if (chunk === 'VP8 ' && size >= 10) {
25
+ requireBytes(30);
26
+ if ((b[20] & 1) || b[23] !== 157 || b[24] !== 1 || b[25] !== 42) throw new Error('Invalid VP8 frame');
27
+ width = v.getUint16(26, true) & 16383; height = v.getUint16(28, true) & 16383;
28
+ } else throw new Error('Unsupported WebP header');
29
+ type = 'image/webp';
30
+ } else if (b[0] === 255 && b[1] === 216) {
31
+ let offset = 2;
32
+ while (offset < b.length) {
33
+ if (b[offset++] !== 255) throw new Error('Invalid JPEG marker');
34
+ while (b[offset] === 255) offset++;
35
+ requireBytes(offset + 1); const marker = b[offset++];
36
+ if (marker === 1) continue;
37
+ if (marker === 0 || marker === 216 || marker === 217 || marker === 218 || (marker >= 208 && marker <= 215)) break;
38
+ requireBytes(offset + 2); const length = v.getUint16(offset);
39
+ if (length < 2) throw new Error('Invalid JPEG segment');
40
+ requireBytes(offset + length);
41
+ if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
42
+ if (length < 8 || b[offset + 7] === 0 || length !== 8 + 3 * b[offset + 7]) throw new Error('Invalid JPEG frame');
43
+ height = v.getUint16(offset + 3); width = v.getUint16(offset + 5); type = 'image/jpeg'; break;
44
+ }
45
+ offset += length;
46
+ }
47
+ }
48
+ if (!type || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0
49
+ || Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) {
50
+ throw new Error('Unsupported image or dimensions exceed budget');
51
+ }
52
+ return { width, height, type };
53
+ }
54
+
55
+ export async function validateImageFile(file, { signal } = {}) {
56
+ if (!Number.isSafeInteger(file?.size) || file.size <= 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES
57
+ || typeof file.slice !== 'function') throw new Error('Invalid image file');
58
+ let timer, abort;
59
+ const aborted = () => Object.assign(new Error('Image header read aborted'), { name: 'AbortError' });
60
+ if (signal?.aborted) throw aborted();
61
+ try {
62
+ const cancellation = new Promise((_, reject) => {
63
+ abort = () => reject(aborted());
64
+ signal?.addEventListener('abort', abort, { once: true });
65
+ });
66
+ const buffer = await Promise.race([
67
+ cancellation,
68
+ file.slice(0, IMAGE_PROCESS.HEADER_BYTES).arrayBuffer(),
69
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Image header read timed out')), IMAGE_PROCESS.TIMEOUT); })
70
+ ]);
71
+ const dimensions = readImageDimensions(buffer);
72
+ if (file.type && file.type !== dimensions.type && !(file.type === 'image/jpg' && dimensions.type === 'image/jpeg')) {
73
+ throw new Error('Image MIME does not match header');
74
+ }
75
+ return dimensions;
76
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
77
+ }
前端页面/market/支付二维码工具.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { QrCode } from '../vendor/qrcodegen.js';
2
+
3
+ /** 支付链接仅用于本地二维码编码,SVG 中不插入链接或文本。 */
4
+ export function createPaymentQRCode(value, maxBytes) {
5
+ if (typeof value !== 'string' || !Number.isSafeInteger(maxBytes) || maxBytes < 1
6
+ || new TextEncoder().encode(value).length > maxBytes) throw new Error('invalid payment data');
7
+ const url = new URL(value);
8
+ if (url.protocol !== 'https:' || url.username || url.password || url.port
9
+ || !(url.hostname === 'alipay.com' || url.hostname.endsWith('.alipay.com'))) {
10
+ throw new Error('invalid payment address');
11
+ }
12
+ const qr = QrCode.encodeText(value, QrCode.Ecc.MEDIUM);
13
+ const border = 4, size = qr.size + border * 2;
14
+ const paths = [];
15
+ for (let y = 0; y < qr.size; y++) {
16
+ for (let x = 0; x < qr.size; x++) {
17
+ if (qr.getModule(x, y)) paths.push(`M${x + border},${y + border}h1v1h-1z`);
18
+ }
19
+ }
20
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${paths.join('')}" fill="black"/></svg>`;
21
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
22
+ }
前端页面/market/有界图片处理.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { validateImageFile } from "./图片文件检查.js";
2
+ import { IMAGE_PROCESS, PUBLISH_MEDIA_LIMITS } from "../core/全局配置.js";
3
+
4
+ /**
5
+ * 通用图片处理核心函数:加载图片 → Canvas绘制 → 转换为JPG文件
6
+ * @param {File} file - 原始图片文件
7
+ * @param {Object} options - 配置选项
8
+ * @param {boolean} options.cropToSquare - 是否中心裁剪为正方形
9
+ * @param {number|null} options.targetSize - 目标尺寸(正方形边长),null则按输出边长预算等比缩小
10
+ * @param {number} options.quality - 初始JPG质量 (0-1)
11
+ * @param {string|null} options.fillBackground - 背景填充颜色,null则不填充
12
+ * @param {number|null} options.maxFileSize - 最大文件大小(字节),超限则迭代降低质量
13
+ * @param {boolean} options.skipIfSmallJpeg - 是否跳过已是小体积JPEG的文件
14
+ * @param {number} options.maxEdge - 可选输出最长边(不超过全局输出预算)
15
+ * @param {AbortSignal} options.signal - 所属会话的取消信号
16
+ * @param {string} options.suffix - 输出文件名后缀
17
+ * @returns {Promise<File>} 处理后的文件
18
+ */
19
+ export function processImageCore(file, options = {}) {
20
+ if (!Number.isFinite(file?.size) || file.size < 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES) {
21
+ return Promise.reject(new Error('Invalid image size'));
22
+ }
23
+ return validateImageFile(file, { signal: options.signal }).then(() => new Promise((resolve, reject) => {
24
+ let img, url, canvas, timer, settled = false;
25
+ const signal = options.signal;
26
+ const abort = () => finish(Object.assign(new Error('Image processing aborted'), { name: 'AbortError' }));
27
+ const finish = (error, result) => {
28
+ if (settled) return;
29
+ settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort);
30
+ if (img) { img.onload = null; img.onerror = null; img.src = ''; }
31
+ if (url) URL.revokeObjectURL(url);
32
+ if (canvas) { canvas.width = 0; canvas.height = 0; }
33
+ if (error) reject(error); else resolve(result);
34
+ };
35
+ if (signal?.aborted) { abort(); return; }
36
+ signal?.addEventListener('abort', abort, { once: true });
37
+ timer = setTimeout(() => finish(new Error('Image processing timed out')), IMAGE_PROCESS.TIMEOUT);
38
+ try {
39
+ img = new Image(); url = URL.createObjectURL(file);
40
+ img.onerror = () => finish(new Error('Image decode failed'));
41
+ img.onload = () => {
42
+ if (settled) return;
43
+ try {
44
+ const width = img.naturalWidth, height = img.naturalHeight;
45
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0
46
+ || Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) {
47
+ throw new Error('Image dimensions exceed budget');
48
+ }
49
+ const cap = options.maxFileSize || IMAGE_PROCESS.MAX_OUTPUT_BYTES;
50
+ if (options.skipIfSmallJpeg && file.type === 'image/jpeg' && file.size <= cap
51
+ && Math.max(width, height) <= IMAGE_PROCESS.MAX_OUTPUT_EDGE) { finish(null, file); return; }
52
+ let sourceX = 0, sourceY = 0, sourceW = width, sourceH = height;
53
+ if (options.cropToSquare) {
54
+ sourceW = sourceH = Math.min(width, height);
55
+ sourceX = (width - sourceW) / 2; sourceY = (height - sourceH) / 2;
56
+ }
57
+ const outputEdge = Math.min(options.maxEdge || IMAGE_PROCESS.MAX_OUTPUT_EDGE, IMAGE_PROCESS.MAX_OUTPUT_EDGE);
58
+ const scale = Math.min(1, outputEdge / Math.max(sourceW, sourceH));
59
+ const destW = options.targetSize || Math.max(1, Math.round(sourceW * scale));
60
+ const destH = options.targetSize || Math.max(1, Math.round(sourceH * scale));
61
+ canvas = document.createElement('canvas'); canvas.width = destW; canvas.height = destH;
62
+ const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unavailable');
63
+ if (options.fillBackground) { ctx.fillStyle = options.fillBackground; ctx.fillRect(0, 0, destW, destH); }
64
+ ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, destW, destH);
65
+ let quality = options.quality || 0.85;
66
+ const encode = () => {
67
+ if (settled) return;
68
+ try {
69
+ canvas.toBlob(blob => {
70
+ if (settled) return;
71
+ try {
72
+ if (!blob || !Number.isSafeInteger(blob.size) || blob.size <= 0) throw new Error('Image encoding failed');
73
+ if (blob.size > cap) {
74
+ if (quality <= IMAGE_PROCESS.MIN_QUALITY) throw new Error('Compressed image exceeds budget');
75
+ quality = Math.max(IMAGE_PROCESS.MIN_QUALITY, quality - IMAGE_PROCESS.QUALITY_STEP);
76
+ encode(); return;
77
+ }
78
+ finish(null, new File([blob], file.name.replace(/\.[^/.]+$/, '') + (options.suffix || '.jpg'), { type: 'image/jpeg' }));
79
+ } catch (error) { finish(error); }
80
+ }, 'image/jpeg', quality);
81
+ } catch (error) { finish(error); }
82
+ };
83
+ encode();
84
+ } catch (error) { finish(error); }
85
+ };
86
+ img.src = url;
87
+ } catch (error) { finish(error); }
88
+ }));
89
+ }
90
+
91
+ // 只记住本模块成功生成的不可变File;弱引用不会延长表单文件生命周期。
92
+ const preparedPublishImages = new WeakSet();
93
+
94
+ /** 帖子/提示词保持原1920最长边与JPEG转换策略,不保留动画。 */
95
+ export function compressPublishImage(file, { signal } = {}) {
96
+ if (signal?.aborted) return Promise.reject(Object.assign(new Error('Image processing aborted'), { name: 'AbortError' }));
97
+ if (preparedPublishImages.has(file)) return Promise.resolve(file);
98
+ return processImageCore(file, {
99
+ maxEdge: PUBLISH_MEDIA_LIMITS.IMAGE_MAX_EDGE,
100
+ quality: PUBLISH_MEDIA_LIMITS.IMAGE_QUALITY,
101
+ maxFileSize: IMAGE_PROCESS.MAX_OUTPUT_BYTES,
102
+ suffix: '.jpg', signal
103
+ }).then(result => { preparedPublishImages.add(result); return result; });
104
+ }
105
+
106
+ /** 按发布输出预算截取当前视频帧,终态释放画布。 */
107
+ export function capturePublishVideoFrame(video, { signal } = {}) {
108
+ return new Promise((resolve, reject) => {
109
+ let canvas, timer, settled = false;
110
+ const abort = () => finish(Object.assign(new Error('Video frame capture aborted'), { name: 'AbortError' }));
111
+ const finish = (error, result) => {
112
+ if (settled) return;
113
+ settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort);
114
+ if (canvas) { canvas.width = 0; canvas.height = 0; }
115
+ if (error) reject(error); else resolve(result);
116
+ };
117
+ if (signal?.aborted) { abort(); return; }
118
+ signal?.addEventListener('abort', abort, { once: true });
119
+ timer = setTimeout(() => finish(new Error('Video frame capture timed out')), IMAGE_PROCESS.TIMEOUT);
120
+ try {
121
+ const width = video.videoWidth, height = video.videoHeight;
122
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 ||
123
+ Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) {
124
+ throw new Error('Video frame dimensions exceed budget');
125
+ }
126
+ const scale = Math.min(1, PUBLISH_MEDIA_LIMITS.IMAGE_MAX_EDGE / Math.max(width, height));
127
+ canvas = document.createElement('canvas');
128
+ canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale));
129
+ const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unavailable');
130
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
131
+ let quality = PUBLISH_MEDIA_LIMITS.IMAGE_QUALITY;
132
+ const encode = () => {
133
+ if (settled) return;
134
+ try {
135
+ canvas.toBlob(blob => {
136
+ if (settled) return;
137
+ try {
138
+ if (!blob || !Number.isSafeInteger(blob.size) || blob.size <= 0) throw new Error('Invalid video frame output');
139
+ if (blob.size > IMAGE_PROCESS.MAX_OUTPUT_BYTES) {
140
+ if (quality <= IMAGE_PROCESS.MIN_QUALITY) throw new Error('Video frame output exceeds budget');
141
+ quality = Math.max(IMAGE_PROCESS.MIN_QUALITY, quality - IMAGE_PROCESS.QUALITY_STEP);
142
+ encode(); return;
143
+ }
144
+ finish(null, new File([blob], 'cover.jpg', { type: 'image/jpeg' }));
145
+ } catch (error) { finish(error); }
146
+ }, 'image/jpeg', quality);
147
+ } catch (error) { finish(error); }
148
+ };
149
+ encode();
150
+ } catch (error) { finish(error); }
151
+ });
152
+ }
前端页面/market/购买状态生命周期.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 购买/退款状态DOM属于原账号;两种资源入口共用取消和身份释放。
2
+ import { captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
3
+ import { API, CACHE } from "../core/全局配置.js";
4
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
5
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
6
+
7
+ const scopes = new WeakMap();
8
+ let sequence = 0;
9
+ export function createPurchaseStatusScope(node, account) {
10
+ scopes.get(node)?.dispose();
11
+ const matches = captureRequestSession(), componentId = `purchase-status-${++sequence}`;
12
+ let constructing = true, observer;
13
+ const scope = createTaskViewScope({
14
+ sessionMatches: () => matches() && getUser()?.account === account,
15
+ connected: () => constructing || node.isConnected,
16
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
17
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
18
+ cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {}
19
+ });
20
+ scope.readOptions = { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
21
+ scope.addCleanup(() => { observer?.disconnect(); node.replaceChildren(); node.style.display = "none"; });
22
+ node._cleanup = () => scope.dispose(); scopes.set(node, scope);
23
+ queueMicrotask(() => {
24
+ constructing = false;
25
+ if (!scope.isCurrent()) return;
26
+ observer = new MutationObserver(() => { scope.isCurrent(); });
27
+ observer.observe(document.body, { childList: true, subtree: true });
28
+ });
29
+ return scope;
30
+ }
31
+
32
+ export function isValidPurchaseStatus(response) {
33
+ if (response?.status !== "success" || typeof response.owned !== "boolean") return false;
34
+ if (!response.owned) return true;
35
+ return typeof response.allow_refund === "boolean" && typeof response.can_refund === "boolean" && Number.isSafeInteger(response.price_paid) && response.price_paid >= 0
36
+ && typeof response.refund_hours_left === "number" && Number.isFinite(response.refund_hours_left) && response.refund_hours_left >= 0;
37
+ }
前端页面/market/资源删除组件.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 资源卡片与详情共用删除确认;取消页面不取消或重放已经提交的DELETE。
2
+ import { api, captureRequestSession, invalidateRelatedCache } from "../core/网络请求API.js";
3
+ import { CACHE } from "../core/全局配置.js";
4
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
5
+ import { showConfirm, showToast } from "../components/UI交互提示组件.js";
6
+ import { t } from "../components/用户体验增强.js";
7
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
8
+
9
+ const active = new Set();
10
+ export async function showResourceDeleteDialog({ itemData, currentUser, isCurrent = () => true, onDeleted }) {
11
+ const account = currentUser?.account, itemId = itemData?.id;
12
+ const matches = captureRequestSession();
13
+ const sessionMatches = () => matches() && getUser()?.account === account;
14
+ if (!account || !itemId || !sessionMatches() || !isCurrent()
15
+ || (itemData.author !== account && currentUser.is_admin !== true)) return false;
16
+ const key = JSON.stringify([account, itemId]);
17
+ if (active.has(key)) return false;
18
+ active.add(key);
19
+ const controller = new AbortController();
20
+ let observer;
21
+ const scope = createTaskViewScope({
22
+ sessionMatches, connected: isCurrent,
23
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
24
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
25
+ cancelReads: () => {}, invalidate: () => {}
26
+ });
27
+ scope.addCleanup(() => { observer?.disconnect(); controller.abort(); active.delete(key); });
28
+ observer = new MutationObserver(() => { scope.isCurrent(); });
29
+ observer.observe(document.body, { childList: true, subtree: true });
30
+ const message = [t('item.content_label') + (itemData.title || t('item.untitled')),
31
+ t('item.type_label') + (itemData.type || t('item.type_unknown')), '', t('item.delete_consequence'),
32
+ t('item.delete_consequence_1'), t('item.delete_consequence_2'), t('item.delete_consequence_3')].join('\n');
33
+ try {
34
+ const succeeded = await showConfirm(message, {
35
+ title: t('item.confirm_delete_title'), type: 'danger', confirmText: t('item.confirm_delete'),
36
+ processingText: t('item.deleting'), signal: controller.signal,
37
+ onConfirm: async () => {
38
+ if (!scope.isCurrent()) return false;
39
+ try {
40
+ const result = await api.deleteItem(itemId);
41
+ if (!scope.isCurrent()) return false;
42
+ if (result?.status !== 'success') {
43
+ showToast(t('item.delete_failed_retry'), 'error'); return false;
44
+ }
45
+ return true;
46
+ } catch (_) {
47
+ if (scope.isCurrent()) showToast(t('item.delete_request_failed'), 'error');
48
+ return false;
49
+ }
50
+ }
51
+ });
52
+ if (!succeeded || !scope.isCurrent()) return false;
53
+ try { invalidateRelatedCache(`/api/items/${itemId}`, 'DELETE'); }
54
+ catch (_) { console.warn('删除成功,部分本地缓存无法清理'); }
55
+ showToast(t('item.delete_success'), 'success');
56
+ try { onDeleted?.(); } catch (_) { console.warn('删除成功,视图更新失败'); }
57
+ return true;
58
+ } finally { scope.dispose(); }
59
+ }
前端页面/market/资源安装引擎.js CHANGED
@@ -5,16 +5,22 @@ import { requestSSE, request } from "../core/网络请求_基础设施.js";
5
  import { openUserProfileModal } from "../profile/个人中心视图.js";
6
  import { API, CACHE, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
7
  import { t } from "../components/用户体验增强.js";
 
 
 
 
8
 
9
  // 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
10
  // (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
11
- async function _loadGraphToCanvas(graphData) {
 
12
  try {
13
  const { app } = await import("../../../scripts/app.js");
14
- app.loadGraphData(graphData);
 
15
  return true;
16
  } catch (e) {
17
- console.warn("当前环境不支持加载工作流到画布:", e);
18
  return false;
19
  }
20
  }
@@ -47,7 +53,87 @@ function clearUsesCache() {
47
  * @returns {string} JWT Token(未登录时为空字符串)
48
  */
49
  function _getAuthToken() {
50
- return localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token") || "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  /**
@@ -56,563 +142,761 @@ function _getAuthToken() {
56
  * @param {Object} itemData - 资源数据
57
  * @param {string} netdiskPassword - 网盘密码(为空则显示无密码版本)
58
  */
59
- function _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword) {
60
- inlineStatusBox.innerHTML = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  inlineStatusBox.style.display = 'block';
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  if (netdiskPassword) {
64
- // 🔒 XSS防护:使用DOM API安全构建网盘密码显示区域
65
- const container = document.createElement('div');
66
- container.innerHTML = `
67
- <div style="color: #4CAF50; font-weight: bold; margin-bottom: 10px;">✅ 授权通过,网盘资源信息如下:</div>
68
- <div style="background: #1a1d2e; padding: 12px; border-radius: 6px; border: 1px solid #2d334a;">
69
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
70
- <span style="color: #2196F3;">🔗 网盘链接:</span>
71
- <button id="btn-netdisk-open-pwd-${itemData.id}" style="padding: 6px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">📂 打开网盘</button>
72
- </div>
73
- <div style="display: flex; align-items: center; gap: 8px;">
74
- <span style="color: #FF9800;">🔐 提取码:</span>
75
- <code id="netdisk-pwd-code-${itemData.id}" style="background: var(--comfy-input-bg); padding: 4px 10px; border-radius: 4px; color: #FFD700; font-weight: bold; letter-spacing: 2px;"></code>
76
- <button id="btn-copy-pwd-${itemData.id}" style="padding: 4px 8px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">📋 复制</button>
77
- </div>
78
- </div>
79
- <div style="margin-top: 8px; font-size: 11px; color: #888;">💡 提示:请复制提取码后前往网盘下载资源</div>
80
- `;
81
- inlineStatusBox.appendChild(container);
82
-
83
- // 安全设置密码文本(防止XSS)
84
- const pwdCodeEl = container.querySelector(`#netdisk-pwd-code-${itemData.id}`);
85
- if (pwdCodeEl) pwdCodeEl.textContent = netdiskPassword;
86
-
87
- // 绑定复制按钮事件
88
- const copyBtn = container.querySelector(`#btn-copy-pwd-${itemData.id}`);
89
- if (copyBtn) {
90
- copyBtn.onclick = () => {
91
- navigator.clipboard.writeText(netdiskPassword);
92
- copyBtn.textContent = '✅ 已复制';
93
- };
94
- }
95
 
96
- // 绑定打开网盘按钮事件
97
- const btnOpen = inlineStatusBox.querySelector(`#btn-netdisk-open-pwd-${itemData.id}`);
98
- if (btnOpen) {
99
- btnOpen.onclick = () => {
100
- window.open(itemData.link, '_blank');
101
- api.recordItemUse(itemData.id).then(() => {
102
- clearUsesCache();
103
- window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } }));
104
- }).catch(err => console.warn('📊 使用量记录失败:', err));
105
- };
106
- }
107
- } else {
108
- // 无密码网盘资源
109
- inlineStatusBox.innerHTML = `
110
- <div style="color: #4CAF50; font-weight: bold; margin-bottom: 10px;">✅ 授权通过,请前往网盘下载:</div>
111
- <button id="btn-netdisk-open-${itemData.id}" style="padding: 6px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">🔗 打开网盘链接</button>
112
- `;
113
- // 绑定打开网盘按钮事件
114
- const btnOpen = inlineStatusBox.querySelector(`#btn-netdisk-open-${itemData.id}`);
115
- if (btnOpen) {
116
- btnOpen.onclick = () => {
117
- window.open(itemData.link, '_blank');
118
- api.recordItemUse(itemData.id).then(() => {
119
- clearUsesCache();
120
- window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } }));
121
- }).catch(err => console.warn('📊 使用量记录失败:', err));
122
- };
123
- }
124
  }
 
 
 
 
 
125
  }
126
 
127
  /**
128
- * 保存已获取的资源记录到本地
129
- * @param {Object} itemData - 资源数据
 
130
  */
131
- function saveAcquiredItem(itemData) {
 
 
 
 
 
 
 
 
 
 
 
 
132
  try {
133
- const key = CACHE.LOCAL_KEYS.ACQUIRED_ITEMS;
134
- const existing = JSON.parse(localStorage.getItem(key) || "[]");
135
-
136
- // 检查是否已存在,如果存在则更新
137
- const index = existing.findIndex(item => item.id === itemData.id);
138
- const record = {
139
- id: itemData.id,
140
- title: itemData.title,
141
- type: itemData.type,
142
- author: itemData.author,
143
- shortDesc: itemData.shortDesc || itemData.short_desc || '',
144
- coverUrl: itemData.coverUrl || itemData.cover_url || '', // 新增
145
- coverBase64: itemData.coverBase64,
146
- imageUrls: itemData.imageUrls || itemData.image_urls || [], // 新增
147
- link: itemData.link || itemData.github_url || '',
148
- price: itemData.price || 0,
149
- likes: itemData.likes || 0, // 新增
150
- uses: itemData.uses || 0, // 新增
151
- local_version: itemData.latest_version || itemData.local_version || itemData.version || '',
152
- acquired_at: Date.now()
153
- };
154
-
155
- if (index >= 0) {
156
- existing[index] = record;
157
- } else {
158
- existing.unshift(record); // 新记���放在最前面
159
- }
160
-
161
- localStorage.setItem(key, JSON.stringify(existing));
162
- } catch (e) {
163
- console.warn("保存已获取资源记录失败:", e);
164
  }
165
  }
166
 
167
- /**
168
- * 获取所有已获取的资源记录
169
- * @returns {Array}
170
- */
171
- export function getAcquiredItems() {
172
  try {
173
- const key = CACHE.LOCAL_KEYS.ACQUIRED_ITEMS;
174
- return JSON.parse(localStorage.getItem(key) || "[]");
175
- } catch (e) {
176
- return [];
 
 
 
177
  }
178
  }
179
 
180
- /**
181
- * 检查资源是否已安装
182
- * @param {string} itemId - 资源ID
183
- * @returns {Object|null} { installed: boolean, hasUpdate: boolean, localVersion: string|null }
184
- */
185
- export function checkItemStatus(itemId, cloudVersion) {
186
- const localVersion = localStorage.getItem(`ComfyCommunity_LocalVer_${itemId}`);
187
- if (!localVersion) {
188
- return { installed: false, hasUpdate: false, localVersion: null };
189
  }
190
- const hasUpdate = cloudVersion && localVersion !== cloudVersion;
191
- return { installed: true, hasUpdate, localVersion };
192
  }
193
 
194
- export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  btnUse.onclick = async (e) => {
196
  e.stopPropagation();
197
- if (!currentUser) return showToast("⚠️ 请先登录您的社区账号后再获取!", "warning");
198
-
199
- const isFree = !itemData.price || itemData.price <= 0 || currentUser.account === itemData.author;
200
- const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool' || itemData.type === 'skill' || itemData.type === 'recommend_skill';
201
- const isApp = itemData.type === 'app' || itemData.type === 'recommend_app';
202
-
203
- // 📱 Web 模式:不支持下载/安装。免费(含作者本人)或已购买 → 仅提醒电脑端安装;未购买 → 放行到下方纯购买流程
204
- if (IS_WEB_MODE) {
205
- if (isFree) return showToast(t('web.install_on_desktop'), "warning");
206
- let webOwned = false;
207
- try {
208
- const st = await api.getPurchaseStatus(currentUser.account, itemData.id);
209
- webOwned = !!(st && st.owned);
210
- } catch (e) {
211
- console.warn('购买状态查询失败,继续购买流程', e);
212
  }
213
- if (webOwned) return showToast(t('item.owned_purchased'), "warning");
214
- }
215
-
216
- // 记录是否已经拥有,决定是否涨销量
217
- let alreadyOwned = false;
218
 
219
- // 已购买用户跳过余额检查
220
- if (!isFree && currentUser && currentUser.account) {
221
  try {
222
- const purchaseStatus = await api.getPurchaseStatus(currentUser.account, itemData.id);
223
- if (purchaseStatus && purchaseStatus.owned) {
224
- alreadyOwned = true;
 
 
 
225
  }
226
- } catch (e) {
227
- console.warn('购买状态查询失败,继续正常流程', e);
 
 
228
  }
229
- }
230
 
231
- // 【新增】:本地前置余额校验(购买前动态获取最新余额)
232
- if (!isFree && !alreadyOwned) {
233
- try {
234
- const walletRes = await api.getWallet(currentUser.account);
235
- if (walletRes && walletRes.status === "success") {
236
- currentUser.balance = walletRes.balance || 0;
 
 
 
 
237
  }
238
- } catch (e) {
239
- console.warn("获取钱包余额失败:", e);
240
- }
241
-
242
- if ((currentUser.balance || 0) < itemData.price) {
243
- if (await showConfirm(`您的积分余额不足(当前:${currentUser.balance || 0},需要:${itemData.price})。是否前往个人中心充值?`)) {
244
- if (typeof globalModal !== 'undefined') globalModal.closeTopModal();
245
- // 【修复点】:直接调用函数唤起个人中心,解决白屏 Bug
246
- openUserProfileModal(currentUser);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  }
248
- return; // 余额不足,直接终止后续所有云端请求
 
 
249
  }
250
- }
251
-
252
- // 防线 1:扣费前进行死链探测 (解决问题 1)
253
- if (!isFree) {
254
  inlineStatusBox.style.display = "block";
255
- inlineStatusBox.innerHTML = `<span style="color: #2196F3;">⏳ 正在检测云端资源有效性...</span>`;
256
-
257
  try {
258
- // 直接向云端发起验证(含10秒超时控制)
259
- const valController = new AbortController();
260
- const valTimeout = setTimeout(() => valController.abort(), 10000);
261
- const valRes = await fetch(`${API.BASE_URL}/api/validate_resource`, {
262
- method: "POST", headers: {"Content-Type": "application/json"},
263
- body: JSON.stringify({
264
- url: itemData.link,
265
- item_id: itemData.id,
266
- account: currentUser.account
267
- }),
268
- signal: valController.signal
269
- });
270
- clearTimeout(valTimeout);
271
- const valData = await valRes.json();
272
- if (!valRes.ok || valData.error) {
273
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 拦截提示: ${escapeHtml(valData.error || '无法访问源地址')}</span>`;
274
- return; // 发现死链,强行阻断用户付款
275
- }
276
- } catch(e) {
277
- if (e.name === 'AbortError') {
278
- console.warn("[资源验证] 超时,跳过验证");
279
  } else {
280
- console.warn("[���源验证] 网络异常,跳过验证:", e.message || e);
 
281
  }
 
282
  }
283
-
284
- // 已购买用户跳过购买确认弹窗,直接进入后续流程
285
- if (!alreadyOwned) {
286
- const confirmPay = await showConfirm(`该资源标价为 <strong style="color:#FF9800;">${itemData.price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(注:一次购买永久免费,如果已购买过系统将直接放行,不会重复扣款)</span>`, { html: true });
287
- if (!confirmPay) {
288
- inlineStatusBox.style.display = "none";
289
- return;
290
- }
 
 
291
  }
292
- } else {
293
- inlineStatusBox.style.display = "block";
294
- }
295
-
296
- inlineStatusBox.innerHTML = `<span style="color: #2196F3;">⏳ 正在校验购买状态与扣款安全...</span>`;
297
-
298
- // 防线 2:事务级扣费 (天然解决问题 3,意外中断不重扣)
299
- let purchaseRes = null; // ☁️ 保存购买响应(包含网盘密码)
300
- // 🐛 Bug修复:防御性校验,防止 itemId 为空
301
- if (!itemData.id) {
302
- console.warn('⚠️ 资源安装: itemData.id 为空,已拦截无效请求');
303
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败:资源ID无效</span>`;
304
- return;
305
- }
306
- try {
307
- purchaseRes = await api.purchaseItem(currentUser.account, itemData.id);
308
-
309
- // 【需求2修改核心】:如果返回 already_owned,改变提示
310
- if (purchaseRes && purchaseRes.already_owned) {
311
- alreadyOwned = true;
312
- if (!isFree) showToast("您已购买过此商品,此次使用不会收费!", "info");
313
- } else if (!isFree) {
314
- showToast(`支付成功!已扣除 ${itemData.price} 积分。`, "success");
315
- }
316
-
317
- // 更新用户本地显示的余额缓存
318
- try {
319
- const profileRes = await api.getUserProfile(currentUser.account);
320
- if (profileRes.data && profileRes.data.balance !== undefined) {
321
- currentUser.balance = profileRes.data.balance;
322
- }
323
- } catch(e) {}
324
-
325
- } catch (err) {
326
- // 【需求3修改核心】:捕获余额不足异常,阻断流程并引导充值
327
- if (err.message && err.message.includes("余额不足")) {
328
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败:积分余额不足。</span>`;
329
- if (await showConfirm("您的积分余额不足,是否立刻前往个人中心充值?")) {
330
- // 【修复点】:直接调用函数唤起个人中心,解决白屏 Bug
331
- openUserProfileModal(currentUser);
332
- }
333
- return; // 直接退出函数,不会走到下方的安装流程
334
- } else {
335
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败: ${escapeHtml(err.message)}</span>`;
336
- showToast("获取失败:" + err.message, "error");
337
  return;
338
  }
339
- }
340
 
341
- // 📱 Web 模式:购买完成即止,不进入任何下载/安装/网盘交付分支
342
- if (IS_WEB_MODE) {
343
- inlineStatusBox.style.display = "block";
344
- inlineStatusBox.innerHTML = alreadyOwned
345
- ? `<span style="color: #4CAF50;">✅ ${escapeHtml(t('item.owned_purchased'))}</span>`
346
- : `<span style="color: #4CAF50;">✅ ${escapeHtml(t('web.purchase_success_desktop'))}</span>`;
347
- showToast(t('web.install_on_desktop'), "info");
348
- return;
349
- }
350
 
351
- // ☁️ 优先处理网盘资源(不论是工具还是应用)
352
- const isNetdisk = purchaseRes?.is_netdisk || itemData.is_netdisk;
353
- const netdiskPassword = purchaseRes?.netdisk_password || itemData.netdisk_password;
354
-
355
- if (isNetdisk) {
356
- // 网盘资源:显示链接和密码(recordItemUse 延迟到用户点击按钮后)
357
- saveAcquiredItem(itemData);
358
- _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword);
359
- btnUse.innerHTML = `✅ 已获取`;
360
- btnUse.style.background = "#4CAF50";
361
- return; // ← 提前返回,不进入 Git 安装流程
362
- }
363
 
364
- if (isTool) {
365
- const installPromptText = isFree
366
- ? "✅ 授权通过!是否通过 Git 自动将此工具克隆安装到本地 <b>custom_nodes</b> 文件夹?"
367
- : "✅ 授权通过!是否开始静默下载并热覆盖到本地 <b>custom_nodes</b> 文件夹?";
368
-
369
- inlineStatusBox.innerHTML = `
370
- <div style="color: #ccc; margin-bottom: 10px; font-size: 13px;">${installPromptText}</div>
371
- <div style="display: flex; gap: 10px;">
372
- <button id="btn-confirm-install" style="padding: 6px 15px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">✔️ 同意并安装</button>
373
- <button id="btn-cancel-install" style="padding: 6px 15px; background: transparent; color: #aaa; border: 1px solid #555; border-radius: 4px; cursor: pointer;">✖ 取消</button>
374
- </div>
375
- `;
376
- inlineStatusBox.querySelector('#btn-cancel-install').onclick = () => inlineStatusBox.style.display = "none";
377
- inlineStatusBox.querySelector('#btn-confirm-install').onclick = async () => {
378
- // 获取最新的 item 数据以确保路由决策准确
379
- // noCache: true 绕过前端 GET 请求缓存(5min TTL),确保读取服务器最新 has_private_token 值
380
- let freshHasPrivateToken = !!itemData.has_private_token;
381
- let apiFetchFailed = false;
382
  try {
383
- const freshRes = await request(`/api/items/${itemData.id}`, { noCache: true });
384
- if (freshRes.status === "success" && freshRes.data) {
385
- freshHasPrivateToken = !!freshRes.data.has_private_token;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  }
387
- } catch (e) {
388
- apiFetchFailed = true;
389
- console.warn("获取最新 item 数据失败,使用缓存值:", e);
390
  }
391
- const hasPrivateToken = freshHasPrivateToken;
392
- // 路由决策:仅当确认是免费+无私有Token时才走 git clone
393
- // 如果 API 获取失败且缓存中无 has_private_token 信息,保守地走 ZIP 代理路径
394
- const useGitClone = isFree && !hasPrivateToken && !apiFetchFailed;
395
- const localApiEndpoint = useGitClone
396
- ? "/community_hub/install_tool"
397
- : "/community_hub/install_private_tool";
398
 
399
- const progress = createInstallProgress(inlineStatusBox);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  try {
402
- // 发起 SSE 流式安装请求
403
  const result = await requestSSE(
404
- `${localApiEndpoint}_stream`,
405
- { url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() },
406
- (event) => progress.update(event.progress, event.message)
 
 
 
 
 
 
 
 
 
 
407
  );
408
 
 
409
  if (result.status === "success") {
410
- progress.complete(result.message);
411
- // 根据后端返回的 message 判断是否为自更新场景
412
- const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效");
413
- if (isSelfUpdate) {
414
- showToast(`🔄 插件 [${itemData.title}] 新版本已就绪,重启 ComfyUI 即可生效!`, "success");
415
- } else {
416
- showToast(`🎉 插件 [${itemData.title}] 安装成功!请重启 ComfyUI。`, "success");
417
  }
 
 
418
 
419
  // 🚀 安装成功后,盖上本地版本戳
420
- // 优先用本地引擎回传的真实安装 commit(installed_version),兜底云端 latest_version;
421
- // 二者皆空才跳过——避免版本戳缺失导致卡片更新徽章永不触发
422
- const _installedVer = result.installed_version || itemData.latest_version;
423
- if (_installedVer) {
424
- localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, _installedVer);
425
  }
426
 
427
  // 记录使用量(后端自动去重)
428
- try {
429
- await api.recordItemUse(itemData.id);
430
  clearUsesCache();
431
- } catch(err) { console.warn('📊 使用量记录失败:', err); }
432
 
433
  // 🚀 保存到已获取记录
434
  saveAcquiredItem(itemData);
435
 
436
  // 🚀 更新按钮状态
437
- btnUse.innerHTML = `✅ 已安装`;
438
- btnUse.style.background = "#4CAF50";
439
  } else {
440
- progress.error(result.message);
441
- showToast(`插件 [${itemData.title}] 安装失败: ${result.message}`, "error");
 
 
 
 
 
 
442
  }
443
  } catch(err) {
 
 
 
 
 
 
 
 
444
  // 降级到原接口
445
  progress.destroy();
446
- inlineStatusBox.innerHTML = `<span style="color: #2196F3;">⏳ 正在后台静默安装 (关闭侧边栏不影响进度)...</span>`;
447
 
448
  try {
449
- const res = await fetch(localApiEndpoint, {
450
- method: "POST", headers: { "Content-Type": "application/json" },
451
- body: JSON.stringify({ url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() })
 
452
  });
453
- const data = await res.json();
454
-
455
- if (data.error) {
456
- const isLinkError = data.error.includes('不是有效的 Git');
457
- inlineStatusBox.innerHTML = isLinkError
458
- ? `<span style="color: #F44336;">❌ ${escapeHtml(data.error)}</span><br><button id="btn-open-source-link" style="margin-top:8px;padding:6px 14px;background:#2563eb;color:white;border:none;border-radius:4px;cursor:pointer;font-weight:bold;">🔗 前往资源原始页面</button>`
459
- : `<span style="color: #F44336;">❌ 安装失败: ${escapeHtml(data.error)}</span>`;
460
- // 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义,
461
- // 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法)
462
- if (isLinkError) {
463
- const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link');
464
- if (btnOpenSource) {
465
- btnOpenSource.onclick = () => window.open(itemData.link, '_blank');
466
- }
467
- }
468
- showToast(`插件 [${itemData.title}] 安装失败: ${data.error}`, "error");
 
 
 
 
 
 
 
 
469
  } else {
470
- // 根据后端返回的 message 判断是否为自更新场景
471
- const isSelfUpdate = data.message && data.message.includes("重启") && data.message.includes("生效");
472
- if (isSelfUpdate) {
473
- inlineStatusBox.innerHTML = `<div style="color: #4CAF50; font-size: 14px; font-weight: bold;">🔄 新版本已就绪!</div><div style="color: #aaa; margin-top: 5px;">重启 ComfyUI 即可生效。</div>`;
474
- showToast(`🔄 插件 [${itemData.title}] 新版本已就绪,重启 ComfyUI 即可生效!`, "success");
475
- } else {
476
- inlineStatusBox.innerHTML = `<div style="color: #4CAF50; font-size: 14px; font-weight: bold;">🎉 插件安装成功!</div><div style="color: #aaa; margin-top: 5px;">请重启 ComfyUI 以加载新节点。</div>`;
477
- showToast(`🎉 插件 [${itemData.title}] 安装成功!请重启 ComfyUI。`, "success");
478
  }
 
 
479
 
480
- const _installedVer = data.installed_version || itemData.latest_version;
481
- if (_installedVer) {
482
- localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, _installedVer);
 
483
  }
484
 
485
- try {
486
- await api.recordItemUse(itemData.id);
487
  clearUsesCache();
488
- } catch(err2) { console.warn('📊 使用量记录失败:', err2); }
489
 
490
  saveAcquiredItem(itemData);
491
 
492
- btnUse.innerHTML = `✅ 已安装`;
493
- btnUse.style.background = "#4CAF50";
494
  }
495
  } catch(err2) {
496
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务。</span>`;
 
 
497
  }
498
  }
499
- };
500
- } else if (isApp) {
501
- const progress = createInstallProgress(inlineStatusBox);
502
-
503
- // 附带 account 凭证,解决问题 2 (一键鉴权加载)
504
- try {
505
- const result = await requestSSE(
506
- "/community_hub/download_app_stream",
507
- { url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() },
508
- (event) => progress.update(event.progress, event.message),
509
- { timeout: 120000 } // 应用JSON下载2分钟足够
510
- );
511
-
512
- if (result.status === "success") {
513
- progress.complete(result.message);
514
- await _loadGraphToCanvas(result.data);
515
- showToast(`✅ 工作流 [${itemData.title}] 已成功加载到画布!`, "success");
516
-
517
- // 🚀 安装成功后,盖上本地版本戳
518
- if (itemData.latest_version) {
519
- localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, itemData.latest_version);
520
- }
521
-
522
- // 记录使用量(后端自动去重)
523
- api.recordItemUse(itemData.id).then(() => {
524
- clearUsesCache();
525
- }).catch(err => console.warn('📊 使用量记录失败:', err));
526
 
527
- // 🚀 保存到已获取记录
528
- saveAcquiredItem(itemData);
 
529
 
530
- // 🚀 更新按钮状态
531
- btnUse.innerHTML = `✅ 已下载`;
532
- btnUse.style.background = "#4CAF50";
533
  } else {
534
- progress.error(result.message);
535
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(result.message)}</span>`;
536
- showToast(`工作流加载失败:${result.message}`, "error");
537
- }
538
- } catch(err) {
539
- // 降级到原接口
540
- progress.destroy();
541
- inlineStatusBox.innerHTML = `<span style="color: #2196F3;">⏳ 授权通过,正在安全鉴权并热加载入工作区...</span>`;
542
-
543
- try {
544
- const res = await fetch("/community_hub/download_app", {
545
- method: "POST",
546
- headers: { "Content-Type": "application/json" },
547
- body: JSON.stringify({ url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() })
548
- });
549
- if (!res.ok) {
550
- const errText = await res.text().catch(() => '未知服务端错误');
551
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败 (${res.status}):${escapeHtml(errText)}</span>`;
552
- return;
553
- }
554
- let data;
555
- try {
556
- data = await res.json();
557
- } catch (parseErr) {
558
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 响应解析失败,请重试</span>`;
559
- showToast(t('common.parse_error'), "error");
560
- return;
561
- }
562
- if (data.error) {
563
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(data.error)}</span>`;
564
- showToast(`工作流加载失败:${data.error}`, "error");
565
- } else {
566
- await _loadGraphToCanvas(data.data);
567
- inlineStatusBox.innerHTML = `<div style="color: #4CAF50; font-weight: bold;">✅ 工作流已加载到画布!</div><div style="color: #888; margin-top: 4px;">由于本地节点差异可能出现飘红,请使用管理器补全节点。</div>`;
568
- showToast(`✅ 工作流 [${itemData.title}] 已成功加载到画布!`, "success");
569
-
570
- if (itemData.latest_version) {
571
- localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, itemData.latest_version);
572
- }
573
-
574
- api.recordItemUse(itemData.id).then(() => {
575
- clearUsesCache();
576
- }).catch(err => console.warn('📊 使用量记录失败:', err));
577
-
578
- saveAcquiredItem(itemData);
579
-
580
- btnUse.innerHTML = `✅ 已下载`;
581
- btnUse.style.background = "#4CAF50";
582
- }
583
- } catch(err2) {
584
- console.error('❌ 应用下载失败:', err2);
585
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务:${escapeHtml(err2.message || '网络错误')}</span>`;
586
  }
587
  }
588
- } else {
589
- // ☁️ 网盘链接或纯链接模式(recordItemUse 延迟到用户点击按钮后)
590
-
591
- // ☁️ 从购买响应中获取网盘信息(安全:只有购买成功后才返回)
592
- const isNetdisk = purchaseRes?.is_netdisk || itemData.is_netdisk;
593
- const netdiskPassword = purchaseRes?.netdisk_password || itemData.netdisk_password;
594
-
595
- // ☁️ 网盘资源:购买后显示密码
596
- if (isNetdisk) {
597
- _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword);
598
- } else {
599
- // 纯链接模式
600
- inlineStatusBox.innerHTML = `
601
- <div style="color: #4CAF50; font-weight: bold; margin-bottom: 10px;">✅ 授权通过,该资源需手动访问源地址获取��</div>
602
- <button id="btn-pure-link-${itemData.id}" style="padding: 6px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">🔗 前往获取</button>
603
- `;
604
- // 绑定按钮点击事件
605
- const btnOpen = inlineStatusBox.querySelector(`#btn-pure-link-${itemData.id}`);
606
- if (btnOpen) {
607
- btnOpen.onclick = () => {
608
- window.open(itemData.link, '_blank');
609
- api.recordItemUse(itemData.id).then(() => {
610
- clearUsesCache();
611
- window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } }));
612
- }).catch(err => console.warn('📊 使用量记录失败:', err));
613
- };
614
- }
615
- }
616
- }
617
  };
618
- }
 
 
5
  import { openUserProfileModal } from "../profile/个人中心视图.js";
6
  import { API, CACHE, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
7
  import { t } from "../components/用户体验增强.js";
8
+ import { createResourceOperationScope } from "./资源操作生命周期.js";
9
+ import { isValidPurchaseStatus } from "./购买状态生命周期.js";
10
+ import { saveAcquiredItem } from "./资源获取记录.js";
11
+ export { getAcquiredItems } from "./资源获取记录.js";
12
 
13
  // 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
14
  // (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
15
+ async function _loadGraphToCanvas(graphData, isCurrent) {
16
+ if (!isCurrent() || !graphData || typeof graphData !== "object" || Array.isArray(graphData) || Object.hasOwn(graphData, "error")) return false;
17
  try {
18
  const { app } = await import("../../../scripts/app.js");
19
+ if (!isCurrent()) return false;
20
+ await app.loadGraphData(graphData);
21
  return true;
22
  } catch (e) {
23
+ console.warn("当前环境无法加载工作流到画布");
24
  return false;
25
  }
26
  }
 
53
  * @returns {string} JWT Token(未登录时为空字符串)
54
  */
55
  function _getAuthToken() {
56
+ try {
57
+ const localToken = localStorage.getItem("ComfyCommunity_Token");
58
+ if (localToken) return localToken;
59
+ } catch (error) {
60
+ console.warn('无法读取本地登录凭证存储');
61
+ }
62
+ try {
63
+ return sessionStorage.getItem("ComfyCommunity_Token") || "";
64
+ } catch (error) {
65
+ console.warn('无法读取会话登录凭证存储');
66
+ return "";
67
+ }
68
+ }
69
+
70
+ function _getInstallErrorMessage(result) {
71
+ if (result?.outcomeUnknown) return t('install.connection_uncertain');
72
+ const errorKeys = {
73
+ missing_params: 'install.error_missing_params',
74
+ invalid_repository: 'install.error_invalid_repository',
75
+ invalid_target: 'install.error_invalid_target',
76
+ git_missing: 'install.error_git_missing',
77
+ install_timeout: 'install.error_timeout',
78
+ install_queue_timeout: 'install.error_queue_timeout',
79
+ install_queue_full: 'install.error_queue_full',
80
+ git_clone_failed: 'install.error_git_clone',
81
+ insufficient_disk_space: 'install.error_disk_space',
82
+ remote_rejected: 'install.error_remote_rejected',
83
+ package_too_large: 'install.error_package_too_large',
84
+ repository_too_large: 'install.error_repository_too_large',
85
+ process_guard_unavailable: 'install.error_process_guard',
86
+ download_failed: 'install.error_download',
87
+ target_occupied: 'install.error_target_occupied',
88
+ invalid_archive: 'install.error_invalid_archive',
89
+ install_failed: 'install.error_generic'
90
+ };
91
+ const errorKey = Object.hasOwn(errorKeys, result?.error_code) ? errorKeys[result.error_code] : "";
92
+ return errorKey ? t(errorKey) : t('install.error_generic');
93
+ }
94
+
95
+ const INSTALL_STAGE_KEYS = {
96
+ queued: 'install.stage_queued',
97
+ validate: 'install.stage_validate',
98
+ auth: 'install.stage_auth',
99
+ cleanup: 'install.stage_cleanup',
100
+ git_mirror: 'install.stage_git_mirror',
101
+ git_cloning: 'install.stage_git_clone',
102
+ git_fallback: 'install.stage_git_fallback',
103
+ git_direct: 'install.stage_git_direct',
104
+ downloading: 'install.stage_downloading',
105
+ download_done: 'install.stage_download_done',
106
+ extracting: 'install.stage_extracting',
107
+ installing: 'install.stage_installing',
108
+ complete: 'install.stage_complete',
109
+ error: 'install.stage_failed'
110
+ };
111
+
112
+ const WORKFLOW_STAGE_KEYS = {
113
+ validate: 'workflow.stage_validate',
114
+ cache_check: 'workflow.stage_cache_check',
115
+ cache_hit: 'workflow.stage_cache_hit',
116
+ downloading: 'workflow.stage_downloading',
117
+ saving: 'workflow.stage_saving',
118
+ complete: 'workflow.stage_download_complete',
119
+ error: 'workflow.stage_failed'
120
+ };
121
+
122
+ function _getWorkflowErrorMessage(result) {
123
+ if (result?.outcomeUnknown) return t('workflow.connection_uncertain');
124
+ const errorKeys = {
125
+ invalid_request: 'workflow.error_invalid_request',
126
+ missing_params: 'workflow.error_missing_params',
127
+ invalid_app_id: 'workflow.error_invalid_id',
128
+ invalid_workflow_data: 'workflow.error_invalid_data',
129
+ auth_failed: 'workflow.error_auth',
130
+ remote_rejected: 'workflow.error_remote',
131
+ network_failed: 'workflow.error_network',
132
+ cloud_cooldown: 'workflow.error_cloud_cooldown',
133
+ download_failed: 'workflow.error_generic'
134
+ };
135
+ const errorKey = Object.hasOwn(errorKeys, result?.error_code) ? errorKeys[result.error_code] : "";
136
+ return errorKey ? t(errorKey) : t('workflow.error_generic');
137
  }
138
 
139
  /**
 
142
  * @param {Object} itemData - 资源数据
143
  * @param {string} netdiskPassword - 网盘密码(为空则显示无密码版本)
144
  */
145
+ function _getSafeExternalUrl(link) {
146
+ let parsedUrl;
147
+ try {
148
+ if (typeof link !== 'string' || !link.trim()) throw new Error('Missing resource link');
149
+ parsedUrl = new URL(link.trim());
150
+ } catch (_) {
151
+ showToast(t('netdisk.invalid_link'), 'error');
152
+ return null;
153
+ }
154
+ if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password) {
155
+ showToast(t('netdisk.invalid_link'), 'error');
156
+ return null;
157
+ }
158
+ return parsedUrl.href;
159
+ }
160
+
161
+ function _openExternalResource(link, itemId, isCurrent) {
162
+ if (!isCurrent()) return;
163
+ const safeUrl = _getSafeExternalUrl(link);
164
+ if (!safeUrl) return;
165
+ window.open(safeUrl, '_blank', 'noopener,noreferrer');
166
+ api.recordItemUse(itemId).then(() => {
167
+ if (!isCurrent()) return;
168
+ clearUsesCache();
169
+ window.dispatchEvent(new CustomEvent('comfy-trigger-sidebar-reload', { detail: { force: true } }));
170
+ }).catch(() => {});
171
+ }
172
+
173
+ function _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent) {
174
+ if (!isCurrent()) return;
175
+ inlineStatusBox.replaceChildren();
176
  inlineStatusBox.style.display = 'block';
177
 
178
+ const container = document.createElement('div');
179
+ container.className = 'netdisk-card';
180
+ const summary = document.createElement('div');
181
+ summary.className = 'netdisk-card__summary';
182
+ summary.textContent = t(netdiskPassword ? 'netdisk.authorized_with_password' : 'netdisk.authorized_no_password');
183
+ container.appendChild(summary);
184
+
185
+ const panel = document.createElement('div');
186
+ panel.className = 'netdisk-card__panel';
187
+ const openButton = document.createElement('button');
188
+ openButton.type = 'button';
189
+ openButton.className = 'netdisk-card__open';
190
+ openButton.textContent = t('netdisk.open');
191
+ openButton.addEventListener('click', () => _openExternalResource(itemData.link, itemData.id, isCurrent));
192
+
193
+ let hint = null;
194
  if (netdiskPassword) {
195
+ const row = document.createElement('div');
196
+ row.className = 'netdisk-card__row';
197
+ const codeLabel = document.createElement('span');
198
+ codeLabel.className = 'netdisk-card__label';
199
+ codeLabel.textContent = t('netdisk.code_label');
200
+ const code = document.createElement('code');
201
+ code.className = 'netdisk-card__code';
202
+ code.textContent = netdiskPassword;
203
+ const copyButton = document.createElement('button');
204
+ copyButton.type = 'button';
205
+ copyButton.className = 'netdisk-card__copy';
206
+ copyButton.textContent = t('netdisk.copy');
207
+ copyButton.addEventListener('click', async () => {
208
+ if (!isCurrent()) return;
209
+ try {
210
+ await navigator.clipboard.writeText(netdiskPassword);
211
+ if (isCurrent()) copyButton.textContent = t('common.copied');
212
+ } catch (error) {
213
+ if (isCurrent()) showToast(t('netdisk.copy_failed'), 'error');
214
+ }
215
+ });
216
+ row.append(codeLabel, code, copyButton);
217
+ panel.append(row);
 
 
 
 
 
 
 
 
218
 
219
+ hint = document.createElement('div');
220
+ hint.className = 'netdisk-card__hint';
221
+ hint.textContent = t('netdisk.download_hint');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  }
223
+
224
+ panel.prepend(openButton);
225
+ container.appendChild(panel);
226
+ if (hint) container.appendChild(hint);
227
+ inlineStatusBox.appendChild(container);
228
  }
229
 
230
  /**
231
+ * 检查资源是否已安装
232
+ * @param {string} itemId - 资源ID
233
+ * @returns {{installed: boolean, hasUpdate: boolean, localVersion: string|null, storageAvailable: boolean}}
234
  */
235
+ export function checkItemStatus(itemId, cloudVersion) {
236
+ const versionStatus = getInstalledVersionStatus(itemId);
237
+ const localVersion = versionStatus.version;
238
+ if (!localVersion) {
239
+ return { installed: versionStatus.installed === true, hasUpdate: false, localVersion: null, storageAvailable: versionStatus.available };
240
+ }
241
+ const hasUpdate = cloudVersion && localVersion !== cloudVersion;
242
+ return { installed: true, hasUpdate, localVersion, storageAvailable: versionStatus.available };
243
+ }
244
+
245
+ const failedInstalledVersionWrites = new Map();
246
+
247
+ export function getInstalledVersionStatus(itemId) {
248
  try {
249
+ const failedWrite = failedInstalledVersionWrites.has(itemId);
250
+ const recorded = failedWrite ? failedInstalledVersionWrites.get(itemId)
251
+ : localStorage.getItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId);
252
+ const unconfirmed = recorded === CACHE.INSTALLED_VERSION_UNCONFIRMED;
253
+ return { version: unconfirmed ? null : recorded, installed: !!recorded, versionConfirmed: !!recorded && !unconfirmed, available: !failedWrite };
254
+ } catch (error) {
255
+ console.warn('无法读取本地安装版本标记');
256
+ return { version: null, installed: false, versionConfirmed: false, available: false };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  }
258
  }
259
 
260
+ export function saveInstalledVersion(itemId, version) {
261
+ if (!version) return true;
 
 
 
262
  try {
263
+ localStorage.setItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId, version);
264
+ failedInstalledVersionWrites.delete(itemId);
265
+ return true;
266
+ } catch (error) {
267
+ failedInstalledVersionWrites.set(itemId, version);
268
+ console.warn('无法保存本地安装版本标记');
269
+ return false;
270
  }
271
  }
272
 
273
+ function _recordToolInstalledVersion(itemId, result) {
274
+ const value = result?.installed_version;
275
+ const confirmed = typeof value === "string" && /^(?:[a-f0-9]{7}|[a-f0-9]{40})$/i.test(value.trim());
276
+ const version = confirmed ? value.trim().slice(0, 7).toLowerCase() : CACHE.INSTALLED_VERSION_UNCONFIRMED;
277
+ if (!saveInstalledVersion(itemId, version)) {
278
+ showToast(t('install.version_record_unavailable'), 'warning');
 
 
 
279
  }
280
+ if (!confirmed) showToast(t('install.version_unconfirmed'), 'warning');
 
281
  }
282
 
283
+ function _guessInstallTargetName(itemUrl) {
284
+ try {
285
+ const lastSegment = String(itemUrl).replace(/\/+$/, '').split('/').pop() || '';
286
+ const name = lastSegment.replace(/\.git$/i, '');
287
+ return /^[A-Za-z0-9_.-]+$/.test(name) && name !== '.' && name !== '..' && !name.includes('..')
288
+ ? name
289
+ : null;
290
+ } catch (error) {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ const activeOperations = new Map();
296
+ let latestWalletRefresh = 0;
297
+
298
+ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox, options = {}) {
299
+ btnUse.type = "button";
300
+ btnUse.classList.add("resource-install__button");
301
+ for (const property of ["background", "color", "border", "border-color"]) btnUse.style.removeProperty(property);
302
+ inlineStatusBox.classList.add("resource-install__status");
303
+ const account = currentUser?.account, token = _getAuthToken();
304
+ const scope = createResourceOperationScope(btnUse, inlineStatusBox, account, options);
305
+ const isCurrent = () => scope.isCurrent();
306
+ const confirm = (message, options = {}) => showConfirm(message, { ...options, signal: scope.confirmationSignal });
307
+ let phase = "idle", previousProgress, removeProgressCleanup = () => {};
308
+ const operationKey = JSON.stringify([account, String(itemData.id)]);
309
+ scope.addCleanup(() => { if (activeOperations.get(operationKey) === scope) activeOperations.delete(operationKey); });
310
+ const trackProgress = () => {
311
+ removeProgressCleanup(); previousProgress?.destroy();
312
+ previousProgress = createInstallProgress(inlineStatusBox);
313
+ const progress = previousProgress;
314
+ removeProgressCleanup = scope.addCleanup(() => progress.destroy());
315
+ return progress;
316
+ };
317
+ const releaseButton = () => {
318
+ if (phase !== "idle") return;
319
+ if (activeOperations.get(operationKey) === scope) activeOperations.delete(operationKey);
320
+ if (isCurrent()) { btnUse.disabled = false; btnUse.removeAttribute("aria-busy"); }
321
+ };
322
  btnUse.onclick = async (e) => {
323
  e.stopPropagation();
324
+ if (!isCurrent() || phase !== "idle" || activeOperations.has(operationKey)) return;
325
+ if (!account) return showToast(t('auth.login_required'), "warning");
326
+ activeOperations.set(operationKey, scope);
327
+ ++latestWalletRefresh; // 新操作使先前余额补新失效。
328
+ phase = "preflight"; btnUse.disabled = true; btnUse.setAttribute("aria-busy", "true");
329
+ try {
330
+ const price = itemData.price ?? 0;
331
+ if (!Number.isSafeInteger(price) || price < 0 || !itemData.id) {
332
+ showToast(t('acquire.invalid_id'), "error"); return;
 
 
 
 
 
 
333
  }
334
+ const isFree = price === 0 || account === itemData.author;
335
+ const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool' || itemData.type === 'skill' || itemData.type === 'recommend_skill';
336
+ const isApp = itemData.type === 'app' || itemData.type === 'recommend_app';
337
+ if (IS_WEB_MODE && isFree) return showToast(t('web.install_on_desktop'), "warning");
 
338
 
339
+ // 同一次新读取决定是否拥有;查询失败不能继续到付款。
340
+ let alreadyOwned = false;
341
  try {
342
+ const status = await api.getPurchaseStatus(account, itemData.id, scope.readOptions);
343
+ if (!isCurrent()) return;
344
+ if (!isValidPurchaseStatus(status)) throw new Error();
345
+ if (status.price_confirmation_supported !== true) {
346
+ showToast(t('acquire.purchase_verification_unavailable'), "warning");
347
+ return;
348
  }
349
+ alreadyOwned = status.owned;
350
+ } catch (_) {
351
+ if (isCurrent()) showToast(t('common.network_error_retry'), "error");
352
+ return;
353
  }
354
+ if (IS_WEB_MODE && alreadyOwned) return showToast(t('item.owned_purchased'), "warning");
355
 
356
+ if (!isFree && !alreadyOwned) {
357
+ let balance;
358
+ try {
359
+ const wallet = await api.getWallet(account, scope.readOptions);
360
+ if (!isCurrent()) return;
361
+ if (wallet?.status !== "success" || !Number.isSafeInteger(wallet.balance) || wallet.balance < 0) throw new Error();
362
+ balance = wallet.balance;
363
+ } catch (_) {
364
+ if (isCurrent()) showToast(t('common.network_error_retry'), "error");
365
+ return;
366
  }
367
+ if (balance < price) {
368
+ const recharge = await confirm(`${t('acquire.balance_short', { balance, price })} ${t('acquire.balance_recharge_prompt')}`);
369
+ if (recharge && isCurrent()) await openUserProfileModal(currentUser);
370
+ return;
371
+ }
372
+ if (itemData.is_netdisk === true) {
373
+ if (!_getSafeExternalUrl(itemData.link)) return;
374
+ } else {
375
+ inlineStatusBox.style.display = "block";
376
+ inlineStatusBox.textContent = t('acquire.validation_running');
377
+ const validationController = new AbortController();
378
+ const removeValidationCleanup = scope.addCleanup(() => validationController.abort());
379
+ const timeout = setTimeout(() => validationController.abort(), API.BACKGROUND_TIMEOUT);
380
+ try {
381
+ const response = await fetch(`${API.BASE_URL}/api/validate_resource`, {
382
+ method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
383
+ body: JSON.stringify({ url: itemData.link, item_id: itemData.id, account }),
384
+ signal: validationController.signal
385
+ });
386
+ if (!isCurrent()) return;
387
+ const data = await response.json();
388
+ if (!isCurrent()) return;
389
+ if (!response.ok || data?.status !== "success" || data.error) throw new Error();
390
+ } catch (_) {
391
+ if (isCurrent()) inlineStatusBox.textContent = t('acquire.validation_blocked', { message: t('common.network_error_retry') });
392
+ return;
393
+ } finally { clearTimeout(timeout); removeValidationCleanup(); }
394
  }
395
+ const confirmPay = await confirm(t('acquire.purchase_confirmation_html', { price }), { html: true });
396
+ if (!isCurrent()) return;
397
+ if (!confirmPay) { inlineStatusBox.style.display = "none"; return; }
398
  }
399
+ if (!isCurrent()) return;
 
 
 
400
  inlineStatusBox.style.display = "block";
401
+ inlineStatusBox.textContent = t('acquire.purchase_running');
402
+ let purchaseRes;
403
  try {
404
+ purchaseRes = await api.purchaseItem(account, itemData.id, isFree ? 0 : price);
405
+ if (!isCurrent()) return;
406
+ if (purchaseRes?.status !== "success" || typeof purchaseRes.already_owned !== "boolean"
407
+ || typeof purchaseRes.is_netdisk !== "boolean"
408
+ || (purchaseRes.netdisk_password != null && typeof purchaseRes.netdisk_password !== "string")) throw new Error();
409
+ } catch (error) {
410
+ if (!isCurrent()) return;
411
+ inlineStatusBox.setAttribute("role", "alert");
412
+ if (["purchase_price_changed", "purchase_price_confirmation_required"].includes(error?.code)) {
413
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
414
+ inlineStatusBox.textContent = t('acquire.price_changed');
415
+ } else if (error?.status >= 400 && error.status < 500) {
416
+ inlineStatusBox.textContent = t(error.status === 402 ? 'acquire.purchase_balance_short' : 'common.error');
 
 
 
 
 
 
 
 
417
  } else {
418
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
419
+ inlineStatusBox.textContent = t('acquire.purchase_unknown');
420
  }
421
+ return;
422
  }
423
+ alreadyOwned = purchaseRes.already_owned;
424
+ if (!isFree) showToast(t(alreadyOwned ? 'acquire.already_owned_no_charge' : 'acquire.purchase_success', { price }), alreadyOwned ? "info" : "success");
425
+ // 权威余额来自 SQL 钱包;补新不阻塞已经确认的资源交付。
426
+ if (!isFree) {
427
+ const walletVersion = ++latestWalletRefresh;
428
+ api.getWallet(account, scope.readOptions).then(wallet => {
429
+ if (walletVersion !== latestWalletRefresh || !isCurrent() || wallet?.status !== "success" || !Number.isSafeInteger(wallet.balance) || wallet.balance < 0) return;
430
+ currentUser.balance = wallet.balance;
431
+ window.dispatchEvent(new CustomEvent("comfy-wallet-update", { detail: { account, balance: wallet.balance } }));
432
+ }).catch(() => {});
433
  }
434
+ if (!isCurrent()) return;
435
+
436
+ // 📱 Web 模式:购买完成即止,不进入任何下载/安装/网盘交付分支
437
+ if (IS_WEB_MODE) {
438
+ inlineStatusBox.style.display = "block";
439
+ inlineStatusBox.innerHTML = alreadyOwned
440
+ ? `<span class="resource-install__message resource-install__message--success">✅ ${escapeHtml(t('item.owned_purchased'))}</span>`
441
+ : `<span class="resource-install__message resource-install__message--success">✅ ${escapeHtml(t('web.purchase_success_desktop'))}</span>`;
442
+ showToast(t('web.install_on_desktop'), "info");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  return;
444
  }
 
445
 
446
+ // ☁️ 优先处理网盘资源(不论是工具还是应用)
447
+ const isNetdisk = purchaseRes.is_netdisk;
448
+ const netdiskPassword = purchaseRes.netdisk_password;
 
 
 
 
 
 
449
 
450
+ if (isNetdisk) {
451
+ // 网盘资源:显示链接和密码(recordItemUse 延迟到用户点击按钮后)
452
+ saveAcquiredItem(itemData);
453
+ _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent);
454
+ btnUse.innerHTML = `✅ ${escapeHtml(t('common.acquired'))}`;
455
+ btnUse.setAttribute("data-install-state", "success");
456
+ return; // ← 提前返回,不进入 Git 安装流程
457
+ }
 
 
 
 
458
 
459
+ if (isTool) {
460
+ inlineStatusBox.style.display = "block";
461
+ inlineStatusBox.textContent = t('install.preview_loading');
462
+ let installPreview = null;
463
+ const previewController = new AbortController();
464
+ const removePreviewCleanup = scope.addCleanup(() => previewController.abort());
465
+ const previewTimeout = setTimeout(() => previewController.abort(), API.BACKGROUND_TIMEOUT);
 
 
 
 
 
 
 
 
 
 
 
466
  try {
467
+ const previewResponse = await fetch("/community_hub/install_preview", {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/json" },
470
+ body: JSON.stringify({ url: itemData.link }), signal: previewController.signal
471
+ });
472
+ if (!isCurrent()) return;
473
+ if (previewResponse.ok) {
474
+ const previewData = await previewResponse.json();
475
+ if (!isCurrent()) return;
476
+ if (previewData.status === "success") installPreview = previewData;
477
+ }
478
+ } catch (error) {
479
+ if (!isCurrent()) return;
480
+ } finally { clearTimeout(previewTimeout); removePreviewCleanup(); }
481
+ if (!isCurrent()) return;
482
+ const guessedTargetName = _guessInstallTargetName(itemData.link);
483
+ const targetDisplay = installPreview?.target_display
484
+ || (guessedTargetName ? `custom_nodes/${guessedTargetName}` : null);
485
+ const installPromptText = t(
486
+ isFree ? 'install.confirm_git' : 'install.confirm_zip',
487
+ { folder: targetDisplay || t('install.target_unknown') }
488
+ );
489
+ const targetStateText = installPreview
490
+ ? (installPreview.self_update
491
+ ? t('install.target_self_update')
492
+ : (installPreview.already_exists
493
+ ? t('install.target_exists')
494
+ : t('install.target_new')))
495
+ : t('install.preview_unavailable');
496
+ let localVersionText = '';
497
+ if (installPreview?.already_exists) {
498
+ if (installPreview.current_git_version) {
499
+ localVersionText = t('install.current_git_version', {
500
+ version: installPreview.current_git_version
501
+ });
502
+ } else if (!isFree) {
503
+ try {
504
+ const recordedVersion = getInstalledVersionStatus(itemData.id).version;
505
+ if (recordedVersion) {
506
+ localVersionText = t('install.recorded_local_version', { version: recordedVersion });
507
+ }
508
+ } catch (error) {
509
+ console.warn('无法读取本地已安装版本记录');
510
+ }
511
  }
 
 
 
512
  }
 
 
 
 
 
 
 
513
 
514
+ phase = "confirm";
515
+ inlineStatusBox.innerHTML = `
516
+ <div class="resource-install__confirmation" role="group" aria-label="${escapeHtml(t('install.confirmation_region'))}">
517
+ <div role="heading" aria-level="3" class="resource-install__heading">${escapeHtml(installPromptText)}</div>
518
+ <div role="status" aria-live="polite" class="resource-install__preview">${escapeHtml(targetStateText)}</div>
519
+ ${localVersionText ? `<div role="status" aria-live="polite" class="resource-install__preview">${escapeHtml(localVersionText)}</div>` : ''}
520
+ <div class="resource-install__actions">
521
+ <button id="btn-confirm-install" type="button" class="resource-install__button resource-install__button--confirm">${escapeHtml(t('install.confirm_button'))}</button>
522
+ <button id="btn-cancel-install" type="button" class="resource-install__button">${escapeHtml(t('common.cancel'))}</button>
523
+ </div>
524
+ </div>
525
+ `;
526
+ const confirmInstallButton = inlineStatusBox.querySelector('#btn-confirm-install');
527
+ const cancelInstallButton = inlineStatusBox.querySelector('#btn-cancel-install');
528
+ let removeEscapeCleanup = () => {};
529
+ const removeInstallEscapeHandler = () => {
530
+ inlineStatusBox.removeEventListener("keydown", handleInstallEscape); removeEscapeCleanup();
531
+ };
532
+ const cancelInstallPrompt = () => {
533
+ if (!isCurrent() || phase !== "confirm") return;
534
+ phase = "idle"; releaseButton();
535
+ removeInstallEscapeHandler();
536
+ inlineStatusBox.style.display = "none";
537
+ btnUse.focus();
538
+ };
539
+ const handleInstallEscape = (event) => {
540
+ if (event.key === "Escape") {
541
+ event.preventDefault();
542
+ cancelInstallPrompt();
543
+ }
544
+ };
545
+ cancelInstallButton.onclick = cancelInstallPrompt;
546
+ inlineStatusBox.addEventListener("keydown", handleInstallEscape);
547
+ removeEscapeCleanup = scope.addCleanup(removeInstallEscapeHandler);
548
+ confirmInstallButton.focus();
549
+ confirmInstallButton.onclick = async () => {
550
+ if (!isCurrent() || phase !== "confirm") return;
551
+ phase = "install";
552
+ try {
553
+ removeInstallEscapeHandler();
554
+ confirmInstallButton.disabled = true;
555
+ cancelInstallButton.disabled = true;
556
+ // 获取最新的 item 数据以确保路由决策准确
557
+ // noCache: true 绕过前端 GET 请求缓存(5min TTL),确保读取服务器最新 has_private_token 值
558
+ let freshHasPrivateToken = !!itemData.has_private_token;
559
+ let apiFetchFailed = false;
560
+ try {
561
+ const freshRes = await request(`/api/items/${itemData.id}`, scope.readOptions);
562
+ if (!isCurrent()) return;
563
+ if (freshRes.status === "success" && freshRes.data) {
564
+ freshHasPrivateToken = !!freshRes.data.has_private_token;
565
+ }
566
+ } catch (e) {
567
+ apiFetchFailed = true;
568
+ if (!isCurrent()) return;
569
+ }
570
+ if (!isCurrent()) return;
571
+ const hasPrivateToken = freshHasPrivateToken;
572
+ // 路由决策:仅当确认是免费+无私有Token时才走 git clone
573
+ // 如果 API 获取失败且缓存中无 has_private_token 信息,保守地走 ZIP 代理路径
574
+ const useGitClone = isFree && !hasPrivateToken && !apiFetchFailed;
575
+ const localApiEndpoint = useGitClone
576
+ ? "/community_hub/install_tool"
577
+ : "/community_hub/install_private_tool";
578
+
579
+ const progress = trackProgress();
580
+ const cancelController = new AbortController();
581
+ progress.addCancelButton(t('install.cancel_in_progress'), () => cancelController.abort());
582
+ const diagnosticEvents = [];
583
+ const installStartedAt = Date.now();
584
 
585
+ try {
586
+ // 发起 SSE 流式安装请求
587
+ const result = await requestSSE(
588
+ `${localApiEndpoint}_stream`,
589
+ { url: itemData.link, id: itemData.id, account, token },
590
+ (event) => {
591
+ if (!isCurrent()) return;
592
+ const elapsedSeconds = Math.floor((Date.now() - installStartedAt) / 1000);
593
+ const rawStage = typeof event.stage === "string" ? event.stage : "";
594
+ diagnosticEvents.push({
595
+ stageKey: Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "",
596
+ stage: /^[a-z0-9_]{1,40}$/i.test(rawStage) ? rawStage : "",
597
+ progress: Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null,
598
+ time: `${Math.floor(elapsedSeconds / 60)}:${String(elapsedSeconds % 60).padStart(2, "0")}`
599
+ });
600
+ if (diagnosticEvents.length > 50) diagnosticEvents.shift();
601
+ const stageKey = Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "";
602
+ progress.update(event.progress, event.status === "error"
603
+ ? _getInstallErrorMessage(event)
604
+ : t(stageKey || 'install.stage_installing'));
605
+ },
606
+ { signal: cancelController.signal }
607
+ );
608
+
609
+ if (!isCurrent()) return;
610
+ if (result.cancelRequested) {
611
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
612
+ const message = t('install.cancel_outcome_uncertain');
613
+ progress.error(message);
614
+ showToast(message, "warning");
615
+ return;
616
+ }
617
+
618
+ if (result.status === "success") {
619
+ progress.complete(t('install.complete'));
620
+ // 根据后端返回的 message 判断是否为自更新场景
621
+ const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效");
622
+ if (isSelfUpdate) {
623
+ showToast(t('install.completed_update', { title: itemData.title }), "success");
624
+ } else {
625
+ showToast(t('install.completed_restart', { title: itemData.title }), "success");
626
+ }
627
+
628
+ // 仅记录本次引擎证明的commit;榜单版本不能证明已经安装。
629
+ if (!isSelfUpdate) _recordToolInstalledVersion(itemData.id, result);
630
+
631
+ // 记录使用量(后端自动去重)
632
+ try {
633
+ await api.recordItemUse(itemData.id);
634
+ if (!isCurrent()) return;
635
+ clearUsesCache();
636
+ } catch (_) {}
637
+ if (!isCurrent()) return;
638
+
639
+ // 🚀 保存到已获取记录
640
+ saveAcquiredItem(itemData);
641
+
642
+ // 🚀 更新按钮状态
643
+ btnUse.innerHTML = `✅ ${escapeHtml(t('common.installed'))}`;
644
+ btnUse.setAttribute("data-install-state", "success");
645
+ } else {
646
+ if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); }
647
+ const message = _getInstallErrorMessage(result);
648
+ progress.error(message);
649
+ progress.showDiagnostics(diagnosticEvents);
650
+ showToast(t('install.failed_for_item', { title: itemData.title, message }), "error");
651
+ }
652
+ } catch(err) {
653
+ if (!isCurrent()) return;
654
+ if (!err?.fallbackSafe) {
655
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
656
+ const message = t('install.connection_uncertain');
657
+ progress.error(message);
658
+ progress.showDiagnostics(diagnosticEvents);
659
+ showToast(message, "error");
660
+ return;
661
+ }
662
+ // 降级到原接口
663
+ progress.destroy();
664
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--info">⏳ ${escapeHtml(t('install.background_started'))}</span>`;
665
+
666
+ try {
667
+ const res = await fetch(localApiEndpoint, {
668
+ method: "POST", headers: { "Content-Type": "application/json" },
669
+ body: JSON.stringify({ url: itemData.link, id: itemData.id, account, token })
670
+ });
671
+ if (!isCurrent()) return;
672
+ const data = await res.json();
673
+ if (!isCurrent()) return;
674
+ if (!res.ok || data?.status !== "success") {
675
+ const isLinkError = typeof data?.error === "string" && data.error.includes('不是有效的 Git');
676
+ const safeMessage = isLinkError ? t('install.error_invalid_repository') : t('install.error_generic');
677
+ inlineStatusBox.innerHTML = isLinkError
678
+ ? `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(safeMessage)}</span><br><button id="btn-open-source-link" type="button" class="resource-install__button resource-install__source">🔗 ${escapeHtml(t('install.open_source'))}</button>`
679
+ : `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('install.failed_for_item', { title: itemData.title, message: safeMessage }))}</span>`;
680
+ // 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义,
681
+ // 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法)
682
+ if (isLinkError) {
683
+ const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link');
684
+ if (btnOpenSource) {
685
+ btnOpenSource.onclick = () => {
686
+ if (!isCurrent()) return;
687
+ const safeUrl = _getSafeExternalUrl(itemData.link);
688
+ if (safeUrl) window.open(safeUrl, '_blank', 'noopener,noreferrer');
689
+ };
690
+ }
691
+ }
692
+ showToast(t('install.failed_for_item', { title: itemData.title, message: safeMessage }), "error");
693
+ } else {
694
+ // 根据后端返回的 message 判断是否为自更新场景
695
+ const isSelfUpdate = data.message && data.message.includes("重启") && data.message.includes("生效");
696
+ if (isSelfUpdate) {
697
+ inlineStatusBox.innerHTML = `<div class="resource-install__message resource-install__message--success resource-install__complete">🔄 ${escapeHtml(t('install.stage_complete'))}</div><div class="resource-install__note">${escapeHtml(t('install.restart_to_apply'))}</div>`;
698
+ showToast(t('install.completed_update', { title: itemData.title }), "success");
699
+ } else {
700
+ inlineStatusBox.innerHTML = `<div class="resource-install__message resource-install__message--success resource-install__complete">🎉 ${escapeHtml(t('install.complete'))}</div><div class="resource-install__note">${escapeHtml(t('install.restart_to_load'))}</div>`;
701
+ showToast(t('install.completed_restart', { title: itemData.title }), "success");
702
+ }
703
+
704
+ if (!isSelfUpdate) _recordToolInstalledVersion(itemData.id, data);
705
+
706
+ try {
707
+ await api.recordItemUse(itemData.id);
708
+ if (!isCurrent()) return;
709
+ clearUsesCache();
710
+ } catch (_) {}
711
+ if (!isCurrent()) return;
712
+
713
+ saveAcquiredItem(itemData);
714
+
715
+ btnUse.innerHTML = `✅ ${escapeHtml(t('common.installed'))}`;
716
+ btnUse.setAttribute("data-install-state", "success");
717
+ }
718
+ } catch(err2) {
719
+ if (!isCurrent()) return;
720
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
721
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}</span>`;
722
+ }
723
+ }
724
+ } catch (_) {
725
+ if (isCurrent()) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); inlineStatusBox.textContent = t('install.connection_uncertain'); }
726
+ } finally { if (phase === "install") phase = "idle"; releaseButton(); }
727
+ };
728
+ } else if (isApp) {
729
+ const progress = trackProgress();
730
+
731
+ // 附带 account 凭证,解决问题 2 (一键鉴权加载)
732
  try {
 
733
  const result = await requestSSE(
734
+ "/community_hub/download_app_stream",
735
+ { url: itemData.link, id: itemData.id, account, token },
736
+ (event) => {
737
+ if (!isCurrent()) return;
738
+ const stageKey = typeof event.stage === "string" && Object.hasOwn(WORKFLOW_STAGE_KEYS, event.stage)
739
+ ? WORKFLOW_STAGE_KEYS[event.stage]
740
+ : "";
741
+ const message = event.status === "error"
742
+ ? _getWorkflowErrorMessage(event)
743
+ : t(stageKey || 'workflow.stage_downloading');
744
+ progress.update(event.progress, message);
745
+ },
746
+ { timeout: API.WORKFLOW_DOWNLOAD_TIMEOUT }
747
  );
748
 
749
+ if (!isCurrent()) return;
750
  if (result.status === "success") {
751
+ const graphLoaded = await _loadGraphToCanvas(result.data, isCurrent);
752
+ if (!isCurrent()) return;
753
+ if (!graphLoaded) {
754
+ const message = t('workflow.canvas_load_failed');
755
+ progress.error(message);
756
+ showToast(message, "error");
757
+ return;
758
  }
759
+ progress.complete(t('workflow.loaded_for_item', { title: itemData.title }));
760
+ showToast(t('workflow.loaded_for_item', { title: itemData.title }), "success");
761
 
762
  // 🚀 安装成功后,盖上本地版本戳
763
+ if (itemData.latest_version) {
764
+ if (!saveInstalledVersion(itemData.id, itemData.latest_version)) {
765
+ showToast(t('install.version_record_unavailable'), 'warning');
766
+ }
 
767
  }
768
 
769
  // 记录使用量(后端自动去重)
770
+ api.recordItemUse(itemData.id).then(() => {
771
+ if (!isCurrent()) return;
772
  clearUsesCache();
773
+ }).catch(() => {});
774
 
775
  // 🚀 保存到已获取记录
776
  saveAcquiredItem(itemData);
777
 
778
  // 🚀 更新按钮状态
779
+ btnUse.innerHTML = `✅ ${escapeHtml(t('common.downloaded'))}`;
780
+ btnUse.setAttribute("data-install-state", "success");
781
  } else {
782
+ if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); }
783
+ const message = _getWorkflowErrorMessage(result);
784
+ const localizedMessage = result.error_code
785
+ ? message
786
+ : t('workflow.failed', { message });
787
+ progress.error(message);
788
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(localizedMessage)}</span>`;
789
+ showToast(localizedMessage, "error");
790
  }
791
  } catch(err) {
792
+ if (!isCurrent()) return;
793
+ if (!err?.fallbackSafe) {
794
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
795
+ const message = t('workflow.connection_uncertain');
796
+ progress.error(message);
797
+ showToast(message, "error");
798
+ return;
799
+ }
800
  // 降级到原接口
801
  progress.destroy();
802
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--info">⏳ ${escapeHtml(t('workflow.fallback_loading'))}</span>`;
803
 
804
  try {
805
+ const res = await fetch("/community_hub/download_app", {
806
+ method: "POST",
807
+ headers: { "Content-Type": "application/json" },
808
+ body: JSON.stringify({ url: itemData.link, id: itemData.id, account, token })
809
  });
810
+ if (!isCurrent()) return;
811
+ if (!res.ok) {
812
+ const messageKey = [401, 403].includes(res.status)
813
+ ? 'workflow.error_auth'
814
+ : ([502, 503, 504].includes(res.status) ? 'workflow.error_network' : 'workflow.error_generic');
815
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.http_failed', { status: res.status, message: t(messageKey) }))}</span>`;
816
+ return;
817
+ }
818
+ let data;
819
+ try {
820
+ data = await res.json();
821
+ if (!isCurrent()) return;
822
+ } catch (parseErr) {
823
+ if (!isCurrent()) return;
824
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.parse_failed'))}</span>`;
825
+ showToast(t('common.parse_error'), "error");
826
+ return;
827
+ }
828
+ if (data?.status !== "success") {
829
+ // 旧式响应的服务端原文可能未本地化,也可能包含上游诊断/链接信息;
830
+ // 使用稳定双语类别提示,不把远端正文直接展示或写入诊断日志。
831
+ const message = t('workflow.error_remote');
832
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(message)}</span>`;
833
+ showToast(message, "error");
834
  } else {
835
+ const graphLoaded = await _loadGraphToCanvas(data.data, isCurrent);
836
+ if (!isCurrent()) return;
837
+ if (!graphLoaded) {
838
+ const message = t('workflow.canvas_load_failed');
839
+ inlineStatusBox.textContent = message;
840
+ showToast(message, "error");
841
+ return;
 
842
  }
843
+ inlineStatusBox.innerHTML = `<div class="resource-install__message resource-install__message--success resource-install__complete">✅ ${escapeHtml(t('workflow.loaded_for_item', { title: itemData.title }))}</div><div class="resource-install__note">${escapeHtml(t('workflow.loaded_warning'))}</div>`;
844
+ showToast(t('workflow.loaded_for_item', { title: itemData.title }), "success");
845
 
846
+ if (itemData.latest_version) {
847
+ if (!saveInstalledVersion(itemData.id, itemData.latest_version)) {
848
+ showToast(t('install.version_record_unavailable'), 'warning');
849
+ }
850
  }
851
 
852
+ api.recordItemUse(itemData.id).then(() => {
853
+ if (!isCurrent()) return;
854
  clearUsesCache();
855
+ }).catch(() => {});
856
 
857
  saveAcquiredItem(itemData);
858
 
859
+ btnUse.innerHTML = `✅ ${escapeHtml(t('common.downloaded'))}`;
860
+ btnUse.setAttribute("data-install-state", "success");
861
  }
862
  } catch(err2) {
863
+ if (!isCurrent()) return;
864
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
865
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}</span>`;
866
  }
867
  }
868
+ } else {
869
+ // ☁️ 网盘链接或纯链接模式(recordItemUse 延迟到用户点击按钮后)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
 
871
+ // ☁️ 从购买响应中获取网盘信息(安全:只有购买成功后才返回)
872
+ const isNetdisk = purchaseRes.is_netdisk;
873
+ const netdiskPassword = purchaseRes.netdisk_password;
874
 
875
+ // ☁️ 网盘资源:购买后显示密码
876
+ if (isNetdisk) {
877
+ _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent);
878
  } else {
879
+ inlineStatusBox.replaceChildren();
880
+ const card = document.createElement('div');
881
+ card.className = 'netdisk-card';
882
+ const summary = document.createElement('div');
883
+ summary.className = 'netdisk-card__summary';
884
+ summary.textContent = t('acquire.pure_link_authorized');
885
+ const openButton = document.createElement('button');
886
+ openButton.type = 'button';
887
+ openButton.className = 'netdisk-card__open';
888
+ openButton.textContent = t('netdisk.open');
889
+ openButton.addEventListener('click', () => _openExternalResource(itemData.link, itemData.id, isCurrent));
890
+ const panel = document.createElement('div');
891
+ panel.className = 'netdisk-card__panel';
892
+ panel.appendChild(openButton);
893
+ card.append(summary, panel);
894
+ inlineStatusBox.appendChild(card);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
  }
896
  }
897
+ } catch (_) {
898
+ if (isCurrent()) { inlineStatusBox.style.display = "block"; inlineStatusBox.textContent = t('common.error'); }
899
+ } finally { if (phase === "preflight") phase = "idle"; releaseButton(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
  };
901
+ return scope;
902
+ }
前端页面/market/资源操作生命周期.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 安装与交付入口共用原账号/节点范围;已经提交的购买和安装不自动重放。
2
+ import { captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
3
+ import { API, CACHE } from "../core/全局配置.js";
4
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
5
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
6
+
7
+ const scopes = new WeakMap();
8
+ let sequence = 0;
9
+ export function createResourceOperationScope(button, statusBox, account, options = {}) {
10
+ scopes.get(button)?.dispose();
11
+ const matches = captureRequestSession(), componentId = `resource-operation-${++sequence}`;
12
+ const confirmation = new AbortController(), cleanups = new Set();
13
+ let inner, observer, disposed = false;
14
+ const cleanup = () => {
15
+ if (disposed) return;
16
+ disposed = true;
17
+ observer?.disconnect(); confirmation.abort();
18
+ button.disabled = true; button.removeAttribute("aria-busy");
19
+ statusBox.replaceChildren(); statusBox.style.display = "none";
20
+ for (const callback of cleanups) { try { callback(); } catch (_) {} }
21
+ cleanups.clear();
22
+ };
23
+ const scope = {
24
+ readOptions: { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES },
25
+ confirmationSignal: confirmation.signal,
26
+ dispose() { if (inner) inner.dispose(); else cleanup(); },
27
+ addCleanup(callback) {
28
+ if (disposed) { callback(); return () => {}; }
29
+ cleanups.add(callback); return () => cleanups.delete(callback);
30
+ },
31
+ isCurrent() {
32
+ if (disposed) return false;
33
+ // 只有实际操作过的卡片订阅身份/DOM变化,避免整个榜单逐卡安装全局监听。
34
+ if (!inner) {
35
+ inner = createTaskViewScope({
36
+ sessionMatches: () => matches() && getUser()?.account === account,
37
+ connected: () => button.isConnected && statusBox.isConnected
38
+ && (!options.isCurrent || options.isCurrent()),
39
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
40
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
41
+ cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {}
42
+ });
43
+ inner.addCleanup(cleanup);
44
+ observer = new MutationObserver(() => { scope.isCurrent(); });
45
+ observer.observe(document.body, { childList: true, subtree: true });
46
+ }
47
+ return inner.isCurrent();
48
+ }
49
+ };
50
+ button._cleanup = () => scope.dispose(); scopes.set(button, scope);
51
+ return scope;
52
+ }
前端页面/market/资源获取记录.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 本地交付记录仅用于离线展示,不作为购买权限或安装结果的依据。
2
+ import { CACHE } from "../core/全局配置.js";
3
+
4
+ function validId(id) {
5
+ return (typeof id === "string" && id.trim().length > 0) ||
6
+ (typeof id === "number" && Number.isFinite(id));
7
+ }
8
+
9
+ function readRecords() {
10
+ let raw;
11
+ try { raw = localStorage.getItem(CACHE.LOCAL_KEYS.ACQUIRED_ITEMS); }
12
+ catch { return { available: false, records: [] }; }
13
+ let entries;
14
+ try { entries = JSON.parse(raw || "[]"); }
15
+ catch { entries = []; }
16
+ if (!Array.isArray(entries)) entries = [];
17
+ return { available: true, records: entries.filter(entry =>
18
+ entry && typeof entry === "object" && !Array.isArray(entry) && validId(entry.id)) };
19
+ }
20
+
21
+ function writeRecords(records) {
22
+ try {
23
+ localStorage.setItem(CACHE.LOCAL_KEYS.ACQUIRED_ITEMS, JSON.stringify(records));
24
+ return true;
25
+ } catch {
26
+ console.warn("本地资源记录无法持久化");
27
+ return false;
28
+ }
29
+ }
30
+
31
+ export function getAcquiredItems() {
32
+ return readRecords().records;
33
+ }
34
+
35
+ export function saveAcquiredItem(itemData) {
36
+ if (!itemData || typeof itemData !== "object" || Array.isArray(itemData) || !validId(itemData.id)) return false;
37
+ const snapshot = readRecords();
38
+ // 无法读取时不能用空数组覆盖可能仍存在的历史记录。
39
+ if (!snapshot.available) return false;
40
+ const record = {
41
+ id: itemData.id,
42
+ title: itemData.title,
43
+ type: itemData.type,
44
+ author: itemData.author,
45
+ shortDesc: itemData.shortDesc || itemData.short_desc || '',
46
+ coverUrl: itemData.coverUrl || itemData.cover_url || '',
47
+ coverBase64: itemData.coverBase64,
48
+ imageUrls: itemData.imageUrls || itemData.image_urls || [],
49
+ link: itemData.link || itemData.github_url || '',
50
+ price: itemData.price || 0,
51
+ likes: itemData.likes || 0,
52
+ uses: itemData.uses || 0,
53
+ local_version: itemData.latest_version || itemData.local_version || itemData.version || '',
54
+ acquired_at: Date.now()
55
+ };
56
+ const index = snapshot.records.findIndex(entry => String(entry.id) === String(itemData.id));
57
+ if (index >= 0) snapshot.records[index] = record;
58
+ else snapshot.records.unshift(record);
59
+ return writeRecords(snapshot.records);
60
+ }
61
+
62
+ export function removeAcquiredItem(itemId) {
63
+ if (!validId(itemId)) return false;
64
+ const snapshot = readRecords();
65
+ if (!snapshot.available) return false;
66
+ return writeRecords(snapshot.records.filter(entry => String(entry.id) !== String(itemId)));
67
+ }
前端页面/market/资源详情页面组件.js CHANGED
@@ -10,23 +10,26 @@
10
  // ==========================================
11
 
12
  import { api, proxyImages } from "../core/网络请求API.js";
13
- import { IS_WEB_MODE } from "../core/全局配置.js";
 
14
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
15
- import { setupResourceInstall } from "./资源安装引擎.js";
16
  import { renderTipBoardHTML } from "../components/打赏等级工具.js";
17
  import { t } from "../components/用户体验增强.js";
18
- import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
19
- import { invalidateRelatedCache } from "../core/网络请求API.js";
 
20
  import { showToast } from "../components/UI交互提示组件.js";
21
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
22
  import { escapeHtml } from "../components/互动工具函数.js";
23
 
24
  // 🔄 P7后悔模式:渲染退款按钮
25
- async function renderRefundButton(container, itemData, currentUser) {
26
  if (!currentUser) return;
27
 
28
  const refundArea = container.querySelector("#refund-area");
29
  if (!refundArea) return;
 
30
 
31
  // 💸 检查商品是否支持退款
32
  if (!itemData.allow_refund && itemData.allow_refund !== undefined) {
@@ -34,15 +37,19 @@ async function renderRefundButton(container, itemData, currentUser) {
34
  return;
35
  }
36
 
 
37
  try {
38
- const statusRes = await api.getPurchaseStatus(currentUser.account, itemData.id);
 
 
39
  if (!statusRes.owned) {
40
  refundArea.innerHTML = '';
41
  return;
42
  }
43
 
44
- if (statusRes.can_refund) {
45
- const hoursLeft = statusRes.refund_hours_left;
 
46
  refundArea.innerHTML = `
47
  <div style="margin-top: 12px; padding: 12px; background: rgba(255,152,0,0.1); border: 1px dashed #FF9800; border-radius: 6px;">
48
  <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
@@ -59,7 +66,9 @@ async function renderRefundButton(container, itemData, currentUser) {
59
  </div>
60
  `;
61
 
62
- refundArea.querySelector("#btn-refund").onclick = () => showRefundConfirm(itemData, currentUser, statusRes.price_paid);
 
 
63
  } else {
64
  refundArea.innerHTML = `
65
  <div style="margin-top: 12px; padding: 10px; background: rgba(76,175,80,0.1); border: 1px solid rgba(76,175,80,0.3); border-radius: 6px; font-size: 12px; color: #4CAF50;">
@@ -68,169 +77,19 @@ async function renderRefundButton(container, itemData, currentUser) {
68
  `;
69
  }
70
  } catch (e) {
71
- console.warn("获取购买状态失败:", e);
 
72
  }
73
  }
74
 
75
- /**
76
- * 通用确认弹窗
77
- * @param {Object} config - 弹窗配置
78
- * @param {string} config.title - 弹窗标题
79
- * @param {string} config.titleColor - 标题颜色
80
- * @param {string} config.infoHtml - 商品信息区域HTML
81
- * @param {string} config.warningHtml - 警告区域HTML
82
- * @param {string} config.warningBgColor - 警告区域背景色
83
- * @param {string} config.warningBorderColor - 警告区域边框色
84
- * @param {string} config.confirmText - 确认按钮文本
85
- * @param {string} config.confirmColor - 确认按钮颜色
86
- * @param {string} config.confirmHoverColor - 确认按钮悬停颜色
87
- * @param {string} config.processingText - 处理中文本
88
- * @param {Function} config.onConfirm - 确认回调 (overlay: HTMLElement) => Promise<void>
89
- */
90
- function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warningBgColor, warningBorderColor, confirmText, confirmColor, confirmHoverColor, processingText, onConfirm }) {
91
- // 两个按钮的公共基础样式
92
- const _btnBaseStyle = "color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: 0.2s;";
93
-
94
- const overlay = document.createElement("div");
95
- Object.assign(overlay.style, {
96
- position: "fixed", top: "0", left: "0", right: "0", bottom: "0",
97
- background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center",
98
- zIndex: "10000"
99
- });
100
-
101
- overlay.innerHTML = `
102
- <div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
103
- <div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: ${titleColor}; display: flex; align-items: center; gap: 10px;">
104
- ⚠️ ${escapeHtml(title)}
105
- </div>
106
-
107
- <div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
108
- ${infoHtml}
109
- </div>
110
-
111
- <div style="background: ${warningBgColor}; border: 1px solid ${warningBorderColor}; border-radius: 8px; padding: 15px; margin-bottom: 20px;">
112
- <div style="font-size: 13px; color: #FF9800; line-height: 1.8;">
113
- ${warningHtml}
114
- </div>
115
- </div>
116
-
117
- <div style="display: flex; gap: 12px; justify-content: flex-end;">
118
- <button id="btn-cancel-confirm" style="background: #555; ${_btnBaseStyle}">
119
- ${t('common.cancel')}
120
- </button>
121
- <button id="btn-confirm-action" style="background: ${confirmColor}; font-weight: bold; ${_btnBaseStyle}">
122
- ${escapeHtml(confirmText)}
123
- </button>
124
- </div>
125
- </div>
126
- `;
127
-
128
- document.body.appendChild(overlay);
129
-
130
- const cancelBtn = overlay.querySelector("#btn-cancel-confirm");
131
- cancelBtn.onmouseover = () => { cancelBtn.style.background = "#666"; };
132
- cancelBtn.onmouseout = () => { cancelBtn.style.background = "#555"; };
133
- cancelBtn.onclick = () => overlay.remove();
134
-
135
- const confirmBtn = overlay.querySelector("#btn-confirm-action");
136
- confirmBtn.onmouseover = () => { confirmBtn.style.background = confirmHoverColor; };
137
- confirmBtn.onmouseout = () => { confirmBtn.style.background = confirmColor; };
138
- confirmBtn.onclick = async () => {
139
- confirmBtn.disabled = true;
140
- confirmBtn.innerText = processingText;
141
- await onConfirm(overlay);
142
- };
143
-
144
- overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
145
- }
146
-
147
- // 🔄 P7后悔模式:退款确认弹窗
148
- function showRefundConfirm(itemData, currentUser, pricePaid) {
149
- _showConfirmOverlay({
150
- title: t('item.confirm_refund_title'),
151
- titleColor: "#FF5722",
152
- infoHtml: `
153
- <div style="font-size: 14px; margin-bottom: 10px;">${t('item.product')}${escapeHtml(itemData.title || t('item.untitled'))}</div>
154
- <div style="font-size: 14px; color: #4CAF50;">${t('item.refund_amount')}<strong>${pricePaid}</strong> ${t('common.credits')}</div>
155
- `,
156
- warningHtml: `
157
- <div>🚨 <strong>${t('item.refund_consequence')}:</strong></div>
158
- <div style="margin-left: 20px; margin-top: 5px;">
159
- • ${t('item.refund_consequence_1')}<br>
160
- • ${t('item.refund_consequence_2')}<br>
161
- • <strong style="color: #F44336;">${t('item.refund_consequence_3')}</strong>
162
- </div>
163
- `,
164
- warningBgColor: "rgba(255,87,34,0.1)",
165
- warningBorderColor: "rgba(255,87,34,0.3)",
166
- confirmText: t('item.confirm_refund'),
167
- confirmColor: "#FF5722",
168
- confirmHoverColor: "#E64A19",
169
- processingText: t('common.processing'),
170
- onConfirm: async (overlay) => {
171
- try {
172
- const res = await api.requestRefund(currentUser.account, itemData.id);
173
- if (res.status === "success") {
174
- // 🔄 P7后悔模式:清除本地缓存和文件
175
- burnLocalFiles(itemData.id);
176
-
177
- overlay.innerHTML = `
178
- <div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 380px; width: 90%; color: #fff; text-align: center;">
179
- <div style="font-size: 48px; margin-bottom: 15px;">✅</div>
180
- <div style="font-size: 18px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">${t('item.refund_success')}</div>
181
- <div style="font-size: 14px; color: #aaa; margin-bottom: 20px;">
182
- ${res.refund_amount} ${t('item.credits_refunded')}<br>
183
- <span style="color: #FF9800;">${t('item.ban_days_notice', { days: res.ban_days })}</span>
184
- </div>
185
- <button id="btn-close-refund" style="background: #4CAF50; color: #fff; border: none; padding: 10px 30px; border-radius: 6px; cursor: pointer; font-size: 14px;">
186
- ${t('common.got_it')}
187
- </button>
188
- </div>
189
- `;
190
- overlay.querySelector("#btn-close-refund").onclick = () => {
191
- overlay.remove();
192
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
193
- };
194
- } else {
195
- showToast(t('item.refund_failed_retry'), "error");
196
- overlay.remove();
197
- }
198
- } catch (e) {
199
- showToast(t('item.refund_request_failed'), "error");
200
- overlay.remove();
201
- }
202
- }
203
  });
204
  }
205
 
206
- // 🔄 P7后悔模式:焚毁本地文件和缓存
207
- function burnLocalFiles(itemId) {
208
- // 清除本地版本标记
209
- localStorage.removeItem(`ComfyCommunity_LocalVer_${itemId}`);
210
-
211
- // 清除购买缓存
212
- removeCache(`ItemOwnership_${itemId}`);
213
-
214
- // 清除列表缓存(强制下次刷新)
215
- // 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op),
216
- // 购买/下载后返回列表仍会看到旧数据,改用前缀删除
217
- removeCacheByPrefix('api_/api/items');
218
- removeCacheByPrefix('api_/api/creators');
219
-
220
- // 清除工具/Skill/应用/推荐列表缓存(使用标准 ListCache 格式)
221
- const tabs = ['tools', 'skills', 'apps', 'recommends'];
222
- const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
223
- for (const tab of tabs) {
224
- for (const sort of sorts) {
225
- removeCache(`ListCache_${tab}_${sort}`);
226
- }
227
- }
228
-
229
- console.log(`🔥 已焚毁资源 ${itemId} 的本地数据`);
230
- }
231
-
232
-
233
-
234
  // ==========================================
235
  // 🗑️ 删除内容功能
236
  // ==========================================
@@ -243,61 +102,18 @@ function canDeleteItem(itemData, currentUser) {
243
  // 作者本人可以删除
244
  if (itemData.author === currentUser.account) return true;
245
  // 管理员可以删除
246
- if (currentUser.is_admin) return true;
247
  return false;
248
  }
249
 
250
  /**
251
  * 显示删除确认弹窗
252
  */
253
- function showDeleteConfirm(itemData, currentUser, onSuccess) {
254
- _showConfirmOverlay({
255
- title: t('item.confirm_delete_title'),
256
- titleColor: "#F44336",
257
- infoHtml: `
258
- <div style="font-size: 14px; margin-bottom: 10px;">${t('item.content_label')}${escapeHtml(itemData.title || t('item.untitled'))}</div>
259
- <div style="font-size: 13px; color: #888;">${t('item.type_label')}${escapeHtml(itemData.type || t('item.type_unknown'))}</div>
260
- `,
261
- warningHtml: `
262
- <div>🚨 <strong>${t('item.delete_consequence')}:</strong></div>
263
- <div style="margin-left: 20px; margin-top: 5px;">
264
- • ${t('item.delete_consequence_1')}<br>
265
- • ${t('item.delete_consequence_2')}<br>
266
- • ${t('item.delete_consequence_3')}
267
- </div>
268
- `,
269
- warningBgColor: "rgba(244,67,54,0.1)",
270
- warningBorderColor: "rgba(244,67,54,0.3)",
271
- confirmText: t('item.confirm_delete'),
272
- confirmColor: "#F44336",
273
- confirmHoverColor: "#D32F2F",
274
- processingText: t('item.deleting'),
275
- onConfirm: async (overlay) => {
276
- try {
277
- const res = await api.deleteItem(itemData.id);
278
- if (res.status === "success") {
279
- // 清除相关缓存
280
- invalidateRelatedCache(`/api/items/${itemData.id}`, "DELETE");
281
-
282
- overlay.remove();
283
- showToast(t('item.delete_success'), "success");
284
-
285
- // 返回列表页
286
- window.dispatchEvent(new CustomEvent("comfy-route-view", {
287
- detail: { view: "market", type: itemData.type || "tool" }
288
- }));
289
-
290
- if (onSuccess) onSuccess();
291
- } else {
292
- showToast(t('item.delete_failed_retry'), "error");
293
- overlay.remove();
294
- }
295
- } catch (e) {
296
- showToast(t('item.delete_request_failed'), "error");
297
- overlay.remove();
298
- }
299
- }
300
- });
301
  }
302
 
303
  export function createItemDetailView(itemData, currentUser) {
@@ -312,13 +128,11 @@ export function createItemDetailView(itemData, currentUser) {
312
  backgroundColor: "#13151c"
313
  });
314
 
315
- const authorCacheKey = `ComfyCommunity_ProfileCache_${itemData.author}`;
316
- const cachedAuthorStr = localStorage.getItem(authorCacheKey);
317
- let authorName = itemData.author;
318
- if (cachedAuthorStr) { try { authorName = JSON.parse(cachedAuthorStr).name || itemData.author; } catch(e) {} }
319
 
320
  // 🚀 使用统一工具渲染单品赞赏榜单(带星星/月亮/太阳等级)
321
- const boardData = itemData.tip_board || [];
322
  const boardHtml = renderTipBoardHTML(boardData, 10, t('item.no_tips_yet'), "normal");
323
 
324
  // 🗑️ 判断是否有权限删除
@@ -345,7 +159,7 @@ export function createItemDetailView(itemData, currentUser) {
345
  ${deleteBtnHtml}
346
  </div>
347
  </div>
348
- <div style="margin-top: 10px; color: #888;">感谢 砚影科技 的支持!</div>
349
 
350
  <!-- 🔄 P7后悔模式:退款区域 -->
351
  <div id="refund-area"></div>
@@ -357,7 +171,8 @@ export function createItemDetailView(itemData, currentUser) {
357
  `;
358
 
359
  // 🚀 核心逻辑:比对云端探测版本与本地安装版本
360
- const localVersionHash = localStorage.getItem(`ComfyCommunity_LocalVer_${itemData.id}`);
 
361
  const cloudVersionHash = itemData.latest_version;
362
  const isFree = !itemData.price || itemData.price <= 0 || (currentUser && currentUser.account === itemData.author);
363
 
@@ -369,13 +184,13 @@ export function createItemDetailView(itemData, currentUser) {
369
  let actionBtnHtml = '';
370
  if (IS_WEB_MODE) {
371
  // 📱 Web 模式:保留「立即获取」按钮但行为受限(未购买仅触发购买、已购买提示电脑端安装,见资源安装引擎);不渲染重装/更新按钮
372
- actionBtnHtml = `<button id="btn-use-item" style="flex: 1; padding: 12px; border-radius: 6px; border: none; background: #2196F3; color: #fff; font-weight: bold; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: transform 0.1s; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">⬇️ ${t('item.get_now')} <span style="font-size: 12px; font-weight: normal; background: rgba(0,0,0,0.2); padding: 2px 6px; border-radius: 4px;">${isFree ? t('item.free') : itemData.price + ' ' + t('common.credits')}</span></button>`;
373
- } else if (!isUpdateAvailable && localVersionHash) {
374
- actionBtnHtml = `<button id="btn-use-item" style="flex: 1; padding: 12px; border-radius: 6px; border: none; background: #4CAF50; color: #fff; font-weight: bold; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: transform 0.1s; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">✅ ${t('item.owned_reinstall')}</button>`;
375
  } else if (isUpdateAvailable) {
376
- actionBtnHtml = `<button id="btn-use-item" style="flex: 1; padding: 12px; border-radius: 6px; border: 1px solid #FF9800; background: rgba(255, 152, 0, 0.2); color: #FF9800; font-weight: bold; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" onmouseover="this.style.background='#FF9800'; this.style.color='#fff'" onmouseout="this.style.background='rgba(255, 152, 0, 0.2)'; this.style.color='#FF9800'">♻️ ${t('item.update_available')}</button>`;
377
  } else {
378
- actionBtnHtml = `<button id="btn-use-item" style="flex: 1; padding: 12px; border-radius: 6px; border: none; background: #2196F3; color: #fff; font-weight: bold; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: transform 0.1s; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">⬇️ ${t('item.get_now')} <span style="font-size: 12px; font-weight: normal; background: rgba(0,0,0,0.2); padding: 2px 6px; border-radius: 4px;">${isFree ? t('item.free') : itemData.price + ' ' + t('common.credits')}</span></button>`;
379
  }
380
 
381
  // 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移
@@ -401,27 +216,48 @@ export function createItemDetailView(itemData, currentUser) {
401
  </div>
402
  `;
403
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  // 🚀 创建安装状态容器
405
  const inlineStatusBox = document.createElement("div");
406
  inlineStatusBox.id = "inline-status-box";
407
  Object.assign(inlineStatusBox.style, { display: "none", marginTop: "15px", padding: "12px", background: "var(--comfy-menu-bg)", borderRadius: "6px", border: "1px solid var(--border-color, #333)", fontSize: "13px" });
408
  container.appendChild(inlineStatusBox);
 
 
 
 
 
409
 
410
- container.querySelector("#btn-back-detail").onclick = () => window.dispatchEvent(new CustomEvent("comfy-route-back"));
 
 
 
411
 
412
  // 🚀 绑定安装/更新按钮事件
413
  const btnUseItem = container.querySelector("#btn-use-item");
414
  if (btnUseItem) {
415
- setupResourceInstall(btnUseItem, itemData, currentUser, inlineStatusBox);
 
 
416
  }
417
 
418
  // 🚀 绑定作品打赏按钮事件
419
  container.querySelector("#btn-tip-item").onclick = async () => {
 
420
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
421
- await openTipModal(currentUser, { account: itemData.author }, (newBalance) => {
422
- currentUser.balance = newBalance;
423
- // 打赏成功后刷新局部或整体详情 (由框架重新渲染)
424
- }, itemData.id);
425
  };
426
 
427
  // 🗑️ 绑定删除按钮事件
@@ -429,42 +265,41 @@ export function createItemDetailView(itemData, currentUser) {
429
  if (btnDeleteItem) {
430
  btnDeleteItem.onclick = () => {
431
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
432
- showDeleteConfirm(itemData, currentUser);
433
  };
434
  }
435
 
436
  // 🐛 Bug修复:跳过系统页面的作者信息查询(如关于页面)
437
- if (!itemData.isSystemPage) {
438
- api.getUserProfile(itemData.author).then(res => {
439
- const freshName = res.data.name || itemData.author;
440
- const nameEl = container.querySelector("#detail-author-name");
441
- if (nameEl) nameEl.innerText = freshName;
442
- localStorage.setItem(authorCacheKey, JSON.stringify(res.data));
 
 
 
 
443
  }).catch(() => {});
444
  }
445
 
446
- // 🔄 P7后悔模式:异步加载退款按钮状态
447
- renderRefundButton(container, itemData, currentUser);
448
-
449
- // 🔄 云端购买状态兜底:本地无版本戳时,查询云端是否已购买
450
- if (!localVersionHash && !isFree && currentUser) {
451
- api.getPurchaseStatus(currentUser.account, itemData.id).then(res => {
452
- const btnUseItem = container.querySelector("#btn-use-item");
453
- if (!btnUseItem) return;
454
- if (res.owned) {
455
- // 📱 Web 模式无本地重装概念,文案改为「已购买」
456
- btnUseItem.innerHTML = `✅ ${t(IS_WEB_MODE ? 'item.owned_purchased' : 'item.owned_reinstall')}`;
457
- Object.assign(btnUseItem.style, {
458
- background: "#4CAF50",
459
- color: "#fff",
460
- border: "none"
461
- });
462
- }
463
- // 未购买时保持默认的「立即获取使用」按钮
464
- }).catch(err => {
465
- console.warn("云端购买状态查询失败,fallback到本地逻辑:", err);
466
  });
467
  }
468
 
469
  return container;
470
- }
 
10
  // ==========================================
11
 
12
  import { api, proxyImages } from "../core/网络请求API.js";
13
+ import { IS_WEB_MODE, getCachedProfile, setCachedProfile } from "../core/全局配置.js";
14
+ import { normalizeProfileData } from "../profile/个人资料工具.js";
15
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
16
+ import { getInstalledVersionStatus, setupResourceInstall } from "./资源安装引擎.js";
17
  import { renderTipBoardHTML } from "../components/打赏等级工具.js";
18
  import { t } from "../components/用户体验增强.js";
19
+ import { createPurchaseStatusScope, isValidPurchaseStatus } from "./购买状态生命周期.js";
20
+ import { showResourceDeleteDialog } from "./资源删除组件.js";
21
+ import { showResourceRefundDialog } from "./资源退款组件.js";
22
  import { showToast } from "../components/UI交互提示组件.js";
23
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
24
  import { escapeHtml } from "../components/互动工具函数.js";
25
 
26
  // 🔄 P7后悔模式:渲染退款按钮
27
+ async function renderRefundButton(container, itemData, currentUser, scope, purchaseStatus) {
28
  if (!currentUser) return;
29
 
30
  const refundArea = container.querySelector("#refund-area");
31
  if (!refundArea) return;
32
+ if (!scope.isCurrent()) return;
33
 
34
  // 💸 检查商品是否支持退款
35
  if (!itemData.allow_refund && itemData.allow_refund !== undefined) {
 
37
  return;
38
  }
39
 
40
+ refundArea.style.display = "block";
41
  try {
42
+ const statusRes = await purchaseStatus;
43
+ if (!scope.isCurrent()) return;
44
+ if (!isValidPurchaseStatus(statusRes)) throw new Error();
45
  if (!statusRes.owned) {
46
  refundArea.innerHTML = '';
47
  return;
48
  }
49
 
50
+ if (statusRes.can_refund && statusRes.allow_refund) {
51
+ const hoursLeft = Number(statusRes.refund_hours_left);
52
+ if (!Number.isFinite(hoursLeft) || hoursLeft < 0) throw new Error("Invalid refund hours");
53
  refundArea.innerHTML = `
54
  <div style="margin-top: 12px; padding: 12px; background: rgba(255,152,0,0.1); border: 1px dashed #FF9800; border-radius: 6px;">
55
  <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
 
66
  </div>
67
  `;
68
 
69
+ refundArea.querySelector("#btn-refund").onclick = () => {
70
+ if (scope.isCurrent()) showRefundConfirm(itemData, currentUser, statusRes.price_paid, () => scope.isCurrent());
71
+ };
72
  } else {
73
  refundArea.innerHTML = `
74
  <div style="margin-top: 12px; padding: 10px; background: rgba(76,175,80,0.1); border: 1px solid rgba(76,175,80,0.3); border-radius: 6px; font-size: 12px; color: #4CAF50;">
 
77
  `;
78
  }
79
  } catch (e) {
80
+ if (!scope.isCurrent()) return;
81
+ refundArea.replaceChildren();
82
  }
83
  }
84
 
85
+ // 卡片与详情页共用退款流程,成功关闭后返回列表。
86
+ function showRefundConfirm(itemData, currentUser, pricePaid, isCurrent) {
87
+ return showResourceRefundDialog({
88
+ itemData, currentUser, pricePaid, isCurrent,
89
+ onClosed: () => window.dispatchEvent(new CustomEvent("comfy-route-back"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  });
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  // ==========================================
94
  // 🗑️ 删除内容功能
95
  // ==========================================
 
102
  // 作者本人可以删除
103
  if (itemData.author === currentUser.account) return true;
104
  // 管理员可以删除
105
+ if (currentUser.is_admin === true) return true;
106
  return false;
107
  }
108
 
109
  /**
110
  * 显示删除确认弹窗
111
  */
112
+ function showDeleteConfirm(itemData, currentUser, isCurrent) {
113
+ return showResourceDeleteDialog({ itemData, currentUser, isCurrent,
114
+ onDeleted: () => window.dispatchEvent(new CustomEvent('comfy-route-view', {
115
+ detail: { view: 'market', type: itemData.type || 'tool' }
116
+ })) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  }
118
 
119
  export function createItemDetailView(itemData, currentUser) {
 
128
  backgroundColor: "#13151c"
129
  });
130
 
131
+ const cachedAuthor = getCachedProfile(itemData.author);
132
+ const authorName = cachedAuthor?.name || itemData.author || t('common.unknown_user');
 
 
133
 
134
  // 🚀 使用统一工具渲染单品赞赏榜单(带星星/月亮/太阳等级)
135
+ const boardData = Array.isArray(itemData.tip_board) ? itemData.tip_board.filter(entry => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
136
  const boardHtml = renderTipBoardHTML(boardData, 10, t('item.no_tips_yet'), "normal");
137
 
138
  // 🗑️ 判断是否有权限删除
 
159
  ${deleteBtnHtml}
160
  </div>
161
  </div>
162
+ <div style="margin-top: 10px; color: #888;">${escapeHtml(t('item.sponsor_thanks'))}</div>
163
 
164
  <!-- 🔄 P7后悔模式:退款区域 -->
165
  <div id="refund-area"></div>
 
171
  `;
172
 
173
  // 🚀 核心逻辑:比对云端探测版本与本地安装版本
174
+ const localVersionStatus = getInstalledVersionStatus(itemData.id);
175
+ const localVersionHash = localVersionStatus.version;
176
  const cloudVersionHash = itemData.latest_version;
177
  const isFree = !itemData.price || itemData.price <= 0 || (currentUser && currentUser.account === itemData.author);
178
 
 
184
  let actionBtnHtml = '';
185
  if (IS_WEB_MODE) {
186
  // 📱 Web 模式:保留「立即获取」按钮但行为受限(未购买仅触发购买、已购买提示电脑端安装,见资源安装引擎);不渲染重装/更新按钮
187
+ actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="ready">⬇️ ${t('item.get_now')} <span class="resource-install__price">${isFree ? t('item.free') : escapeHtml(itemData.price) + ' ' + t('common.credits')}</span></button>`;
188
+ } else if (!isUpdateAvailable && localVersionStatus.installed) {
189
+ actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="success">✅ ${t('item.owned_reinstall')}</button>`;
190
  } else if (isUpdateAvailable) {
191
+ actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="update">♻️ ${t('item.update_available')}</button>`;
192
  } else {
193
+ actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="ready">⬇️ ${t('item.get_now')} <span class="resource-install__price">${isFree ? t('item.free') : escapeHtml(itemData.price) + ' ' + t('common.credits')}</span></button>`;
194
  }
195
 
196
  // 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移
 
216
  </div>
217
  `;
218
 
219
+ // 按既有状态生成的安装入口必须实际挂载到详情页面。
220
+ container.insertAdjacentHTML("beforeend", actionBtnHtml);
221
+ const scope = createPurchaseStatusScope(container.querySelector("#refund-area"), currentUser?.account);
222
+ container._cleanup = () => scope.dispose();
223
+ scope.addCleanup(() => {
224
+ for (const id of ["btn-use-item", "btn-tip-item", "btn-delete-item"]) {
225
+ const button = container.querySelector(`#${id}`);
226
+ if (button) { button.onclick = null; button.remove(); }
227
+ }
228
+ container.querySelector("#inline-status-box")?.replaceChildren();
229
+ });
230
+
231
  // 🚀 创建安装状态容器
232
  const inlineStatusBox = document.createElement("div");
233
  inlineStatusBox.id = "inline-status-box";
234
  Object.assign(inlineStatusBox.style, { display: "none", marginTop: "15px", padding: "12px", background: "var(--comfy-menu-bg)", borderRadius: "6px", border: "1px solid var(--border-color, #333)", fontSize: "13px" });
235
  container.appendChild(inlineStatusBox);
236
+ if (!localVersionStatus.available) {
237
+ inlineStatusBox.style.display = "block";
238
+ inlineStatusBox.setAttribute("role", "status");
239
+ inlineStatusBox.textContent = t('install.version_status_unavailable');
240
+ }
241
 
242
+ container.querySelector("#btn-back-detail").onclick = () => {
243
+ scope.dispose();
244
+ window.dispatchEvent(new CustomEvent("comfy-route-back"));
245
+ };
246
 
247
  // 🚀 绑定安装/更新按钮事件
248
  const btnUseItem = container.querySelector("#btn-use-item");
249
  if (btnUseItem) {
250
+ const installation = setupResourceInstall(btnUseItem, itemData, currentUser, inlineStatusBox,
251
+ { isCurrent: () => scope.isCurrent() });
252
+ scope.addCleanup(() => installation?.dispose());
253
  }
254
 
255
  // 🚀 绑定作品打赏按钮事件
256
  container.querySelector("#btn-tip-item").onclick = async () => {
257
+ if (!scope.isCurrent()) return;
258
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
259
+ await openTipModal(currentUser, { account: itemData.author }, undefined, itemData.id,
260
+ { isCurrent: () => scope.isCurrent() });
 
 
261
  };
262
 
263
  // 🗑️ 绑定删除按钮事件
 
265
  if (btnDeleteItem) {
266
  btnDeleteItem.onclick = () => {
267
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
268
+ if (scope.isCurrent()) showDeleteConfirm(itemData, currentUser, () => scope.isCurrent());
269
  };
270
  }
271
 
272
  // 🐛 Bug修复:跳过系统页面的作者信息查询(如关于页面)
273
+ if (!itemData.isSystemPage && itemData.author && scope.isCurrent()) {
274
+ const originalNameEl = container.querySelector("#detail-author-name");
275
+ api.getUserProfile(itemData.author, scope.readOptions).then(res => {
276
+ if (!scope.isCurrent() || !originalNameEl?.isConnected || container.querySelector("#detail-author-name") !== originalNameEl) return;
277
+ if (res?.status !== 'success') return;
278
+ const profile = normalizeProfileData(res.data, itemData.author);
279
+ setCachedProfile(itemData.author, profile);
280
+ if (originalNameEl?.isConnected && container.querySelector("#detail-author-name") === originalNameEl) {
281
+ originalNameEl.textContent = profile.name || itemData.author;
282
+ }
283
  }).catch(() => {});
284
  }
285
 
286
+ // 拥有文案和退款入口使用同一次、同账号的购买快照。
287
+ const needsOwnership = !localVersionHash && !isFree;
288
+ const needsRefund = itemData.allow_refund !== false;
289
+ if (currentUser && scope.isCurrent() && (needsOwnership || needsRefund)) {
290
+ const purchaseStatus = api.getPurchaseStatus(currentUser.account, itemData.id, scope.readOptions)
291
+ .then(response => {
292
+ if (!scope.isCurrent() || !isValidPurchaseStatus(response)) return null;
293
+ return response;
294
+ }).catch(() => null);
295
+ if (needsRefund) renderRefundButton(container, itemData, currentUser, scope, purchaseStatus);
296
+ if (needsOwnership) purchaseStatus.then(response => {
297
+ if (!scope.isCurrent() || !response?.owned || !btnUseItem?.isConnected
298
+ || container.querySelector("#btn-use-item") !== btnUseItem) return;
299
+ btnUseItem.textContent = `✅ ${t(IS_WEB_MODE ? 'item.owned_purchased' : 'item.owned_reinstall')}`;
300
+ btnUseItem.setAttribute("data-install-state", "success");
 
 
 
 
 
301
  });
302
  }
303
 
304
  return container;
305
+ }