ZHIWEI666 commited on
Commit
6c3e64f
·
verified ·
1 Parent(s): ba85053

Upload 98 files

Browse files
前端页面/components/UI交互提示组件.js CHANGED
@@ -15,7 +15,7 @@
15
  // ===========================================
16
 
17
  import { t } from "./用户体验增强.js";
18
- import { escapeHtml } from "../core/全局配置.js";
19
 
20
  // ==========================================
21
  // 📢 Toast 消息提示
@@ -24,16 +24,58 @@ import { escapeHtml } from "../core/全局配置.js";
24
 
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 消息提示
39
  * @param {string} message - 提示内容
@@ -41,16 +83,17 @@ function repositionToasts() {
41
  * @param {number} duration - 显示时长(毫秒),默认3000
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 标签,故无条件转义不会改变现有显示效果。
@@ -61,34 +104,74 @@ export function showToast(message, type = "info", duration = 3000) {
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
- // 自动关闭
77
- const autoCloseTimer = setTimeout(() => closeToast(toast), duration);
78
- toast._autoCloseTimer = autoCloseTimer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
@@ -716,6 +799,8 @@ export function createInstallProgress(container) {
716
  const progressTrack = wrapper.querySelector(".install-progress__track");
717
  const startedAt = Date.now();
718
  let cancelButton = null;
 
 
719
  const updateElapsed = () => {
720
  const totalSeconds = Math.floor((Date.now() - startedAt) / 1000);
721
  const time = `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`;
@@ -755,28 +840,37 @@ export function createInstallProgress(container) {
755
  stage.textContent = message || `❌ ${t('install.failed')}`;
756
  progressTrack.setAttribute("aria-valuetext", stage.textContent);
757
  },
758
- showDiagnostics(entries) {
759
- if (!Array.isArray(entries) || entries.length === 0) return;
760
- const details = document.createElement("details");
761
- details.className = "install-progress__diagnostics";
762
- const summary = document.createElement("summary");
763
- summary.textContent = t('install.diagnostics');
764
- details.appendChild(summary);
765
-
766
- const list = document.createElement("ol");
767
- for (const entry of entries.slice(-50)) {
768
- const item = document.createElement("li");
769
- const stageKey = entry.stageKey;
770
- const stageLabel = stageKey ? t(stageKey) : (entry.stage || t('install.stage_unknown'));
771
- item.textContent = t('install.diagnostic_entry', {
772
- time: entry.time,
773
- stage: stageLabel,
774
- progress: Number.isFinite(entry.progress) ? `${entry.progress}%` : "—"
775
- });
776
- list.appendChild(item);
 
 
 
 
 
 
777
  }
778
- details.appendChild(list);
779
- wrapper.appendChild(details);
 
 
 
780
  },
781
  addCancelButton(label, onCancel) {
782
  if (cancelButton || typeof onCancel !== "function") return;
@@ -793,6 +887,8 @@ export function createInstallProgress(container) {
793
  wrapper.appendChild(cancelButton);
794
  },
795
  destroy() {
 
 
796
  stopElapsedTimer();
797
  wrapper.remove();
798
  }
 
15
  // ===========================================
16
 
17
  import { t } from "./用户体验增强.js";
18
+ import { escapeHtml, UI } from "../core/全局配置.js";
19
 
20
  // ==========================================
21
  // 📢 Toast 消息提示
 
24
 
25
  // Toast 队列管理(避免重叠)
26
  let toastQueue = [];
27
+ let toastObserver = null;
28
+ let toastSizeObserver = null;
29
  let dialogInstanceId = 0;
30
  const dialogStack = [];
31
 
32
  function repositionToasts() {
33
+ const heights = toastQueue.map(toast => toast.offsetHeight);
34
+ let top = UI.TOAST.OFFSET;
35
  toastQueue.forEach((toast, index) => {
36
+ toast.style.top = `${top}px`;
37
+ top += heights[index] + UI.TOAST.GAP;
38
  });
39
  }
40
 
41
+ function observeToastSize(toast) {
42
+ if (typeof ResizeObserver !== "function") return;
43
+ if (!toastSizeObserver) toastSizeObserver = new ResizeObserver(repositionToasts);
44
+ toastSizeObserver.observe(toast);
45
+ }
46
+
47
+
48
+ function releaseToast(toast) {
49
+ toast._toastReleased = true;
50
+ toastSizeObserver?.unobserve(toast);
51
+ if (toast._autoCloseTimer != null) clearTimeout(toast._autoCloseTimer);
52
+ if (toast._closeTimer != null) clearTimeout(toast._closeTimer);
53
+ if (toast._entryFrame != null) cancelAnimationFrame(toast._entryFrame);
54
+ toast._autoCloseTimer = null;
55
+ toast._closeTimer = null;
56
+ toast._entryFrame = null;
57
+ toast._previousFocus = null;
58
+ toast._cleanupToastEvents?.();
59
+ toast._cleanupToastEvents = null;
60
+ const index = toastQueue.indexOf(toast);
61
+ if (index > -1) toastQueue.splice(index, 1);
62
+ repositionToasts();
63
+ if (!toastQueue.length) {
64
+ toastObserver?.disconnect();
65
+ toastObserver = null;
66
+ toastSizeObserver?.disconnect();
67
+ toastSizeObserver = null;
68
+ }
69
+ }
70
+
71
+ function observeToastRemoval() {
72
+ if (toastObserver) return;
73
+ toastObserver = new MutationObserver(() => {
74
+ for (const toast of [...toastQueue]) if (!toast.isConnected) releaseToast(toast);
75
+ });
76
+ toastObserver.observe(document.body, { childList: true });
77
+ }
78
+
79
  /**
80
  * 显示 Toast 消息提示
81
  * @param {string} message - 提示内容
 
83
  * @param {number} duration - 显示时长(毫秒),默认3000
84
  */
85
  export function showToast(message, type = "info", duration = 3000) {
86
+ const previousFocus = document.activeElement;
87
  const toast = document.createElement("div");
88
  const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info";
89
  toast.className = `comfy-ranking-toast comfy-ranking-toast--${safeType}`;
90
+ toast.setAttribute("role", safeType === "error" ? "alert" : "status");
91
  toast.setAttribute("aria-live", safeType === "error" ? "assertive" : "polite");
92
  toast.setAttribute("aria-atomic", "true");
93
  const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
94
  if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion");
95
  // 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。
96
+ toast.style.top = `${UI.TOAST.OFFSET - 30}px`;
97
 
98
  // 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
99
  // 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
 
104
  <button type="button" class="toast-close comfy-ranking-toast__close" aria-label="${escapeHtml(t('common.close'))}">×</button>
105
  `;
106
 
107
+ toast._previousFocus = previousFocus;
108
  document.body.appendChild(toast);
109
  toastQueue.push(toast);
110
+ observeToastRemoval();
111
+ observeToastSize(toast);
112
 
113
  // 点击关闭
114
+ const closeButton = toast.querySelector(".toast-close");
115
+ const handleClose = () => closeToast(toast);
116
+ closeButton.addEventListener("click", handleClose);
117
 
118
  // 入场动画
119
+ toast._entryFrame = requestAnimationFrame(() => {
120
+ toast._entryFrame = null;
121
+ if (toast._toastReleased || !toast.isConnected || toast.classList.contains("comfy-ranking-toast--closing")) return;
122
  toast.style.opacity = "1";
123
  repositionToasts();
124
  });
125
 
126
+ let remaining = Number(duration);
127
+ let deadline = 0;
128
+ let hovered = false;
129
+ const pauseTimer = () => {
130
+ if (toast._autoCloseTimer == null) return;
131
+ clearTimeout(toast._autoCloseTimer);
132
+ toast._autoCloseTimer = null;
133
+ remaining = Math.max(0, deadline - performance.now());
134
+ };
135
+ const resumeTimer = () => {
136
+ if (toast._toastReleased || hovered || toast.contains(document.activeElement) || !toast.isConnected ||
137
+ toast.classList.contains("comfy-ranking-toast--closing") || toast._autoCloseTimer != null) return;
138
+ deadline = performance.now() + remaining;
139
+ toast._autoCloseTimer = setTimeout(() => closeToast(toast), remaining);
140
+ };
141
+ const handleEnter = () => { hovered = true; pauseTimer(); };
142
+ const handleLeave = () => { hovered = false; resumeTimer(); };
143
+ const handleFocusOut = event => {
144
+ if (!toast.contains(event.relatedTarget)) queueMicrotask(resumeTimer);
145
+ };
146
+ toast.addEventListener("mouseenter", handleEnter);
147
+ toast.addEventListener("mouseleave", handleLeave);
148
+ toast.addEventListener("focusin", pauseTimer);
149
+ toast.addEventListener("focusout", handleFocusOut);
150
+ toast._cleanupToastEvents = () => {
151
+ closeButton.removeEventListener("click", handleClose);
152
+ toast.removeEventListener("mouseenter", handleEnter);
153
+ toast.removeEventListener("mouseleave", handleLeave);
154
+ toast.removeEventListener("focusin", pauseTimer);
155
+ toast.removeEventListener("focusout", handleFocusOut);
156
+ };
157
+ resumeTimer();
158
  }
159
 
160
  function closeToast(toast) {
161
+ if (!toast || toast._toastReleased) return;
162
+ if (!toast.isConnected) { releaseToast(toast); return; }
163
+ if (toast.classList.contains("comfy-ranking-toast--closing")) return;
164
+ if (toast._autoCloseTimer != null) clearTimeout(toast._autoCloseTimer);
165
+ toast._autoCloseTimer = null;
166
 
167
  toast.classList.add("comfy-ranking-toast--closing");
168
 
169
+ toast._closeTimer = setTimeout(() => {
170
+ const restore = toast.contains(document.activeElement);
171
+ const previousFocus = toast._previousFocus;
172
  toast.remove();
173
+ releaseToast(toast);
174
+ if (restore && previousFocus?.isConnected) previousFocus.focus();
 
175
  }, window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 300);
176
  }
177
 
 
799
  const progressTrack = wrapper.querySelector(".install-progress__track");
800
  const startedAt = Date.now();
801
  let cancelButton = null;
802
+ let destroyed = false;
803
+ let diagnosticGeneration = 0;
804
  const updateElapsed = () => {
805
  const totalSeconds = Math.floor((Date.now() - startedAt) / 1000);
806
  const time = `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`;
 
840
  stage.textContent = message || `❌ ${t('install.failed')}`;
841
  progressTrack.setAttribute("aria-valuetext", stage.textContent);
842
  },
843
+ showDiagnostics(entries, error = null) {
844
+ if (destroyed) return;
845
+ const generation = ++diagnosticGeneration;
846
+ wrapper.querySelectorAll('.install-progress__diagnostics, .comfy-ranking-request-diagnostic').forEach(node => node.remove());
847
+ if (Array.isArray(entries) && entries.length) {
848
+ const details = document.createElement("details");
849
+ details.className = "install-progress__diagnostics";
850
+ const summary = document.createElement("summary");
851
+ summary.textContent = t('install.diagnostics');
852
+ details.appendChild(summary);
853
+
854
+ const list = document.createElement("ol");
855
+ for (const entry of entries.slice(-50)) {
856
+ const item = document.createElement("li");
857
+ const stageKey = entry.stageKey;
858
+ const stageLabel = stageKey ? t(stageKey) : (entry.stage || t('install.stage_unknown'));
859
+ item.textContent = t('install.diagnostic_entry', {
860
+ time: entry.time,
861
+ stage: stageLabel,
862
+ progress: Number.isFinite(entry.progress) ? `${entry.progress}%` : "—"
863
+ });
864
+ list.appendChild(item);
865
+ }
866
+ details.appendChild(list);
867
+ wrapper.appendChild(details);
868
  }
869
+ if (typeof error?.requestId !== 'string') return;
870
+ import('./性能优化工具.js').then(({ appendRequestDiagnostic }) => {
871
+ const isCurrent = () => !destroyed && generation === diagnosticGeneration && wrapper.isConnected;
872
+ if (isCurrent()) appendRequestDiagnostic(wrapper, error, t, isCurrent);
873
+ }).catch(() => { console.warn('请求诊断界面加载失败'); });
874
  },
875
  addCancelButton(label, onCancel) {
876
  if (cancelButton || typeof onCancel !== "function") return;
 
887
  wrapper.appendChild(cancelButton);
888
  },
889
  destroy() {
890
+ destroyed = true;
891
+ diagnosticGeneration++;
892
  stopElapsedTimer();
893
  wrapper.remove();
894
  }
前端页面/components/全局设置组件.js CHANGED
@@ -11,54 +11,16 @@
11
  import { showToast } from "./UI交互提示组件.js";
12
  import { setLanguage, getLanguage, t } from "./用户体验增强.js";
13
  import { clearAllCache } from "./性能优化工具.js";
14
- import { IS_WEB_MODE } from "../core/全局配置.js";
 
15
  import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
16
 
17
- // 🔧 设置项 localStorage Key
18
- const SETTINGS_KEY = "ComfyCommunity_Settings";
19
-
20
- // 🔧 默认设置值
21
- const DEFAULT_SETTINGS = {
22
- showCreatorBanner: true, // 创作者卡片显示背景图
23
- enableAnimations: true, // ✨ 榜单切换动画
24
- enableSoundEffects: true, // 🔊 动画音效
25
- language: 'zh-CN', // 🌐 界面语言
26
- cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时
27
- // 🚀 后续可扩展更多设置项
28
- // compactMode: false, // 紧凑模式
29
- // darkMode: true, // 深色模式
30
- };
31
-
32
  // 🌐 语言选项(双语显示)
33
  const LANGUAGE_OPTIONS = [
34
  { value: 'zh-CN', label: '简体中文 / Simplified Chinese' },
35
  { value: 'en-US', label: 'English / 英语' }
36
  ];
37
 
38
- /**
39
- * 📦 获取当前设置
40
- */
41
- export function getSettings() {
42
- try {
43
- const saved = localStorage.getItem(SETTINGS_KEY);
44
- if (saved) {
45
- return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
46
- }
47
- } catch (e) {}
48
- return { ...DEFAULT_SETTINGS };
49
- }
50
-
51
- /**
52
- * 💾 保存设置
53
- */
54
- export function saveSettings(settings) {
55
- try {
56
- localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
57
- // 🔔 广播设置变更事件,让其他组件响应
58
- window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
59
- } catch (e) {}
60
- }
61
-
62
  /**
63
  * 📊 格式化文件大小
64
  */
 
11
  import { showToast } from "./UI交互提示组件.js";
12
  import { setLanguage, getLanguage, t } from "./用户体验增强.js";
13
  import { clearAllCache } from "./性能优化工具.js";
14
+ import { IS_WEB_MODE, getSettings, saveSettings } from "../core/全局配置.js";
15
+ export { getSettings, saveSettings };
16
  import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  // 🌐 语言选项(双语显示)
19
  const LANGUAGE_OPTIONS = [
20
  { value: 'zh-CN', label: '简体中文 / Simplified Chinese' },
21
  { value: 'en-US', label: 'English / 英语' }
22
  ];
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  /**
25
  * 📊 格式化文件大小
26
  */
前端页面/components/动画音效引擎.js CHANGED
@@ -11,7 +11,7 @@
11
  // - tetris: 提示词 - 俄罗斯方块式逐个下坠堆积
12
  // ==========================================
13
 
14
- import { getSettings } from "./全局设置组件.js";
15
 
16
  function prefersReducedMotion() {
17
  try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; }
 
11
  // - tetris: 提示词 - 俄罗斯方块式逐个下坠堆积
12
  // ==========================================
13
 
14
+ import { getSettings } from "../core/全局配置.js";
15
 
16
  function prefersReducedMotion() {
17
  try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; }
前端页面/components/性能优化工具.js CHANGED
@@ -1411,6 +1411,12 @@ export function renderListLoadError(container, message, onRetry, t, error = null
1411
 
1412
  status.append(text, retry);
1413
  container.replaceChildren(status);
 
 
 
 
 
 
1414
  const requestId = error?.requestId;
1415
  if (typeof requestId !== 'string' || !API.REQUEST_ID_PATTERN.test(requestId)) return;
1416
 
@@ -1433,24 +1439,25 @@ export function renderListLoadError(container, message, onRetry, t, error = null
1433
  feedback.setAttribute('aria-live', 'polite');
1434
  let copying = false;
1435
  copy.addEventListener('click', async () => {
1436
- if (copying || !details.isConnected) return;
1437
  copying = true; copy.setAttribute('aria-busy', 'true');
1438
  try {
1439
  if (typeof navigator.clipboard?.writeText !== 'function') throw new Error();
1440
  await navigator.clipboard.writeText(requestId);
1441
- if (details.isConnected) feedback.textContent = t('feedback.copied');
1442
  } catch {
1443
- if (details.isConnected) {
1444
  feedback.textContent = t('feedback.copy_id_manually');
1445
  if (document.activeElement === copy) { input.focus(); input.select(); }
1446
  }
1447
  } finally {
1448
  copying = false;
1449
- if (details.isConnected) copy.setAttribute('aria-busy', 'false');
1450
  }
1451
  });
1452
  details.append(summary, label, copy, feedback);
1453
  container.append(details);
 
1454
  }
1455
 
1456
  /**
 
1411
 
1412
  status.append(text, retry);
1413
  container.replaceChildren(status);
1414
+ appendRequestDiagnostic(container, error, t);
1415
+ }
1416
+
1417
+
1418
+ export function appendRequestDiagnostic(container, error, t, isCurrent = () => true) {
1419
+ if (!container || !isCurrent()) return;
1420
  const requestId = error?.requestId;
1421
  if (typeof requestId !== 'string' || !API.REQUEST_ID_PATTERN.test(requestId)) return;
1422
 
 
1439
  feedback.setAttribute('aria-live', 'polite');
1440
  let copying = false;
1441
  copy.addEventListener('click', async () => {
1442
+ if (copying || !details.isConnected || !isCurrent()) return;
1443
  copying = true; copy.setAttribute('aria-busy', 'true');
1444
  try {
1445
  if (typeof navigator.clipboard?.writeText !== 'function') throw new Error();
1446
  await navigator.clipboard.writeText(requestId);
1447
+ if (details.isConnected && isCurrent()) feedback.textContent = t('feedback.copied');
1448
  } catch {
1449
+ if (details.isConnected && isCurrent()) {
1450
  feedback.textContent = t('feedback.copy_id_manually');
1451
  if (document.activeElement === copy) { input.focus(); input.select(); }
1452
  }
1453
  } finally {
1454
  copying = false;
1455
+ if (details.isConnected && isCurrent()) copy.setAttribute('aria-busy', 'false');
1456
  }
1457
  });
1458
  details.append(summary, label, copy, feedback);
1459
  container.append(details);
1460
+ return details;
1461
  }
1462
 
1463
  /**
前端页面/components/用户体验_国际化.js CHANGED
@@ -808,6 +808,11 @@ const translations = {
808
  'feedback.search_local_result': '⚠️ 网络不可用,显示本地搜索结果',
809
  // 🔍 搜索请求被新关键词取代时抛出的错误消息(详见 网络请求_基础设施.js 的主动取消分支)
810
  'feedback.request_cancelled': '请求已取消',
 
 
 
 
 
811
 
812
  // 通用
813
  'common.processing': '处理中',
@@ -1458,7 +1463,8 @@ const translations = {
1458
  'publish.password_encrypted_hint': '密码加密存储于云端,仅在用户成功购买后所解密显示。未购买用户无法通过任何接口获取。',
1459
  'publish.private_repo_hint': '这是一个私有 GitHub 仓库(需配置访问密钥)',
1460
  'publish.pat_label': '私有库专属访问密钥 (PAT)',
1461
- 'publish.pat_hint': '系统使用此 Token 替购买者拉取源码。该密钥仅存储于云端后台,<span style="color:#F44336; font-weight:bold;">绝对不会在前端接口中公开暴露</span>。',
 
1462
  'publish.token_placeholder': 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
1463
  'publish.token_keep_hint': '已配置,留空保留原密钥',
1464
  'publish.effect_images': '效果展示图 (最多6张,可选)',
@@ -1656,6 +1662,10 @@ const translations = {
1656
  'settings_form.moderation_skipped': '上传图片将跳过审核',
1657
  'settings_form.moderation_enabled': '图像审核已启用',
1658
  'settings_form.moderation_disabled': '图像审核已关闭',
 
 
 
 
1659
  // 视频播放器
1660
  'video.play': '播放',
1661
  'video.pause': '暂停',
@@ -2520,6 +2530,11 @@ const translations = {
2520
  'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
2521
  'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
2522
  'feedback.request_cancelled': 'Request cancelled',
 
 
 
 
 
2523
  'common.processing': 'Processing',
2524
  'common.got_it': 'Got it',
2525
  'confirm.delete_title': 'Confirm Delete',
@@ -3147,7 +3162,8 @@ const translations = {
3147
  'publish.password_encrypted_hint': 'Password encrypted in cloud, shown only after successful purchase.',
3148
  'publish.private_repo_hint': 'This is a private GitHub repo (requires access key)',
3149
  'publish.pat_label': 'Private Repo PAT',
3150
- 'publish.pat_hint': 'System uses this token to pull source for buyers. Only stored on backend, <span style="color:#F44336; font-weight:bold;">never exposed in frontend APIs</span>.',
 
3151
  'publish.token_placeholder': 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
3152
  'publish.token_keep_hint': 'Already configured, leave empty to keep',
3153
  'publish.effect_images': 'Preview Images (max 6, optional)',
@@ -3342,6 +3358,10 @@ const translations = {
3342
  'settings_form.moderation_skipped': 'Uploaded images will skip moderation',
3343
  'settings_form.moderation_enabled': 'Image moderation enabled',
3344
  'settings_form.moderation_disabled': 'Image moderation disabled',
 
 
 
 
3345
 
3346
  // Video player
3347
  'video.play': 'Play',
 
808
  'feedback.search_local_result': '⚠️ 网络不可用,显示本地搜索结果',
809
  // 🔍 搜索请求被新关键词取代时抛出的错误消息(详见 网络请求_基础设施.js 的主动取消分支)
810
  'feedback.request_cancelled': '请求已取消',
811
+ 'feedback.stream_idle_timeout': '连接长时间未收到数据,操作结果暂时无法确认。',
812
+ 'feedback.stream_missing_final': '连接已结束,但未收到最终结果,请先核对操作状态。',
813
+ 'feedback.stream_invalid_encoding': '流数据编码异常,操作结果暂时无法确认。',
814
+ 'feedback.stream_limit_exceeded': '流数据超出安全读取范围,操作结果暂时无法确认。',
815
+ 'feedback.stream_timeout_unconfirmed': '连接超时,操作结果暂时无法确认。',
816
 
817
  // 通用
818
  'common.processing': '处理中',
 
1463
  'publish.password_encrypted_hint': '密码加密存储于云端,仅在用户成功购买后所解密显示。未购买用户无法通过任何接口获取。',
1464
  'publish.private_repo_hint': '这是一个私有 GitHub 仓库(需配置访问密钥)',
1465
  'publish.pat_label': '私有库专属访问密钥 (PAT)',
1466
+ 'publish.pat_hint': '系统使用此 Token 替购买者拉取源码。该密钥仅存储于云端后台,',
1467
+ 'publish.pat_privacy': '绝对不会在前端接口中公开暴露。',
1468
  'publish.token_placeholder': 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
1469
  'publish.token_keep_hint': '已配置,留空保留原密钥',
1470
  'publish.effect_images': '效果展示图 (最多6张,可选)',
 
1662
  'settings_form.moderation_skipped': '上传图片将跳过审核',
1663
  'settings_form.moderation_enabled': '图像审核已启用',
1664
  'settings_form.moderation_disabled': '图像审核已关闭',
1665
+ 'settings_form.moderation_read_failed': '审核状态读取失败,请重试后再修改。',
1666
+ 'settings_form.moderation_confirming': '正在保存并确认审核状态…',
1667
+ 'settings_form.moderation_unconfirmed': '审核设置结果尚未确认,请重试读取状态后再操作。',
1668
+ 'settings_form.moderation_changed': '审核状态与本次设置不同,已显示当前读取结果。',
1669
  // 视频播放器
1670
  'video.play': '播放',
1671
  'video.pause': '暂停',
 
2530
  'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
2531
  'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
2532
  'feedback.request_cancelled': 'Request cancelled',
2533
+ 'feedback.stream_idle_timeout': 'No data has arrived for a while. The operation result is not yet confirmed.',
2534
+ 'feedback.stream_missing_final': 'The connection ended without a final result. Check the operation status before continuing.',
2535
+ 'feedback.stream_invalid_encoding': 'The stream contains invalid text encoding. The operation result is not yet confirmed.',
2536
+ 'feedback.stream_limit_exceeded': 'The stream exceeds the safe reading limits. The operation result is not yet confirmed.',
2537
+ 'feedback.stream_timeout_unconfirmed': 'The connection timed out. The operation result is not yet confirmed.',
2538
  'common.processing': 'Processing',
2539
  'common.got_it': 'Got it',
2540
  'confirm.delete_title': 'Confirm Delete',
 
3162
  'publish.password_encrypted_hint': 'Password encrypted in cloud, shown only after successful purchase.',
3163
  'publish.private_repo_hint': 'This is a private GitHub repo (requires access key)',
3164
  'publish.pat_label': 'Private Repo PAT',
3165
+ 'publish.pat_hint': 'System uses this token to pull source for buyers. Only stored on backend,',
3166
+ 'publish.pat_privacy': 'never exposed in frontend APIs.',
3167
  'publish.token_placeholder': 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
3168
  'publish.token_keep_hint': 'Already configured, leave empty to keep',
3169
  'publish.effect_images': 'Preview Images (max 6, optional)',
 
3358
  'settings_form.moderation_skipped': 'Uploaded images will skip moderation',
3359
  'settings_form.moderation_enabled': 'Image moderation enabled',
3360
  'settings_form.moderation_disabled': 'Image moderation disabled',
3361
+ 'settings_form.moderation_read_failed': 'Could not read moderation status. Retry before changing it.',
3362
+ 'settings_form.moderation_confirming': 'Saving and confirming moderation status…',
3363
+ 'settings_form.moderation_unconfirmed': 'Moderation settings are not yet confirmed. Retry reading the status before making changes.',
3364
+ 'settings_form.moderation_changed': 'Moderation status differs from this change. The current readback is shown.',
3365
 
3366
  // Video player
3367
  'video.play': 'Play',
前端页面/components/顶部导航组件.js CHANGED
@@ -1,12 +1,10 @@
1
  // 前端页面/components/顶部导航组件.js
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, PLACEHOLDERS } from "../core/全局配置.js";
@@ -58,7 +56,13 @@ export function createTopNav() {
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";
@@ -84,13 +88,69 @@ export function createTopNav() {
84
  bellBtn.setAttribute("aria-label", t('social.notifications'));
85
 
86
  let currentUser = null;
 
87
  chatEntryBtn.onclick = () => openChatModal(currentUser);
88
- bellBtn.onclick = () => openNotificationCenter(currentUser, bellBtn);
 
 
 
 
 
 
 
89
  const sessionStorages = () => ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
90
  const restoredSession = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS);
91
  currentUser = restoredSession?.user || null;
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  const updateUserButtonState = () => {
 
94
  if (currentUser) {
95
  const avatar = document.createElement("img");
96
  avatar.className = "top-nav__avatar";
@@ -121,8 +181,9 @@ export function createTopNav() {
121
  bellBtn.querySelector("#unread-badge").style.display = "none";
122
  chatEntryBtn.querySelector("#chat-unread-badge").style.display = "none";
123
 
124
- userActionBtn.onclick = () => {
125
- const view = createAuthView(async (formData, context) => {
 
126
  const originalSession = captureRequestSession();
127
  const current = () => context.isCurrent() && userHeader.isConnected
128
  && _navInstance?.dom === userHeader && originalSession();
@@ -157,7 +218,7 @@ export function createTopNav() {
157
  showToast(t(isRegister ? 'auth.register_success' : 'auth.login_success'), 'success');
158
  // 凭证已发布即完成登录,资料补充不阻塞页面返回。
159
  window.dispatchEvent(new CustomEvent('comfy-route-back'));
160
- updateUserButtonState(); window.dispatchEvent(new CustomEvent('comfy-user-login'));
161
  const applyProfile = data => {
162
  if (!sameLogin() || data?.account !== account) return;
163
  currentUser = sessionProfile({ ...currentUser, ...data });
@@ -195,8 +256,7 @@ export function createTopNav() {
195
  }
196
  }
197
  });
198
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
199
- };
200
  }
201
  };
202
 
 
1
  // 前端页面/components/顶部导航组件.js
 
2
  import { openUserProfileModal } from "../profile/个人中心视图.js";
3
  import { openChatModal, loadChatUnreadCount } from "../social/私信聊天组件.js";
4
  import { api, captureRequestSession } from "../core/网络请求API.js";
5
  import { showToast } from "./UI交互提示组件.js";
6
  import { showAboutInfo } from "./关于插件组件.js";
7
+ import { loadUnreadCount } from "../social/未读角标工具.js";
 
8
  import { setUser, updateUser, initUserState } from "../core/状态管理.js";
9
  import { readUserSession, sessionProfile } from "../core/用户会话存储.js";
10
  import { API, CACHE, PLACEHOLDERS } from "../core/全局配置.js";
 
56
  settingsBtn.textContent = "⚙️";
57
  settingsBtn.title = t('nav.settings');
58
  settingsBtn.setAttribute("aria-label", t('nav.settings'));
59
+ settingsBtn.onclick = (e) => {
60
+ e.stopPropagation();
61
+ return openDeferredView(async () => {
62
+ const { createSettingsView } = await import('./全局设置组件.js');
63
+ return createSettingsView;
64
+ }, settingsBtn);
65
+ };
66
 
67
  const titleWrapper = document.createElement("div");
68
  titleWrapper.className = "top-nav__heading";
 
88
  bellBtn.setAttribute("aria-label", t('social.notifications'));
89
 
90
  let currentUser = null;
91
+ let navigationGeneration = 0;
92
  chatEntryBtn.onclick = () => openChatModal(currentUser);
93
+ bellBtn.onclick = () => {
94
+ if (!currentUser?.account) return showToast(t('notif.login_required'), 'warning');
95
+ const notificationUser = currentUser;
96
+ return openDeferredView(async () => {
97
+ const { openNotificationCenter } = await import('../social/通知中心组件.js');
98
+ return () => openNotificationCenter(notificationUser, bellBtn);
99
+ }, bellBtn, { ownsRoute: true });
100
+ };
101
  const sessionStorages = () => ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
102
  const restoredSession = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS);
103
  currentUser = restoredSession?.user || null;
104
 
105
+ async function openDeferredView(loadFactory, trigger, { ownsRoute = false } = {}) {
106
+ const generation = navigationGeneration;
107
+ const matchesSession = captureRequestSession();
108
+ const loadingView = document.createElement('div');
109
+ loadingView.className = 'hub-inline-load-state';
110
+ const message = document.createElement('span');
111
+ message.textContent = t('common.loading');
112
+ message.setAttribute('role', 'status');
113
+ message.setAttribute('aria-live', 'polite');
114
+ const back = document.createElement('button');
115
+ back.type = 'button'; back.className = 'ranking-publish-back';
116
+ back.textContent = t('common.back');
117
+ let active = true;
118
+ loadingView._cleanup = () => { active = false; back.onclick = null; };
119
+ loadingView.appendChild(message); loadingView.appendChild(back);
120
+ const currentLoad = () => active && loadingView.isConnected && userHeader.isConnected
121
+ && _navInstance?.dom === userHeader && navigationGeneration === generation && matchesSession();
122
+ back.onclick = () => {
123
+ if (!active || !loadingView.isConnected || _navInstance?.dom !== userHeader) return;
124
+ window.dispatchEvent(new CustomEvent('comfy-route-back'));
125
+ if (trigger.isConnected) trigger.focus();
126
+ };
127
+ window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: loadingView } }));
128
+ try {
129
+ const createView = await loadFactory();
130
+ if (!currentLoad()) return;
131
+ if (ownsRoute) {
132
+ await createView();
133
+ if (currentLoad()) back.onclick();
134
+ return;
135
+ }
136
+ const view = createView();
137
+ if (!currentLoad()) { try { view._cleanup?.(); } finally { view.remove(); } return; }
138
+ window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view } }));
139
+ } catch {
140
+ if (currentLoad()) {
141
+ message.textContent = t('common.operation_failed');
142
+ message.setAttribute('role', 'alert'); message.setAttribute('aria-live', 'assertive');
143
+ }
144
+ } finally {
145
+ if (active && !currentLoad()) {
146
+ if (loadingView.isConnected && _navInstance?.dom === userHeader) window.dispatchEvent(new CustomEvent('comfy-route-back'));
147
+ else { loadingView._cleanup(); loadingView.remove(); }
148
+ }
149
+ }
150
+ }
151
+
152
  const updateUserButtonState = () => {
153
+ navigationGeneration++;
154
  if (currentUser) {
155
  const avatar = document.createElement("img");
156
  avatar.className = "top-nav__avatar";
 
181
  bellBtn.querySelector("#unread-badge").style.display = "none";
182
  chatEntryBtn.querySelector("#chat-unread-badge").style.display = "none";
183
 
184
+ userActionBtn.onclick = () => openDeferredView(async () => {
185
+ const { createAuthView } = await import('../auth/用户注册登录组件.js');
186
+ return () => createAuthView(async (formData, context) => {
187
  const originalSession = captureRequestSession();
188
  const current = () => context.isCurrent() && userHeader.isConnected
189
  && _navInstance?.dom === userHeader && originalSession();
 
218
  showToast(t(isRegister ? 'auth.register_success' : 'auth.login_success'), 'success');
219
  // 凭证已发布即完成登录,资料补充不阻塞页面返回。
220
  window.dispatchEvent(new CustomEvent('comfy-route-back'));
221
+ window.dispatchEvent(new CustomEvent('comfy-user-login')); updateUserButtonState();
222
  const applyProfile = data => {
223
  if (!sameLogin() || data?.account !== account) return;
224
  currentUser = sessionProfile({ ...currentUser, ...data });
 
256
  }
257
  }
258
  });
259
+ }, userActionBtn);
 
260
  }
261
  };
262
 
前端页面/core/侧边栏主程序.js CHANGED
@@ -6,13 +6,8 @@
6
  // 由 侧边栏入口注册.js 动态加载
7
  // ==========================================
8
 
9
- import { createPublishView } from "../market/发布内容组件.js";
10
- 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, 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";
@@ -117,6 +112,18 @@ function _onSidebarWindow(event, handler) {
117
  _sidebarWindowListeners.push({ event, handler });
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  export function buildSidebarDOM() {
121
  // 🔧 P1修复:重建前先清理上一轮的 window 监听,避免重复注册
122
  _cleanupSidebarWindowListeners();
@@ -356,77 +363,120 @@ export function buildSidebarDOM() {
356
 
357
  _onSidebarWindow("comfy-open-detail", (e) => {
358
  const { itemData, currentUser } = e.detail;
359
- const view = createItemDetailView(itemData, currentUser);
360
- showInlineView(view);
361
  });
362
 
363
- // 监听进入修改编辑页面的请求
364
- // 🔴 修复:编辑时先获取详情API数据,确保 has_private_token 等字段完整(列表缓存可能缺少该字段)
365
- _onSidebarWindow("comfy-route-edit-publish", async (e) => {
366
- // 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
367
- if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
368
- const { itemData, currentUser } = e.detail;
369
  const matchesSession = captureRequestSession();
370
- if (!currentUser?.account || topNav.getCurrentUser()?.account !== currentUser.account) return;
371
-
372
- // 先展示加载状态,避免用户感知延迟
373
- const loadingView = document.createElement("div");
374
- loadingView.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;height:400px;color:#888;font-size:14px;";
375
- loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
 
 
 
 
 
 
 
 
 
376
  showInlineView(loadingView);
377
-
378
- const isCurrent = () => matchesSession() && topNav.getCurrentUser()?.account === currentUser.account
379
  && activeInlineView === loadingView && loadingView.isConnected;
380
- let enrichedItemData;
 
 
 
 
381
  try {
382
- const detailRes = await api.getItemById(itemData.id, { noStore: true });
383
- if (!isCurrent()) return;
384
- const data = detailRes?.data, revision = data?.edit_revision === undefined ? 0 : data.edit_revision;
385
- if (detailRes?.status !== 'success' || data?.id !== itemData.id || data?.author !== currentUser.account
386
- || !Number.isSafeInteger(revision) || revision < 0) throw new Error('Invalid edit detail');
387
- enrichedItemData = { ...data, edit_revision: revision };
388
- } catch (_) {
389
  if (!isCurrent()) return;
390
- loadingView.textContent = t('common.operation_failed');
391
- loadingView.setAttribute('role', 'alert');
392
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  }
394
- if (!isCurrent()) return;
395
-
396
- const publishView = createPublishView(currentUser,
397
- () => hideInlineView(),
398
- () => { hideInlineView(); triggerLoad(true); },
399
- enrichedItemData
400
- );
401
- showInlineView(publishView);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  });
403
 
404
  // 监听进入任务编辑页面的请求
405
- _onSidebarWindow("comfy-route-edit-task", (e) => {
406
- if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
407
  const { taskData, currentUser } = e.detail;
408
- try {
409
- const view = createPublishTaskView(currentUser, taskData);
410
- showInlineView(view);
411
- } catch (err) {
412
- console.error('创建编辑任务视图失败');
413
- }
414
  });
415
 
416
- // 监听进入帖子编辑页面的请求
417
- _onSidebarWindow("comfy-route-edit-post", (e) => {
418
- if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
419
  const { postData, currentUser } = e.detail;
420
- const view = createPublishPostView(currentUser, postData);
421
- showInlineView(view);
422
  });
423
 
424
- // 监听进入提示词编辑页面的请求
425
- _onSidebarWindow("comfy-route-edit-prompt", (e) => {
426
- if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
427
  const { promptData, currentUser } = e.detail;
428
- const view = createPublishPromptView(currentUser, promptData);
429
- showInlineView(view);
430
  });
431
 
432
  // 🔔 通知跳转:切换到对应Tab并展开指定卡片
@@ -545,33 +595,11 @@ export function buildSidebarDOM() {
545
  triggerLoad(e.detail?.force ?? false);
546
  });
547
 
548
- sortContainer.querySelector("#btn-open-publish").onclick = () => {
549
  const currentUser = topNav.getCurrentUser();
550
- if (!currentUser) return showToast(t('auth.login_required'), "warning");
551
-
552
- // 🎯 根据当前Tab打开对应的发布界面
553
- if (currentTab === "tasks") {
554
- // 任务榜 -> 打开发布任务界面
555
- const view = createPublishTaskView(currentUser);
556
- showInlineView(view);
557
- } else if (currentTab === "posts") {
558
- // 讨论区 -> 打开发布帖子界面
559
- const view = createPublishPostView(currentUser);
560
- showInlineView(view);
561
- } else if (currentTab === "prompts") {
562
- // 🧩 提示词 -> 打开发布提示词界面
563
- const view = createPublishPromptView(currentUser);
564
- showInlineView(view);
565
- } else {
566
- // 工具/Skill/应用/推荐 -> 打开发布内容界面,并自动设置对应类型
567
- const publishView = createPublishView(currentUser,
568
- () => hideInlineView(),
569
- () => { hideInlineView(); triggerLoad(true); },
570
- null, // editItemData
571
- currentTab // initialType: tools/skills/apps/recommends
572
- );
573
- showInlineView(publishView);
574
- }
575
  };
576
 
577
  tabs.forEach(tab => {
 
6
  // 由 侧边栏入口注册.js 动态加载
7
  // ==========================================
8
 
 
 
 
 
9
  import { createTopNav } from "../components/顶部导航组件.js";
10
  import { loadSidebarContent, disposeSidebarContent } from "./侧边栏数据引擎.js";
 
11
  import { showToast } from "../components/UI交互提示组件.js";
12
  import { api, captureRequestSession } from "./网络请求API.js"; // 🔴 编辑模式需调用详情API获取完整数据
13
  import { CACHE, UI, getBackgroundKey, IS_WEB_MODE } from "./全局配置.js";
 
112
  _sidebarWindowListeners.push({ event, handler });
113
  }
114
 
115
+ async function loadDetailFactory() {
116
+ return (await import('../market/资源详情页面组件.js')).createItemDetailView;
117
+ }
118
+
119
+ async function loadPublishFactory(kind) {
120
+ if (kind === 'resource') return (await import('../market/发布内容组件.js')).createPublishView;
121
+ if (kind === 'task') return (await import('../task/发布任务组件.js')).createPublishTaskView;
122
+ if (kind === 'post') return (await import('../post/发布帖子组件.js')).createPublishPostView;
123
+ if (kind === 'prompt') return (await import('../prompt/发布提示词组件.js')).createPublishPromptView;
124
+ throw new Error('Invalid publish view');
125
+ }
126
+
127
  export function buildSidebarDOM() {
128
  // 🔧 P1修复:重建前先清理上一轮的 window 监听,避免重复注册
129
  _cleanupSidebarWindowListeners();
 
363
 
364
  _onSidebarWindow("comfy-open-detail", (e) => {
365
  const { itemData, currentUser } = e.detail;
366
+ void openDetailView(itemData, currentUser);
 
367
  });
368
 
369
+ const openInlineViewAfterLoad = async (prepareView, ownerCurrent) => {
 
 
 
 
 
370
  const matchesSession = captureRequestSession();
371
+ const renderToken = getRenderToken();
372
+ const loadingView = document.createElement('div');
373
+ loadingView.className = 'hub-inline-load-state';
374
+ const loadingMessage = document.createElement('span');
375
+ loadingMessage.setAttribute('role', 'status');
376
+ loadingMessage.setAttribute('aria-live', 'polite');
377
+ loadingMessage.textContent = t('common.loading');
378
+ const returnButton = document.createElement('button');
379
+ returnButton.type = 'button';
380
+ returnButton.className = 'ranking-publish-back';
381
+ returnButton.textContent = t('common.back');
382
+ const returnFocus = document.activeElement;
383
+ loadingView.appendChild(loadingMessage);
384
+ loadingView.appendChild(returnButton);
385
+ loadingView._cleanup = () => { returnButton.onclick = null; };
386
  showInlineView(loadingView);
387
+ const isCurrent = () => !sidebarDisposed && matchesSession() && ownerCurrent()
388
+ && renderToken === getRenderToken()
389
  && activeInlineView === loadingView && loadingView.isConnected;
390
+ returnButton.onclick = () => {
391
+ if (activeInlineView !== loadingView || sidebarDisposed) return;
392
+ hideInlineView();
393
+ if (returnFocus?.isConnected) returnFocus.focus();
394
+ };
395
  try {
396
+ const createView = await prepareView(isCurrent);
 
 
 
 
 
 
397
  if (!isCurrent()) return;
398
+ const view = createView();
399
+ if (!isCurrent()) {
400
+ try { view._cleanup?.(); }
401
+ catch { console.warn('Unshown inline view cleanup failed'); }
402
+ try { cleanupImageSandbox(view); }
403
+ finally { view.remove(); }
404
+ return;
405
+ }
406
+ showInlineView(view);
407
+ } catch {
408
+ if (isCurrent()) {
409
+ loadingMessage.textContent = t('common.operation_failed');
410
+ loadingMessage.setAttribute('role', 'alert');
411
+ loadingMessage.setAttribute('aria-live', 'assertive');
412
+ }
413
+ } finally {
414
+ if (activeInlineView === loadingView && !isCurrent()) hideInlineView();
415
  }
416
+ };
417
+
418
+ const openDetailView = async (itemData, currentUser) => {
419
+ const account = currentUser?.account;
420
+ const ownerCurrent = () => topNav.getCurrentUser()?.account === account;
421
+ if (!ownerCurrent()) return;
422
+ await openInlineViewAfterLoad(async () => {
423
+ const createView = await loadDetailFactory();
424
+ return () => createView(itemData, currentUser);
425
+ }, ownerCurrent);
426
+ };
427
+
428
+ const openPublishView = async (kind, currentUser, editData = null, initialType) => {
429
+ if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), 'warning');
430
+ const account = currentUser?.account;
431
+ const ownerCurrent = () => topNav.getCurrentUser()?.account === account;
432
+ if (!account || !ownerCurrent()) return;
433
+ await openInlineViewAfterLoad(async isCurrent => {
434
+ let preparedData = editData;
435
+ if (kind === 'resource' && editData) {
436
+ const itemId = editData.id;
437
+ const detailRes = await api.getItemById(itemId, { noStore: true });
438
+ if (!isCurrent()) return;
439
+ const data = detailRes?.data, revision = data?.edit_revision === undefined ? 0 : data.edit_revision;
440
+ if (detailRes?.status !== 'success' || data?.id !== itemId || data?.author !== account
441
+ || !Number.isSafeInteger(revision) || revision < 0) throw new Error('Invalid edit detail');
442
+ preparedData = { ...data, edit_revision: revision };
443
+ }
444
+ const createView = await loadPublishFactory(kind);
445
+ if (!isCurrent()) return;
446
+ return () => {
447
+ let view;
448
+ if (kind === 'resource') {
449
+ view = createView(currentUser,
450
+ () => { if (activeInlineView === view) hideInlineView(); },
451
+ () => { if (activeInlineView === view) { hideInlineView(); triggerLoad(true); } },
452
+ preparedData, initialType);
453
+ } else {
454
+ view = createView(currentUser, preparedData);
455
+ }
456
+ return view;
457
+ };
458
+ }, ownerCurrent);
459
+ };
460
+
461
+ _onSidebarWindow("comfy-route-edit-publish", async (e) => {
462
+ const { itemData, currentUser } = e.detail;
463
+ await openPublishView('resource', currentUser, itemData);
464
  });
465
 
466
  // 监听进入任务编辑页面的请求
467
+ _onSidebarWindow("comfy-route-edit-task", async (e) => {
 
468
  const { taskData, currentUser } = e.detail;
469
+ await openPublishView('task', currentUser, taskData);
 
 
 
 
 
470
  });
471
 
472
+ _onSidebarWindow("comfy-route-edit-post", async (e) => {
 
 
473
  const { postData, currentUser } = e.detail;
474
+ await openPublishView('post', currentUser, postData);
 
475
  });
476
 
477
+ _onSidebarWindow("comfy-route-edit-prompt", async (e) => {
 
 
478
  const { promptData, currentUser } = e.detail;
479
+ await openPublishView('prompt', currentUser, promptData);
 
480
  });
481
 
482
  // 🔔 通知跳转:切换到对应Tab并展开指定卡片
 
595
  triggerLoad(e.detail?.force ?? false);
596
  });
597
 
598
+ sortContainer.querySelector("#btn-open-publish").onclick = async () => {
599
  const currentUser = topNav.getCurrentUser();
600
+ if (!currentUser) return showToast(t('auth.login_required'), 'warning');
601
+ const kind = { tasks: 'task', posts: 'post', prompts: 'prompt' }[currentTab] || 'resource';
602
+ await openPublishView(kind, currentUser, null, currentTab);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  };
604
 
605
  tabs.forEach(tab => {
前端页面/core/全局配置.js CHANGED
@@ -18,7 +18,7 @@
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BANNER_IMAGE_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp, REQUEST_ID_HEADER: string, REQUEST_ID_PATTERN: RegExp, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string }} */
22
  export const API = {
23
  REQUEST_ID_HEADER: "X-Ranking-Request-Id",
24
  REQUEST_ID_PATTERN: /^(?:[a-f0-9]{32}|ERR_[0-9]{10,13}_[a-f0-9]{1,8})$/,
@@ -38,6 +38,13 @@ export const API = {
38
  LOCAL_CACHE_STATS_TIMEOUT: 10000,
39
  LOCAL_CACHE_CLEAR_TIMEOUT: 60000,
40
  LOCAL_CACHE_RESPONSE_MAX_BYTES: 8192,
 
 
 
 
 
 
 
41
 
42
  // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
43
  // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
@@ -300,6 +307,8 @@ export const UI = {
300
  },
301
  // Toast 消息配置
302
  TOAST: {
 
 
303
  DURATION: 3000, // 显示时长(毫秒)
304
  MAX_VISIBLE: 3, // 最大同时显示数量
305
  POSITION: "top-center" // 位置
@@ -773,3 +782,63 @@ export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
773
 
774
  // 任务定价边界,与云端 task_publication/task_terms 对应。
775
  export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]), TIP_AMOUNTS: Object.freeze([10, 50, 100, 500]) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, SSE_MAX_RESPONSE_BYTES: number, SSE_MAX_LINE_BYTES: number, SSE_MAX_LINES: number, SSE_YIELD_LINES: number, SSE_YIELD_BYTES: number, SSE_YIELD_CHUNKS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BANNER_IMAGE_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp, REQUEST_ID_HEADER: string, REQUEST_ID_PATTERN: RegExp, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string }} */
22
  export const API = {
23
  REQUEST_ID_HEADER: "X-Ranking-Request-Id",
24
  REQUEST_ID_PATTERN: /^(?:[a-f0-9]{32}|ERR_[0-9]{10,13}_[a-f0-9]{1,8})$/,
 
38
  LOCAL_CACHE_STATS_TIMEOUT: 10000,
39
  LOCAL_CACHE_CLEAR_TIMEOUT: 60000,
40
  LOCAL_CACHE_RESPONSE_MAX_BYTES: 8192,
41
+ // 工作流终态编码上限为512 MiB,另保留64 MiB进度数据;行预算包含data前缀。
42
+ SSE_MAX_RESPONSE_BYTES: 576 * 1024 * 1024,
43
+ SSE_MAX_LINE_BYTES: 512 * 1024 * 1024,
44
+ SSE_MAX_LINES: 262144,
45
+ SSE_YIELD_LINES: 256,
46
+ SSE_YIELD_BYTES: 1024 * 1024,
47
+ SSE_YIELD_CHUNKS: 256,
48
 
49
  // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
50
  // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
 
307
  },
308
  // Toast 消息配置
309
  TOAST: {
310
+ OFFSET: 50,
311
+ GAP: 10,
312
  DURATION: 3000, // 显示时长(毫秒)
313
  MAX_VISIBLE: 3, // 最大同时显示数量
314
  POSITION: "top-center" // 位置
 
782
 
783
  // 任务定价边界,与云端 task_publication/task_terms 对应。
784
  export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]), TIP_AMOUNTS: Object.freeze([10, 50, 100, 500]) });
785
+
786
+
787
+ export const TYPE_RULES = {
788
+ tool: {
789
+ recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
790
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
791
+ },
792
+ skill: {
793
+ recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
794
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
795
+ },
796
+ app: {
797
+ recommendForm: false, resType: { fixed: 'json' },
798
+ privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
799
+ },
800
+ recommend: {
801
+ recommendForm: true, resType: { lockedByForm: true },
802
+ privateRepoWhenLink: false, price: false, refund: false, originalRequired: false,
803
+ formToResType: { recommend_tool: 'link', recommend_skill: 'link', recommend_app: 'json', recommend_link: 'netdisk' }
804
+ }
805
+ };
806
+
807
+ // 🔧 设置项 localStorage Key
808
+ const SETTINGS_KEY = "ComfyCommunity_Settings";
809
+
810
+ // 🔧 默认设置值
811
+ const DEFAULT_SETTINGS = {
812
+ showCreatorBanner: true, // 创作者卡片显示背景图
813
+ enableAnimations: true, // ✨ 榜单切换动画
814
+ enableSoundEffects: true, // 🔊 动画音效
815
+ language: 'zh-CN', // 🌐 界面语言
816
+ cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时
817
+ // 🚀 后续可扩展更多设置项
818
+ // compactMode: false, // 紧凑模式
819
+ // darkMode: true, // 深色模式
820
+ };
821
+
822
+ /**
823
+ * 📦 获取当前设置
824
+ */
825
+ export function getSettings() {
826
+ try {
827
+ const saved = localStorage.getItem(SETTINGS_KEY);
828
+ if (saved) {
829
+ return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
830
+ }
831
+ } catch (e) {}
832
+ return { ...DEFAULT_SETTINGS };
833
+ }
834
+
835
+ /**
836
+ * 💾 保存设置
837
+ */
838
+ export function saveSettings(settings) {
839
+ try {
840
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
841
+ // 🔔 广播设置变更事件,让其他组件响应
842
+ window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
843
+ } catch (e) {}
844
+ }
前端页面/core/网络请求_基础设施.js CHANGED
@@ -702,6 +702,7 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
702
  let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
703
  let idleTimer = null;
704
  let finalResult = null;
 
705
 
706
  const clearIdleTimer = () => {
707
  if (idleTimer !== null) {
@@ -727,16 +728,68 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
727
  throw error;
728
  }
729
 
730
- const reader = response.body.getReader();
731
- const decoder = new TextDecoder();
732
- let buffer = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  const processSSELine = (line) => {
734
  if (!line.startsWith("data: ")) return;
735
  let data;
736
  try {
737
  data = JSON.parse(line.slice(6));
738
- } catch (parseError) {
739
- console.warn("[SSE] 解析事件失败:", parseError?.name || 'SyntaxError');
740
  return;
741
  }
742
  if (!data || typeof data !== "object" || Array.isArray(data)) {
@@ -754,6 +807,22 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
754
  console.warn("[SSE] 进度回调失败");
755
  }
756
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  while (true) {
758
  // 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
759
  const idlePromise = new Promise((_, reject) => {
@@ -773,7 +842,7 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
773
  // 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
774
  if (finalResult) return finalResult;
775
  // 否则返回空闲超时错误
776
- return { status: "error", outcomeUnknown: true, message: "连接超时:30秒未收到数据" };
777
  }
778
  throw e;
779
  }
@@ -781,19 +850,18 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
781
 
782
  const { done, value } = readResult;
783
  if (done) {
784
- buffer += decoder.decode();
785
- for (const line of buffer.split("\n")) processSSELine(line);
786
- buffer = "";
787
  break;
788
  }
789
 
790
- buffer += decoder.decode(value, { stream: true });
791
- const lines = buffer.split("\n");
792
- buffer = lines.pop() || ""; // 保留不完整行
793
- for (const line of lines) processSSELine(line);
 
794
  }
795
 
796
- return finalResult || { status: "error", outcomeUnknown: true, message: "未收到最终结果" };
797
  } catch (err) {
798
  // 已收到最终事件时,后续连接关闭错误不应覆盖操作结果或触发重复提交。
799
  if (finalResult) return finalResult;
@@ -802,7 +870,7 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
802
  status: "error",
803
  outcomeUnknown: true,
804
  cancelRequested: !!externalSignal?.aborted,
805
- message: externalSignal?.aborted ? t('feedback.request_cancelled') : "连接超时,操作结果暂时无法确认"
806
  };
807
  }
808
  throw err;
@@ -810,6 +878,12 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
810
  clearTimeout(globalTimer);
811
  clearIdleTimer();
812
  externalSignal?.removeEventListener("abort", abortFromCaller);
 
 
 
 
 
 
813
  }
814
  }
815
 
 
702
  let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
703
  let idleTimer = null;
704
  let finalResult = null;
705
+ let streamReader = null;
706
 
707
  const clearIdleTimer = () => {
708
  if (idleTimer !== null) {
 
728
  throw error;
729
  }
730
 
731
+ const reader = streamReader = response.body.getReader();
732
+ const decoder = new TextDecoder('utf-8', { fatal: true });
733
+ const decodeSSEChunk = (value, options) => {
734
+ try {
735
+ return decoder.decode(value, options);
736
+ } catch (_) {
737
+ const error = new Error(t('feedback.stream_invalid_encoding'));
738
+ error.code = 'invalid_stream_encoding';
739
+ error.outcomeUnknown = true;
740
+ throw error;
741
+ }
742
+ };
743
+ let totalBytes = 0, lineBytes = 0, lineCount = 0;
744
+ let workBytes = 0, workLines = 0, workChunks = 0;
745
+ const lineParts = [];
746
+ const limitError = () => {
747
+ const error = new Error(t('feedback.stream_limit_exceeded'));
748
+ error.code = 'stream_limit_exceeded';
749
+ error.outcomeUnknown = true;
750
+ return error;
751
+ };
752
+ const appendLinePart = (part) => {
753
+ if (!part) return;
754
+ let level = 0;
755
+ while (lineParts[level] !== undefined) {
756
+ part = lineParts[level] + part;
757
+ lineParts[level++] = undefined;
758
+ }
759
+ lineParts[level] = part;
760
+ };
761
+ const takeLine = () => {
762
+ const line = lineParts.slice().reverse().filter(part => part !== undefined).join('');
763
+ lineParts.length = 0;
764
+ return line;
765
+ };
766
+ const yieldIfNeeded = async () => {
767
+ if (workBytes >= API.SSE_YIELD_BYTES || workLines >= API.SSE_YIELD_LINES || workChunks >= API.SSE_YIELD_CHUNKS) {
768
+ workBytes = workLines = workChunks = 0;
769
+ await new Promise(resolve => setTimeout(resolve, 0));
770
+ }
771
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
772
+ };
773
+ const checkChunkBudget = (value) => {
774
+ totalBytes += value.byteLength;
775
+ if (totalBytes > API.SSE_MAX_RESPONSE_BYTES) throw limitError();
776
+ let start = 0, end;
777
+ while ((end = value.indexOf(10, start)) !== -1) {
778
+ lineBytes += end - start;
779
+ if (lineBytes > API.SSE_MAX_LINE_BYTES) throw limitError();
780
+ lineBytes = 0;
781
+ start = end + 1;
782
+ }
783
+ lineBytes += value.byteLength - start;
784
+ if (lineBytes > API.SSE_MAX_LINE_BYTES) throw limitError();
785
+ };
786
  const processSSELine = (line) => {
787
  if (!line.startsWith("data: ")) return;
788
  let data;
789
  try {
790
  data = JSON.parse(line.slice(6));
791
+ } catch (_) {
792
+ console.warn("[SSE] 解析事件失败");
793
  return;
794
  }
795
  if (!data || typeof data !== "object" || Array.isArray(data)) {
 
807
  console.warn("[SSE] 进度回调失败");
808
  }
809
  };
810
+ const processSSEText = async (text, flush = false) => {
811
+ let start = 0, end;
812
+ while ((end = text.indexOf("\n", start)) !== -1) {
813
+ if (++lineCount > API.SSE_MAX_LINES) throw limitError();
814
+ appendLinePart(text.slice(start, end));
815
+ processSSELine(takeLine());
816
+ start = end + 1;
817
+ workLines++;
818
+ await yieldIfNeeded();
819
+ }
820
+ appendLinePart(text.slice(start));
821
+ if (flush && lineParts.length) {
822
+ if (++lineCount > API.SSE_MAX_LINES) throw limitError();
823
+ processSSELine(takeLine());
824
+ }
825
+ };
826
  while (true) {
827
  // 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
828
  const idlePromise = new Promise((_, reject) => {
 
842
  // 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
843
  if (finalResult) return finalResult;
844
  // 否则返回空闲超时错误
845
+ return { status: "error", outcomeUnknown: true, message: t('feedback.stream_idle_timeout') };
846
  }
847
  throw e;
848
  }
 
850
 
851
  const { done, value } = readResult;
852
  if (done) {
853
+ await processSSEText(decodeSSEChunk(), true);
 
 
854
  break;
855
  }
856
 
857
+ checkChunkBudget(value);
858
+ workBytes += value.byteLength;
859
+ workChunks++;
860
+ await processSSEText(decodeSSEChunk(value, { stream: true }));
861
+ await yieldIfNeeded();
862
  }
863
 
864
+ return finalResult || { status: "error", outcomeUnknown: true, message: t('feedback.stream_missing_final') };
865
  } catch (err) {
866
  // 已收到最终事件时,后续连接关闭错误不应覆盖操作结果或触发重复提交。
867
  if (finalResult) return finalResult;
 
870
  status: "error",
871
  outcomeUnknown: true,
872
  cancelRequested: !!externalSignal?.aborted,
873
+ message: externalSignal?.aborted ? t('feedback.request_cancelled') : t('feedback.stream_timeout_unconfirmed')
874
  };
875
  }
876
  throw err;
 
878
  clearTimeout(globalTimer);
879
  clearIdleTimer();
880
  externalSignal?.removeEventListener("abort", abortFromCaller);
881
+ try { controller.abort(); } catch (_) {}
882
+ if (streamReader) {
883
+ try { Promise.resolve(streamReader.cancel()).catch(() => {}); } catch (_) {}
884
+ try { streamReader.releaseLock(); } catch (_) {}
885
+ streamReader = null;
886
+ }
887
  }
888
  }
889
 
前端页面/market/创作者卡片组件.js CHANGED
@@ -11,12 +11,11 @@
11
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
12
  import { createCommentSection } from "../social/评论与互动组件.js";
13
  import { renderTipLevelHTML, getTipLevelRuleShort } from "../components/打赏等级工具.js";
14
- import { getBannerCacheKey, PLACEHOLDERS, getCachedProfile, getProfileWithSWR, escapeHtml, CACHE, API } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
  import { captureRequestSession, requestCancelManager } from "../core/网络请求_基础设施.js";
17
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
18
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
19
- import { getSettings } from "../components/全局设置组件.js";
20
  import { t } from "../components/用户体验增强.js";
21
  import { showToast } from "../components/UI交互提示组件.js";
22
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
 
11
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
12
  import { createCommentSection } from "../social/评论与互动组件.js";
13
  import { renderTipLevelHTML, getTipLevelRuleShort } from "../components/打赏等级工具.js";
14
+ import { getBannerCacheKey, PLACEHOLDERS, getCachedProfile, getProfileWithSWR, escapeHtml, CACHE, API, getSettings } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
  import { captureRequestSession, requestCancelManager } from "../core/网络请求_基础设施.js";
17
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
18
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
 
19
  import { t } from "../components/用户体验增强.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
21
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
前端页面/market/发布内容_UI模板.js CHANGED
@@ -81,7 +81,7 @@ export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExi
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>
 
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'))} <strong class="ranking-publish__security-emphasis">${escapeHtml(t('publish.pat_privacy'))}</strong></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>
前端页面/market/发布内容_提交引擎.js CHANGED
@@ -1,13 +1,12 @@
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";
9
  import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
10
- import { TYPE_RULES } from "./发布内容组件.js"; // 📋 四榜发布表单声明式规则(与 UI 联动同源)
11
 
12
  /**
13
  * 📋 获取指定主类型的提交规则(校验与字段组装统一读取,与 UI 显隐同源)
 
1
  import { processImageCore as _processImageCore } from "./有界图片处理.js";
2
  import { validateImageFile } from "./图片文件检查.js";
3
+ import { IMAGE_PROCESS, TYPE_RULES } 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";
9
  import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
 
10
 
11
  /**
12
  * 📋 获取指定主类型的提交规则(校验与字段组装统一读取,与 UI 显隐同源)
前端页面/market/发布内容组件.js CHANGED
@@ -6,7 +6,7 @@ import { handlePublishSubmit } from "./发布内容_提交引擎.js";
6
  import { t } from "../components/用户体验增强.js";
7
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
8
  import { globalModal } from "../components/全局弹窗管理器.js";
9
- import { escapeHtml } from "../core/全局配置.js";
10
 
11
  // ==========================================
12
  // 📌 模块级常量
@@ -14,25 +14,7 @@ import { escapeHtml } from "../core/全局配置.js";
14
 
15
  // 📋 四榜发布表单声明式规则:tool 插件榜 / skill Skill榜 / app 工作流 / recommend 推荐榜
16
  // UI 显隐、联动、校验统一读取此配置,避免类型分支散落多处
17
- export const TYPE_RULES = {
18
- tool: {
19
- recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
20
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
21
- },
22
- skill: {
23
- recommendForm: false, resType: { selectable: ['link', 'netdisk'] },
24
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
25
- },
26
- app: {
27
- recommendForm: false, resType: { fixed: 'json' },
28
- privateRepoWhenLink: true, price: true, refund: true, originalRequired: true
29
- },
30
- recommend: {
31
- recommendForm: true, resType: { lockedByForm: true },
32
- privateRepoWhenLink: false, price: false, refund: false, originalRequired: false,
33
- formToResType: { recommend_tool: 'link', recommend_skill: 'link', recommend_app: 'json', recommend_link: 'netdisk' }
34
- }
35
- };
36
 
37
  // 封面标签统一样式
38
  const _COVER_LABEL_STYLE = 'position: absolute; top: 2px; left: 2px; background: #4CAF50; color: #fff; font-size: 10px; padding: 1px 4px; border-radius: 2px; pointer-events: none;';
 
6
  import { t } from "../components/用户体验增强.js";
7
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
8
  import { globalModal } from "../components/全局弹窗管理器.js";
9
+ import { escapeHtml, TYPE_RULES } from "../core/全局配置.js";
10
 
11
  // ==========================================
12
  // 📌 模块级常量
 
14
 
15
  // 📋 四榜发布表单声明式规则:tool 插件榜 / skill Skill榜 / app 工作流 / recommend 推荐榜
16
  // UI 显隐、联动、校验统一读取此配置,避免类型分支散落多处
17
+ export { TYPE_RULES };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  // 封面标签统一样式
20
  const _COVER_LABEL_STYLE = 'position: absolute; top: 2px; left: 2px; background: #4CAF50; color: #fff; font-size: 10px; padding: 1px 4px; border-radius: 2px; pointer-events: none;';
前端页面/market/资源安装引擎.js CHANGED
@@ -139,6 +139,23 @@ const WORKFLOW_STAGE_KEYS = {
139
  error: 'workflow.stage_failed'
140
  };
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  function _getWorkflowErrorMessage(result) {
143
  if (result?.outcomeUnknown) return t('workflow.connection_uncertain');
144
  const errorKeys = {
@@ -599,8 +616,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
599
  const progress = trackProgress();
600
  const cancelController = new AbortController();
601
  progress.addCancelButton(t('install.cancel_in_progress'), () => cancelController.abort());
602
- const diagnosticEvents = [];
603
- const installStartedAt = Date.now();
604
 
605
  try {
606
  // 发起 SSE 流式安装请求
@@ -609,15 +625,8 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
609
  { url: itemData.link, id: itemData.id, account, token },
610
  (event) => {
611
  if (!isCurrent()) return;
612
- const elapsedSeconds = Math.floor((Date.now() - installStartedAt) / 1000);
613
  const rawStage = typeof event.stage === "string" ? event.stage : "";
614
- diagnosticEvents.push({
615
- stageKey: Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "",
616
- stage: /^[a-z0-9_]{1,40}$/i.test(rawStage) ? rawStage : "",
617
- progress: Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null,
618
- time: `${Math.floor(elapsedSeconds / 60)}:${String(elapsedSeconds % 60).padStart(2, "0")}`
619
- });
620
- if (diagnosticEvents.length > 50) diagnosticEvents.shift();
621
  const stageKey = Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "";
622
  progress.update(event.progress, event.status === "error"
623
  ? _getInstallErrorMessage(event)
@@ -640,7 +649,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
640
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
641
  const message = t('install.connection_uncertain');
642
  progress.error(message);
643
- progress.showDiagnostics(diagnosticEvents);
644
  showToast(message, "warning");
645
  return;
646
  }
@@ -675,7 +684,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
675
  if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); }
676
  const message = _getInstallErrorMessage(result);
677
  progress.error(message);
678
- progress.showDiagnostics(diagnosticEvents);
679
  showToast(t('install.failed_for_item', { title: itemData.title, message }), "error");
680
  }
681
  } catch(err) {
@@ -684,7 +693,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
684
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
685
  const message = t('install.connection_uncertain');
686
  progress.error(message);
687
- progress.showDiagnostics(diagnosticEvents);
688
  showToast(message, "error");
689
  return;
690
  }
@@ -764,6 +773,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
764
  };
765
  } else if (isApp) {
766
  const progress = trackProgress();
 
767
 
768
  // 附带 account 凭证,解决问题 2 (一键鉴权加载)
769
  try {
@@ -772,6 +782,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
772
  { url: itemData.link, id: itemData.id, account, token },
773
  (event) => {
774
  if (!isCurrent()) return;
 
775
  const stageKey = typeof event.stage === "string" && Object.hasOwn(WORKFLOW_STAGE_KEYS, event.stage)
776
  ? WORKFLOW_STAGE_KEYS[event.stage]
777
  : "";
@@ -790,6 +801,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
790
  if (!graphLoaded) {
791
  const message = t('workflow.canvas_load_failed');
792
  progress.error(message);
 
793
  showToast(message, "error");
794
  return;
795
  }
@@ -821,8 +833,8 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
821
  const localizedMessage = result.error_code
822
  ? message
823
  : t('workflow.failed', { message });
824
- progress.error(message);
825
- inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(localizedMessage)}</span>`;
826
  showToast(localizedMessage, "error");
827
  }
828
  } catch(err) {
@@ -831,6 +843,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
831
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
832
  const message = t('workflow.connection_uncertain');
833
  progress.error(message);
 
834
  showToast(message, "error");
835
  return;
836
  }
 
139
  error: 'workflow.stage_failed'
140
  };
141
 
142
+ function _createProgressDiagnostics(stageKeys) {
143
+ const entries = [], startedAt = Date.now();
144
+ return {
145
+ entries,
146
+ record(event) {
147
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
148
+ const rawStage = typeof event.stage === 'string' ? event.stage : '';
149
+ entries.push({
150
+ stageKey: Object.hasOwn(stageKeys, rawStage) ? stageKeys[rawStage] : '',
151
+ progress: Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null,
152
+ time: `${Math.floor(elapsedSeconds / 60)}:${String(elapsedSeconds % 60).padStart(2, '0')}`
153
+ });
154
+ if (entries.length > 50) entries.shift();
155
+ }
156
+ };
157
+ }
158
+
159
  function _getWorkflowErrorMessage(result) {
160
  if (result?.outcomeUnknown) return t('workflow.connection_uncertain');
161
  const errorKeys = {
 
616
  const progress = trackProgress();
617
  const cancelController = new AbortController();
618
  progress.addCancelButton(t('install.cancel_in_progress'), () => cancelController.abort());
619
+ const diagnostics = _createProgressDiagnostics(INSTALL_STAGE_KEYS);
 
620
 
621
  try {
622
  // 发起 SSE 流式安装请求
 
625
  { url: itemData.link, id: itemData.id, account, token },
626
  (event) => {
627
  if (!isCurrent()) return;
628
+ diagnostics.record(event);
629
  const rawStage = typeof event.stage === "string" ? event.stage : "";
 
 
 
 
 
 
 
630
  const stageKey = Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "";
631
  progress.update(event.progress, event.status === "error"
632
  ? _getInstallErrorMessage(event)
 
649
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
650
  const message = t('install.connection_uncertain');
651
  progress.error(message);
652
+ progress.showDiagnostics(diagnostics.entries, result);
653
  showToast(message, "warning");
654
  return;
655
  }
 
684
  if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); }
685
  const message = _getInstallErrorMessage(result);
686
  progress.error(message);
687
+ progress.showDiagnostics(diagnostics.entries, result);
688
  showToast(t('install.failed_for_item', { title: itemData.title, message }), "error");
689
  }
690
  } catch(err) {
 
693
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
694
  const message = t('install.connection_uncertain');
695
  progress.error(message);
696
+ progress.showDiagnostics(diagnostics.entries, err);
697
  showToast(message, "error");
698
  return;
699
  }
 
773
  };
774
  } else if (isApp) {
775
  const progress = trackProgress();
776
+ const diagnostics = _createProgressDiagnostics(WORKFLOW_STAGE_KEYS);
777
 
778
  // 附带 account 凭证,解决问题 2 (一键鉴权加载)
779
  try {
 
782
  { url: itemData.link, id: itemData.id, account, token },
783
  (event) => {
784
  if (!isCurrent()) return;
785
+ diagnostics.record(event);
786
  const stageKey = typeof event.stage === "string" && Object.hasOwn(WORKFLOW_STAGE_KEYS, event.stage)
787
  ? WORKFLOW_STAGE_KEYS[event.stage]
788
  : "";
 
801
  if (!graphLoaded) {
802
  const message = t('workflow.canvas_load_failed');
803
  progress.error(message);
804
+ progress.showDiagnostics(diagnostics.entries, result);
805
  showToast(message, "error");
806
  return;
807
  }
 
833
  const localizedMessage = result.error_code
834
  ? message
835
  : t('workflow.failed', { message });
836
+ progress.error(localizedMessage);
837
+ progress.showDiagnostics(diagnostics.entries, result);
838
  showToast(localizedMessage, "error");
839
  }
840
  } catch(err) {
 
843
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
844
  const message = t('workflow.connection_uncertain');
845
  progress.error(message);
846
+ progress.showDiagnostics(diagnostics.entries, err);
847
  showToast(message, "error");
848
  return;
849
  }
前端页面/market/资源详情页面组件.js CHANGED
@@ -51,16 +51,16 @@ async function renderRefundButton(container, itemData, currentUser, scope, purch
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;">
56
- <div style="font-size: 12px; color: #FF9800;">
57
  🔄 ${t('item.regret_mode')}${t('item.hours_left', { hours: hoursLeft.toFixed(1) })}
58
  </div>
59
- <button id="btn-refund" 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">
60
  💸 ${t('item.apply_refund')}
61
  </button>
62
  </div>
63
- <div style="font-size: 11px; color: #888; margin-top: 8px;">
64
  ⚠️ ${t('item.refund_warning')}
65
  </div>
66
  </div>
@@ -71,7 +71,7 @@ async function renderRefundButton(container, itemData, currentUser, scope, purch
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;">
75
  ✅ ${t('item.refund_expired')}
76
  </div>
77
  `;
@@ -121,12 +121,7 @@ export function createItemDetailView(itemData, currentUser) {
121
  itemData = proxyImages(itemData);
122
 
123
  const container = document.createElement("div");
124
- Object.assign(container.style, {
125
- display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
126
- // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
127
- fontSize: "14px", padding: "15px", overflowY: "auto", flex: "1", minHeight: "0", boxSizing: "border-box",
128
- backgroundColor: "#13151c"
129
- });
130
 
131
  const cachedAuthor = getCachedProfile(itemData.author);
132
  const authorName = cachedAuthor?.name || itemData.author || t('common.unknown_user');
@@ -138,34 +133,32 @@ export function createItemDetailView(itemData, currentUser) {
138
  // 🗑️ 判断是否有权限删除
139
  const showDeleteBtn = canDeleteItem(itemData, currentUser);
140
  const deleteBtnHtml = showDeleteBtn ? `
141
- <button id="btn-delete-item" style="background: #F44336; color: white; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: bold; box-shadow: 0 2px 4px rgba(244,67,54,0.3); transition: 0.2s; margin-left: 10px;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">🗑️ ${t('common.delete')}</button>
142
  ` : '';
143
 
144
  let authorInfoHtml = `
145
- <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
146
  <div>
147
  <strong>${t('item.author')}:</strong> <span id="detail-author-name">${escapeHtml(authorName)}</span>
148
  <!-- 原创标识 -->
149
  ${itemData.is_original ? `
150
- <div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
151
- background: linear-gradient(135deg, #FF6B35, #FF8F00); border-radius: 12px;
152
- font-size: 11px; color: #fff; font-weight: 500; margin-left: 8px;">
153
  🎨 ${t('item.original_notice')}
154
  </div>
155
  ` : ''}
156
  </div>
157
- <div style="display: flex; align-items: center; gap: 8px;">
158
- <button id="btn-tip-item" style="background: #E91E63; color: white; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: bold; box-shadow: 0 2px 4px rgba(233,30,99,0.3); transition: 0.2s;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">💰 ${t('item.tip_encourage')}</button>
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>
166
 
167
- <div style="margin-top: 15px; border-top: 1px dashed #333; padding-top: 15px;">
168
- <div style="font-size: 13px; font-weight: bold; color: #E91E63; margin-bottom: 10px;">💖 ${t('item.tip_board_title')}</div>
169
  ${boardHtml}
170
  </div>
171
  `;
@@ -193,24 +186,23 @@ export function createItemDetailView(itemData, currentUser) {
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 控制下移
197
  container.innerHTML = `
198
- <button id="btn-back-detail" 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); margin-bottom: 15px; width: fit-content; 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)'">
199
  ⬅ ${t('common.back')}
200
  </button>
201
 
202
- <div style="background: #181b28; border: 1px solid #2d334a; border-radius: 8px; padding: 15px; box-shadow: 0 4px 6px rgba(0,0,0,0.2); margin-bottom: 15px;">
203
- <div style="font-size: 16px; font-weight: bold; color: #00bcd4; margin-bottom: 15px; display: flex; align-items: center; gap: 8px;">
204
  ⚙️ ${t('item.detail_info')}
205
  </div>
206
- <div style="color: #ddd; line-height: 1.8; font-size: 13px; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(itemData.fullDesc || itemData.shortDesc)}</div>
207
  </div>
208
 
209
- <div style="background: #181b28; border: 1px solid #2d334a; border-radius: 8px; padding: 15px; box-shadow: 0 4px 6px rgba(0,0,0,0.2);">
210
- <div style="font-size: 16px; font-weight: bold; color: #4CAF50; margin-bottom: 15px; display: flex; align-items: center; gap: 8px;">
211
  🎉 ${t('item.other_info')}
212
  </div>
213
- <div style="color: #eee; line-height: 1.6; font-size: 14px;">
214
  ${authorInfoHtml}
215
  </div>
216
  </div>
@@ -231,7 +223,8 @@ export function createItemDetailView(itemData, currentUser) {
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";
 
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 class="resource-detail__refund-panel">
55
+ <div class="resource-detail__refund-header">
56
+ <div class="resource-detail__refund-copy">
57
  🔄 ${t('item.regret_mode')}${t('item.hours_left', { hours: hoursLeft.toFixed(1) })}
58
  </div>
59
+ <button type="button" id="btn-refund" class="resource-detail__button resource-detail__button--warning">
60
  💸 ${t('item.apply_refund')}
61
  </button>
62
  </div>
63
+ <div class="resource-detail__refund-warning">
64
  ⚠️ ${t('item.refund_warning')}
65
  </div>
66
  </div>
 
71
  };
72
  } else {
73
  refundArea.innerHTML = `
74
+ <div class="resource-detail__refund-expired">
75
  ✅ ${t('item.refund_expired')}
76
  </div>
77
  `;
 
121
  itemData = proxyImages(itemData);
122
 
123
  const container = document.createElement("div");
124
+ container.className = "resource-page";
 
 
 
 
 
125
 
126
  const cachedAuthor = getCachedProfile(itemData.author);
127
  const authorName = cachedAuthor?.name || itemData.author || t('common.unknown_user');
 
133
  // 🗑️ 判断是否有权限删除
134
  const showDeleteBtn = canDeleteItem(itemData, currentUser);
135
  const deleteBtnHtml = showDeleteBtn ? `
136
+ <button type="button" id="btn-delete-item" class="resource-detail__button resource-detail__button--danger">🗑️ ${t('common.delete')}</button>
137
  ` : '';
138
 
139
  let authorInfoHtml = `
140
+ <div class="resource-page__author">
141
  <div>
142
  <strong>${t('item.author')}:</strong> <span id="detail-author-name">${escapeHtml(authorName)}</span>
143
  <!-- 原创标识 -->
144
  ${itemData.is_original ? `
145
+ <div class="resource-detail__badge resource-detail__badge--original">
 
 
146
  🎨 ${t('item.original_notice')}
147
  </div>
148
  ` : ''}
149
  </div>
150
+ <div class="resource-page__actions">
151
+ <button type="button" id="btn-tip-item" class="resource-detail__button resource-detail__button--tip">💰 ${t('item.tip_encourage')}</button>
152
  ${deleteBtnHtml}
153
  </div>
154
  </div>
155
+ <div class="resource-page__thanks">${escapeHtml(t('item.sponsor_thanks'))}</div>
156
 
157
  <!-- 🔄 P7后悔模式:退款区域 -->
158
+ <div id="refund-area" class="resource-page__refund"></div>
159
 
160
+ <div class="resource-page__tips">
161
+ <div class="resource-detail__heading">💖 ${t('item.tip_board_title')}</div>
162
  ${boardHtml}
163
  </div>
164
  `;
 
186
  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>`;
187
  }
188
 
 
189
  container.innerHTML = `
190
+ <button type="button" id="btn-back-detail" class="resource-detail__button resource-page__back">
191
  ⬅ ${t('common.back')}
192
  </button>
193
 
194
+ <div class="resource-page__section">
195
+ <div class="resource-page__heading">
196
  ⚙️ ${t('item.detail_info')}
197
  </div>
198
+ <div class="resource-page__description">${escapeHtml(itemData.fullDesc || itemData.shortDesc)}</div>
199
  </div>
200
 
201
+ <div class="resource-page__section">
202
+ <div class="resource-page__heading">
203
  🎉 ${t('item.other_info')}
204
  </div>
205
+ <div class="resource-page__info">
206
  ${authorInfoHtml}
207
  </div>
208
  </div>
 
223
  // 🚀 创建安装状态容器
224
  const inlineStatusBox = document.createElement("div");
225
  inlineStatusBox.id = "inline-status-box";
226
+ inlineStatusBox.className = "resource-detail__status resource-install__status";
227
+ inlineStatusBox.style.display = "none";
228
  container.appendChild(inlineStatusBox);
229
  if (!localVersionStatus.available) {
230
  inlineStatusBox.style.display = "block";
前端页面/profile/个人设置表单组件.js CHANGED
@@ -121,11 +121,13 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
121
  container.className = 'ranking-settings-form';
122
  const sessionMatches = captureRequestSession();
123
  let disposed = false, saving = false;
 
124
  const readers = new Set(), cropControllers = new Set();
125
  const current = () => !disposed && container.isConnected && sessionMatches() && (options.isCurrent?.() ?? true);
126
  container._cleanup = () => {
127
  if (disposed) return;
128
  disposed = true;
 
129
  for (const controller of cropControllers) { try { controller.abort(); } catch (_) {} }
130
  cropControllers.clear();
131
  for (const reader of readers) { try { reader.abort(); } catch (_) {} }
@@ -243,15 +245,16 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
243
  <div style="background: var(--comfy-input-bg); padding: 12px; border-radius: 6px; border: 1px solid #444;">
244
  <div style="display: flex; align-items: center; justify-content: space-between;">
245
  <div>
246
- <div style="font-size: 13px; color: #fff; margin-bottom: 4px;">🖼️ 图像内容审核</div>
247
- <div style="font-size: 11px; color: #888;">启用后上传的图片将自动进行内容安全审核</div>
248
  </div>
249
  <label class="toggle-switch" style="position: relative; display: inline-block; width: 50px; height: 26px;">
250
- <input type="checkbox" id="admin-image-moderation" style="opacity: 0; width: 0; height: 0;">
251
  <span class="toggle-slider" style="position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--comfy-input-bg); transition: .3s; border-radius: 26px;"></span>
252
  </label>
253
  </div>
254
- <div id="moderation-status" style="margin-top: 8px; font-size: 11px; color: #666;"></div>
 
255
  </div>
256
  </div>
257
  </div>
@@ -316,56 +319,87 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
316
  document.head.appendChild(toggleStyles);
317
  }
318
 
319
- // 🔒 管理员:初始化图像审核开关状态
320
  if (isAdmin) {
321
- setTimeout(async () => {
322
- const moderationToggle = container.querySelector('#admin-image-moderation');
323
- if (!current()) return;
324
- const statusDiv = container.querySelector('#moderation-status');
325
- if (!moderationToggle) return;
326
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  try {
328
- // 获取当前审核状态
329
- const res = await api.getSystemConfig('image_moderation');
330
  if (!current()) return;
331
- const enabled = res?.data?.enabled === true;
332
- moderationToggle.checked = enabled;
333
-
334
- if (statusDiv) {
335
- const quota = res?.data?.quota || {};
336
- statusDiv.innerHTML = enabled
337
- ? `<span style="color: #4CAF50;">✅ ${escapeHtml(t('settings_form.enabled'))}</span> | ${escapeHtml(t('settings_form.moderation_quota', { aliyun: quota.aliyun || 0, tencent: quota.tencent || 0 }))}`
338
- : `<span style="color: #888;">⚪ ${escapeHtml(t('settings_form.disabled'))}</span> | ${escapeHtml(t('settings_form.moderation_skipped'))}`;
339
- }
340
- } catch (e) {
341
- console.warn('获取审核状态失败:', e?.name || 'Error');
342
  }
343
-
344
- // 开关变化事件
345
- moderationToggle.onchange = async () => {
346
- if (!current() || moderationToggle.disabled) return;
347
- const newState = moderationToggle.checked;
348
- moderationToggle.disabled = true;
349
-
350
- try {
351
- await api.setSystemConfig('image_moderation', { enabled: newState });
352
- if (!current()) return;
353
- showToast(newState ? t('settings_form.moderation_enabled') : t('settings_form.moderation_disabled'), 'success');
354
-
355
- if (statusDiv) {
356
- statusDiv.innerHTML = newState
357
- ? `<span style="color: #4CAF50;">✅ ${escapeHtml(t('settings_form.enabled'))}</span>`
358
- : `<span style="color: #888;">⚪ ${escapeHtml(t('settings_form.disabled'))}</span> | ${escapeHtml(t('settings_form.moderation_skipped'))}`;
359
- }
360
- } catch (e) {
361
- if (!current()) return;
362
- showToast(t('settings_form.save_failed'), 'error');
363
- moderationToggle.checked = !newState; // 回滚
364
- } finally {
365
- if (current()) moderationToggle.disabled = false;
366
- }
367
- };
368
- }, 100);
 
 
 
 
 
 
 
 
369
  }
370
 
371
  // 加载本地界面背景预览(使用账号区分键)
@@ -503,7 +537,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
503
  for (const [control] of lockedControls) control.disabled = true;
504
  const unlock = () => {
505
  for (const [control, disabled] of lockedControls) control.disabled = disabled;
506
- saving = false; refreshSaveAvailability();
507
  };
508
 
509
  try {
 
121
  container.className = 'ranking-settings-form';
122
  const sessionMatches = captureRequestSession();
123
  let disposed = false, saving = false;
124
+ let refreshModerationControls = () => {}, releaseModeration = () => {};
125
  const readers = new Set(), cropControllers = new Set();
126
  const current = () => !disposed && container.isConnected && sessionMatches() && (options.isCurrent?.() ?? true);
127
  container._cleanup = () => {
128
  if (disposed) return;
129
  disposed = true;
130
+ releaseModeration();
131
  for (const controller of cropControllers) { try { controller.abort(); } catch (_) {} }
132
  cropControllers.clear();
133
  for (const reader of readers) { try { reader.abort(); } catch (_) {} }
 
245
  <div style="background: var(--comfy-input-bg); padding: 12px; border-radius: 6px; border: 1px solid #444;">
246
  <div style="display: flex; align-items: center; justify-content: space-between;">
247
  <div>
248
+ <div style="font-size: 13px; color: #fff; margin-bottom: 4px;">🖼️ ${escapeHtml(t('settings_form.image_moderation'))}</div>
249
+ <div id="moderation-hint" style="font-size: 11px; color: #888;">${escapeHtml(t('settings_form.image_moderation_hint'))}</div>
250
  </div>
251
  <label class="toggle-switch" style="position: relative; display: inline-block; width: 50px; height: 26px;">
252
+ <input type="checkbox" id="admin-image-moderation" disabled aria-label="${escapeHtml(t('settings_form.image_moderation'))}" aria-describedby="moderation-hint moderation-status" style="opacity: 0; width: 0; height: 0;">
253
  <span class="toggle-slider" style="position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--comfy-input-bg); transition: .3s; border-radius: 26px;"></span>
254
  </label>
255
  </div>
256
+ <div id="moderation-status" role="status" aria-live="polite" style="margin-top: 8px; font-size: 11px; color: var(--rk-text-muted, #aaa);">${escapeHtml(t('common.loading'))}</div>
257
+ <button id="moderation-retry" type="button" hidden>${escapeHtml(t('common.retry'))}</button>
258
  </div>
259
  </div>
260
  </div>
 
319
  document.head.appendChild(toggleStyles);
320
  }
321
 
 
322
  if (isAdmin) {
323
+ const moderationToggle = container.querySelector('#admin-image-moderation');
324
+ const statusDiv = container.querySelector('#moderation-status');
325
+ const retryButton = container.querySelector('#moderation-retry');
326
+ let ready = false, busy = false;
327
+ moderationToggle.indeterminate = true;
328
+ refreshModerationControls = () => {
329
+ moderationToggle.disabled = saving || busy || !ready;
330
+ retryButton.disabled = saving || busy;
331
+ statusDiv.setAttribute('aria-busy', String(busy));
332
+ };
333
+ const confirmState = response => {
334
+ if (response?.status !== 'success' || typeof response?.data?.enabled !== 'boolean') {
335
+ throw new Error('invalid moderation response');
336
+ }
337
+ return response.data.enabled;
338
+ };
339
+ const showState = enabled => {
340
+ ready = true;
341
+ moderationToggle.indeterminate = false;
342
+ moderationToggle.checked = enabled;
343
+ retryButton.hidden = true;
344
+ statusDiv.textContent = enabled ? t('settings_form.enabled')
345
+ : `${t('settings_form.disabled')} | ${t('settings_form.moderation_skipped')}`;
346
+ };
347
+ const showUnknown = message => {
348
+ ready = false;
349
+ moderationToggle.indeterminate = true;
350
+ statusDiv.textContent = message;
351
+ retryButton.hidden = false;
352
+ };
353
+ const loadState = async () => {
354
+ if (!current() || saving || busy) return;
355
+ ready = false; busy = true;
356
+ retryButton.hidden = true;
357
+ statusDiv.textContent = t('common.loading');
358
+ refreshModerationControls();
359
  try {
360
+ const response = await api.getSystemConfig('image_moderation');
 
361
  if (!current()) return;
362
+ showState(confirmState(response));
363
+ } catch (_) {
364
+ if (current()) showUnknown(t('settings_form.moderation_read_failed'));
365
+ } finally {
366
+ busy = false;
367
+ if (current()) refreshModerationControls();
 
 
 
 
 
368
  }
369
+ };
370
+ retryButton.onclick = loadState;
371
+ moderationToggle.onchange = async () => {
372
+ if (!current() || saving || busy || !ready || moderationToggle.disabled) return;
373
+ const requested = moderationToggle.checked;
374
+ ready = false; busy = true;
375
+ statusDiv.textContent = t('settings_form.moderation_confirming');
376
+ refreshModerationControls();
377
+ try {
378
+ const result = await api.setSystemConfig('image_moderation', { enabled: requested });
379
+ if (!current()) return;
380
+ if (result?.status !== 'success') throw new Error('unconfirmed moderation save');
381
+ const response = await api.getSystemConfig('image_moderation');
382
+ if (!current()) return;
383
+ const enabled = confirmState(response);
384
+ showState(enabled);
385
+ showToast(enabled === requested
386
+ ? t(enabled ? 'settings_form.moderation_enabled' : 'settings_form.moderation_disabled')
387
+ : t('settings_form.moderation_changed'), enabled === requested ? 'success' : 'warning');
388
+ } catch (_) {
389
+ if (!current()) return;
390
+ showUnknown(t('settings_form.moderation_unconfirmed'));
391
+ showToast(t('settings_form.moderation_unconfirmed'), 'warning');
392
+ } finally {
393
+ busy = false;
394
+ if (current()) refreshModerationControls();
395
+ }
396
+ };
397
+ const initialRead = setTimeout(loadState, 100);
398
+ releaseModeration = () => {
399
+ clearTimeout(initialRead);
400
+ moderationToggle.onchange = retryButton.onclick = null;
401
+ };
402
+ refreshModerationControls();
403
  }
404
 
405
  // 加载本地界面背景预览(使用账号区分键)
 
537
  for (const [control] of lockedControls) control.disabled = true;
538
  const unlock = () => {
539
  for (const [control, disabled] of lockedControls) control.disabled = disabled;
540
+ saving = false; refreshSaveAvailability(); refreshModerationControls();
541
  };
542
 
543
  try {
前端页面/prompt/提示词组件.js CHANGED
@@ -95,14 +95,13 @@ function _renderEmptyState() {
95
  /**
96
  * 🖼️ 生成头像 HTML 字符串
97
  */
98
- function _generateAvatarHtml(avatar, name, size) {
99
  const initial = (name || 'U')[0].toUpperCase();
100
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
101
  if (avatar) {
102
- return `<img class="swr-avatar" alt="" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
103
  }
104
- const fontSize = Math.max(9, Math.round(size * 0.5));
105
- return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
106
  }
107
 
108
  /**
@@ -741,15 +740,7 @@ export function createPromptsView(currentUser, keyword = "") {
741
  */
742
  function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
743
  const card = document.createElement("div");
744
- card.className = "business-list-card";
745
- Object.assign(card.style, {
746
- background: "var(--comfy-menu-bg)",
747
- borderRadius: "8px",
748
- overflow: "hidden",
749
- cursor: "pointer",
750
- transition: "transform 0.2s, box-shadow 0.2s",
751
- border: "1px solid var(--border-color, #333)"
752
- });
753
 
754
  // 格式化时间
755
  const timeStr = formatTime(prompt.created_at);
@@ -757,59 +748,53 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
757
  // 判断是否为视频封面
758
  const isVideo = prompt.media_type === "video";
759
 
760
- // 价格徽标:免费绿色 / 💎价格金色
761
  const priceHtml = (prompt.price > 0)
762
- ? `<span style="background: linear-gradient(135deg, #FFD700, #FFA000); color: #333; font-size: 10px; font-weight: bold; padding: 2px 8px; border-radius: 10px;">💎 ${escapeHtml(prompt.price)}</span>`
763
- : `<span style="background: rgba(76, 175, 80, 0.25); color: #4CAF50; font-size: 10px; font-weight: bold; padding: 2px 8px; border-radius: 10px; border: 1px solid rgba(76, 175, 80, 0.5);">${t('prompt.free')}</span>`;
764
 
765
  card.innerHTML = `
766
  <!-- 封面图 -->
767
- <div style="position: relative; width: 100%; padding-top: 100%; background: #111;">
768
- <img src="${escapeHtml(prompt.cover_image || 'data:image/svg+xml,...')}"
769
- style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;"
770
- alt="">
771
  ${isVideo ? `
772
  <!-- 视频播放按钮 -->
773
- <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
774
- width: 48px; height: 48px; background: rgba(0,0,0,0.6); border-radius: 50%;
775
- display: flex; align-items: center; justify-content: center; pointer-events: none;">
776
- <div style="width: 0; height: 0; border-left: 16px solid rgba(255,255,255,0.9);
777
- border-top: 10px solid transparent; border-bottom: 10px solid transparent;
778
- margin-left: 4px;"></div>
779
  </div>
780
  ` : ''}
781
  <!-- 价格徽标 -->
782
- <div style="position: absolute; top: 8px; right: 8px; pointer-events: none;">
783
  ${priceHtml}
784
  </div>
785
  <!-- 🔒 付费锁标识 -->
786
  ${prompt.price > 0 ? `
787
- <div style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.6); border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; pointer-events: none;">🔒</div>
788
  ` : ''}
789
  </div>
790
-
791
  <!-- 内容区 -->
792
- <div style="padding: 10px;">
793
  <!-- 标题 -->
794
  <button type="button" class="business-list-card__title">
795
  ${escapeHtml(prompt.title)}
796
  </button>
797
-
798
  <!-- 分类标签 -->
799
- <div style="margin-bottom: 6px;">
800
- <span style="display: inline-block; background: rgba(118, 75, 162, 0.25); border: 1px solid rgba(118, 75, 162, 0.5); color: #CE93D8; font-size: 10px; padding: 2px 8px; border-radius: 10px;">${escapeHtml(_catLabel(prompt.category || ''))}</span>
801
- ${(Array.isArray(prompt.tags) && prompt.tags.length) ? prompt.tags.slice(0, 3).map(tag => `<span style="display: inline-block; background: rgba(0,188,212,0.12); border: 1px solid rgba(0,188,212,0.35); color: #4DD0E1; font-size: 10px; padding: 2px 6px; border-radius: 10px; margin-left: 4px;">#${escapeHtml(tag)}</span>`).join('') : ''}
802
  </div>
803
-
804
  <!-- 作者信息(SWR 缓存头像) -->
805
- <div class="business-list-card__author" style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;"></div>
806
-
807
  <!-- 互动数据 -->
808
- <div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #888));">
809
  <span>❤️ ${escapeHtml(prompt.likes || 0)}</span>
810
  <span>🔖 ${escapeHtml(prompt.favorites || 0)}</span>
811
  <span>🔥 ${escapeHtml(prompt.views || 0)}</span>
812
- <span style="margin-left: auto; font-size: 10px;">${escapeHtml(timeStr)}</span>
813
  </div>
814
  </div>
815
  `;
@@ -826,9 +811,9 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
826
  const name = cached?.name || account || '';
827
 
828
  // 渲染初始头像
829
- const avatarHtml = _generateAvatarHtml(avatar, name, 18);
830
 
831
- authorContainer.innerHTML = `${avatarHtml}<span class="swr-name" style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #999)); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(name)}</span>`;
832
 
833
  // 后台静默校对
834
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
@@ -839,7 +824,7 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
839
  if (avatarEl.tagName === 'IMG') {
840
  avatarEl.src = profile.avatar;
841
  } else {
842
- avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name || name, 18);
843
  }
844
  }
845
  if (nameEl && profile.name) {
 
95
  /**
96
  * 🖼️ 生成头像 HTML 字符串
97
  */
98
+ function _generateAvatarHtml(avatar, name) {
99
  const initial = (name || 'U')[0].toUpperCase();
100
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
101
  if (avatar) {
102
+ return `<img class="swr-avatar prompt-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
103
  }
104
+ return `<div class="swr-avatar prompt-card__avatar prompt-card__avatar--initial">${escapeHtml(initial)}</div>`;
 
105
  }
106
 
107
  /**
 
740
  */
741
  function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
742
  const card = document.createElement("div");
743
+ card.className = "business-list-card prompt-card";
 
 
 
 
 
 
 
 
744
 
745
  // 格式化时间
746
  const timeStr = formatTime(prompt.created_at);
 
748
  // 判断是否为视频封面
749
  const isVideo = prompt.media_type === "video";
750
 
751
+ // 价格徽标
752
  const priceHtml = (prompt.price > 0)
753
+ ? `<span class="prompt-card__badge prompt-card__badge--paid">💎 ${escapeHtml(prompt.price)}</span>`
754
+ : `<span class="prompt-card__badge prompt-card__badge--free">${t('prompt.free')}</span>`;
755
 
756
  card.innerHTML = `
757
  <!-- 封面图 -->
758
+ <div class="prompt-card__cover">
759
+ <img class="prompt-card__image" src="${escapeHtml(prompt.cover_image || 'data:image/svg+xml,...')}" alt="">
 
 
760
  ${isVideo ? `
761
  <!-- 视频播放按钮 -->
762
+ <div class="prompt-card__play">
763
+ <div class="prompt-card__play-icon"></div>
 
 
 
 
764
  </div>
765
  ` : ''}
766
  <!-- 价格徽标 -->
767
+ <div class="prompt-card__price">
768
  ${priceHtml}
769
  </div>
770
  <!-- 🔒 付费锁标识 -->
771
  ${prompt.price > 0 ? `
772
+ <div class="prompt-card__lock">🔒</div>
773
  ` : ''}
774
  </div>
775
+
776
  <!-- 内容区 -->
777
+ <div class="prompt-card__body">
778
  <!-- 标题 -->
779
  <button type="button" class="business-list-card__title">
780
  ${escapeHtml(prompt.title)}
781
  </button>
782
+
783
  <!-- 分类标签 -->
784
+ <div class="prompt-card__tags">
785
+ <span class="prompt-card__badge prompt-card__badge--category">${escapeHtml(_catLabel(prompt.category || ''))}</span>
786
+ ${(Array.isArray(prompt.tags) && prompt.tags.length) ? prompt.tags.slice(0, 3).map(tag => `<span class="prompt-card__badge prompt-card__badge--tag">#${escapeHtml(tag)}</span>`).join('') : ''}
787
  </div>
788
+
789
  <!-- 作者信息(SWR 缓存头像) -->
790
+ <div class="business-list-card__author prompt-card__author"></div>
791
+
792
  <!-- 互动数据 -->
793
+ <div class="prompt-card__stats">
794
  <span>❤️ ${escapeHtml(prompt.likes || 0)}</span>
795
  <span>🔖 ${escapeHtml(prompt.favorites || 0)}</span>
796
  <span>🔥 ${escapeHtml(prompt.views || 0)}</span>
797
+ <span class="prompt-card__time">${escapeHtml(timeStr)}</span>
798
  </div>
799
  </div>
800
  `;
 
811
  const name = cached?.name || account || '';
812
 
813
  // 渲染初始头像
814
+ const avatarHtml = _generateAvatarHtml(avatar, name);
815
 
816
+ authorContainer.innerHTML = `${avatarHtml}<span class="swr-name prompt-card__name">${escapeHtml(name)}</span>`;
817
 
818
  // 后台静默校对
819
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
 
824
  if (avatarEl.tagName === 'IMG') {
825
  avatarEl.src = profile.avatar;
826
  } else {
827
+ avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name || name);
828
  }
829
  }
830
  if (nameEl && profile.name) {
前端页面/social/未读角标工具.js CHANGED
@@ -1,4 +1,4 @@
1
- import { captureRequestSession } from "../core/网络请求API.js";
2
  import { getUser } from "../core/状态管理.js";
3
 
4
  const badgeGenerations = new WeakMap();
@@ -25,3 +25,14 @@ export function createUnreadBadgeUpdate(button, selector, account) {
25
  export function validUnreadCount(value) {
26
  return Number.isSafeInteger(value) && value >= 0 ? value : 0;
27
  }
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api, captureRequestSession } from "../core/网络请求API.js";
2
  import { getUser } from "../core/状态管理.js";
3
 
4
  const badgeGenerations = new WeakMap();
 
25
  export function validUnreadCount(value) {
26
  return Number.isSafeInteger(value) && value >= 0 ? value : 0;
27
  }
28
+
29
+ export async function loadUnreadCount(currentUser, bellBtn) {
30
+ const update = createUnreadBadgeUpdate(bellBtn, '#unread-badge', currentUser?.account);
31
+ if (!currentUser?.account || !bellBtn?.querySelector("#unread-badge")) return;
32
+ try {
33
+ const res = await api.getUnreadCount(currentUser.account);
34
+ if (res?.status === "success" && Number.isSafeInteger(res.unread_count) && res.unread_count >= 0) {
35
+ update.render(res.unread_count);
36
+ }
37
+ } catch (_) {}
38
+ }
前端页面/social/通知中心组件.js CHANGED
@@ -1,5 +1,6 @@
1
  // 前端页面/social/通知中心组件.js
2
- import { createUnreadBadgeUpdate } from "./未读角标工具.js";
 
3
  import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
4
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
5
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
@@ -275,14 +276,3 @@ export async function openNotificationCenter(currentUser, bellBtn) {
275
  } catch (_) { if (scope.isCurrent() && messages.length === 0) listArea.innerHTML = `<div class="notification-status" role="alert">${t('common.error')}</div>`; }
276
 
277
  }
278
-
279
- export async function loadUnreadCount(currentUser, bellBtn) {
280
- const update = createUnreadBadgeUpdate(bellBtn, '#unread-badge', currentUser?.account);
281
- if (!currentUser?.account || !bellBtn?.querySelector("#unread-badge")) return;
282
- try {
283
- const res = await api.getUnreadCount(currentUser.account);
284
- if (res?.status === "success" && Number.isSafeInteger(res.unread_count) && res.unread_count >= 0) {
285
- update.render(res.unread_count);
286
- }
287
- } catch (_) {}
288
- }
 
1
  // 前端页面/social/通知中心组件.js
2
+ import { loadUnreadCount } from "./未读角标工具.js";
3
+ export { loadUnreadCount };
4
  import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
5
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
6
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
 
276
  } catch (_) { if (scope.isCurrent() && messages.length === 0) listArea.innerHTML = `<div class="notification-status" role="alert">${t('common.error')}</div>`; }
277
 
278
  }
 
 
 
 
 
 
 
 
 
 
 
前端页面/styles/主题基础.css CHANGED
@@ -1350,6 +1350,7 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
1350
  .ranking-publish__field--wide { flex: 2 1 200px; min-width: 0; }
1351
  .ranking-publish label:not(.ranking-publish__checkbox) { display: block; margin-bottom: 6px; color: inherit; font-weight: 600; overflow-wrap: anywhere; }
1352
  .ranking-publish__required { color: var(--rk-danger, #ef5350); }
 
1353
  .ranking-publish .form-input { display: block; width: 100%; min-width: 0; min-height: 36px; padding: 8px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 1px solid var(--rk-border, var(--border-color, #555)); color: inherit; border-radius: var(--rk-radius-sm, 6px); font: inherit; line-height: 1.5; }
1354
  .ranking-publish .form-input::placeholder { color: var(--rk-muted, var(--descrip-text, #a4a4a4)); opacity: 1; }
1355
  .ranking-publish__panel { min-width: 0; padding: 12px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
@@ -1451,6 +1452,19 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
1451
  @media (hover: hover) { .resource-detail__button:hover:not(:disabled) { border-color: var(--rk-focus, #8ab4ff); } }
1452
  @media (prefers-reduced-motion: reduce) { .resource-detail__button { transition: none; } }
1453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1454
  /* Install engine runtime UI also appears outside list-card details. */
1455
  .resource-install__status { min-width: 0; color: var(--rk-text, var(--fg-color, #ddd)); overflow-wrap: anywhere; }
1456
  .resource-install__confirmation { min-width: 0; }
@@ -1731,3 +1745,41 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
1731
  .comfy-ranking-request-diagnostic button[aria-busy="true"] { opacity: .65; }
1732
  .comfy-ranking-request-diagnostic__feedback { margin: 6px 0 0; overflow-wrap: anywhere; }
1733
  .comfy-ranking-request-diagnostic summary:focus-visible { outline: 2px solid var(--rk-focus); outline-offset: 3px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1350
  .ranking-publish__field--wide { flex: 2 1 200px; min-width: 0; }
1351
  .ranking-publish label:not(.ranking-publish__checkbox) { display: block; margin-bottom: 6px; color: inherit; font-weight: 600; overflow-wrap: anywhere; }
1352
  .ranking-publish__required { color: var(--rk-danger, #ef5350); }
1353
+ .ranking-publish__security-emphasis { color: var(--rk-danger, #ef5350); }
1354
  .ranking-publish .form-input { display: block; width: 100%; min-width: 0; min-height: 36px; padding: 8px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 1px solid var(--rk-border, var(--border-color, #555)); color: inherit; border-radius: var(--rk-radius-sm, 6px); font: inherit; line-height: 1.5; }
1355
  .ranking-publish .form-input::placeholder { color: var(--rk-muted, var(--descrip-text, #a4a4a4)); opacity: 1; }
1356
  .ranking-publish__panel { min-width: 0; padding: 12px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
 
1452
  @media (hover: hover) { .resource-detail__button:hover:not(:disabled) { border-color: var(--rk-focus, #8ab4ff); } }
1453
  @media (prefers-reduced-motion: reduce) { .resource-detail__button { transition: none; } }
1454
 
1455
+ .resource-page { display: flex; flex-direction: column; gap: 15px; flex: 1; min-width: 0; min-height: 0; box-sizing: border-box; padding: 15px; overflow-y: auto; color: var(--rk-text, var(--fg-color, #ddd)); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); font-size: 14px; overflow-wrap: anywhere; }
1456
+ .resource-page__back { align-self: flex-start; }
1457
+ .resource-page__section { min-width: 0; box-sizing: border-box; padding: 15px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius, 8px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1458
+ .resource-page__heading { margin-bottom: 15px; font-size: 16px; font-weight: 600; line-height: 1.4; }
1459
+ .resource-page__description { line-height: 1.8; font-size: 13px; white-space: pre-wrap; }
1460
+ .resource-page__info { line-height: 1.6; }
1461
+ .resource-page__author { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; min-width: 0; }
1462
+ .resource-page__author > div { min-width: 0; max-width: 100%; }
1463
+ .resource-page__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
1464
+ .resource-page__thanks { margin-top: 10px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); }
1465
+ .resource-page__tips { margin-top: 15px; padding-top: 15px; border-top: 1px dashed var(--rk-border, var(--border-color, #555)); }
1466
+ .resource-page__refund:not(:empty) { margin-top: 12px; }
1467
+
1468
  /* Install engine runtime UI also appears outside list-card details. */
1469
  .resource-install__status { min-width: 0; color: var(--rk-text, var(--fg-color, #ddd)); overflow-wrap: anywhere; }
1470
  .resource-install__confirmation { min-width: 0; }
 
1745
  .comfy-ranking-request-diagnostic button[aria-busy="true"] { opacity: .65; }
1746
  .comfy-ranking-request-diagnostic__feedback { margin: 6px 0 0; overflow-wrap: anywhere; }
1747
  .comfy-ranking-request-diagnostic summary:focus-visible { outline: 2px solid var(--rk-focus); outline-offset: 3px; }
1748
+
1749
+ .comfy-ranking-root .hub-inline-load-state {
1750
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1751
+ gap: 16px;
1752
+ flex: 1; min-height: 0; padding: 20px; overflow-wrap: anywhere;
1753
+ color: var(--rk-muted, var(--descrip-text, #a4a4a4));
1754
+ }
1755
+
1756
+ .ranking-settings-form .toggle-switch input:focus-visible + .toggle-slider { outline: 2px solid var(--rk-accent, #64b5f6); outline-offset: 3px; }
1757
+
1758
+ .prompt-card { min-width: 0; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 8px; background: var(--rk-panel, var(--comfy-menu-bg, #292929)); color: var(--rk-text, var(--fg-color, #ddd)); overflow: hidden; cursor: pointer; transition: transform .2s, box-shadow .2s; }
1759
+ .prompt-card__cover { position: relative; width: 100%; padding-top: 100%; background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1760
+ .prompt-card__image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
1761
+ .prompt-card__play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 48px; height: 48px; border-radius: 50%; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; pointer-events: none; }
1762
+ .prompt-card__play-icon { width: 0; height: 0; border-left: 16px solid white; border-top: 10px solid transparent; border-bottom: 10px solid transparent; margin-left: 4px; }
1763
+ .prompt-card__price { position: absolute; top: 8px; right: 8px; max-width: calc(100% - 16px); pointer-events: none; }
1764
+ .prompt-card__badge { display: inline-block; box-sizing: border-box; min-width: 0; max-width: 100%; padding: 2px 8px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 10px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: var(--rk-text, var(--fg-color, #ddd)); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
1765
+ .prompt-card__badge--paid { border-color: var(--rk-warning, #d97706); font-weight: bold; }
1766
+ .prompt-card__badge--free { border-color: var(--rk-success, #4caf50); font-weight: bold; }
1767
+ .prompt-card__badge--category { border-color: var(--rk-accent, #3b82f6); }
1768
+ .prompt-card__badge--tag { padding-inline: 6px; }
1769
+ .prompt-card__lock { position: absolute; bottom: 8px; right: 8px; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 50%; background: var(--rk-panel, var(--comfy-input-bg, #292929)); font-size: 12px; pointer-events: none; }
1770
+ .prompt-card__body { padding: 10px; min-width: 0; }
1771
+ .prompt-card .business-list-card__title { min-height: 28px; }
1772
+ .prompt-card .business-list-card__title:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
1773
+ .prompt-card__tags { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; margin-bottom: 6px; }
1774
+ .prompt-card__author { display: flex; align-items: center; gap: 6px; min-width: 0; margin-bottom: 6px; }
1775
+ .prompt-card__avatar { box-sizing: border-box; width: 18px; height: 18px; flex: 0 0 18px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 50%; object-fit: cover; background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1776
+ .prompt-card__avatar--initial { display: flex; align-items: center; justify-content: center; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 9px; font-weight: bold; }
1777
+ .prompt-card__name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); }
1778
+ .prompt-card__stats { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; min-width: 0; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); overflow-wrap: anywhere; }
1779
+ .prompt-card__stats > span { min-width: 0; max-width: 100%; }
1780
+ .prompt-card__time { margin-left: auto; font-size: 10px; }
1781
+ @media (prefers-reduced-motion: reduce) { .prompt-card { transition: none; } }
1782
+ @media (forced-colors: active) {
1783
+ .prompt-card__badge, .prompt-card__lock, .prompt-card__play { background: Canvas; color: CanvasText; border: 1px solid CanvasText; }
1784
+ .prompt-card__play-icon { border-left-color: CanvasText; }
1785
+ }